Merge pull request 'refactor' (#22) from refactor into main

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
h z
2026-04-10 07:49:55 +00:00
69 changed files with 6220 additions and 3259 deletions

393
DESIGN.md Normal file
View File

@@ -0,0 +1,393 @@
# Dirigent — Design Spec (v2)
## Overview
Dirigent is an OpenClaw plugin that orchestrates turn-based multi-agent conversations in Discord. It manages who speaks when, prevents out-of-turn responses, and coordinates structured discussions between agents.
**Optional integrations** (Dirigent must function fully without either):
- **padded-cell** — enables auto-registration of agent identities from `ego.json`
- **yonexus** — enables cross-instance multi-agent coordination (see §8)
---
## 1. Identity Registry
### Storage
A JSON file (path configurable via plugin config, default `~/.openclaw/dirigent-identity.json`).
Each entry:
```json
{
"discordUserId": "123456789012345678",
"agentId": "home-developer",
"agentName": "Developer"
}
```
### Registration Methods
#### Manual — Tool
Agents call `dirigent-register` to add or update their own entry. `agentId` is auto-derived from the calling session; the agent only provides `discordUserId` and optionally `agentName`.
#### Manual — Control Page
The `/dirigent` control page exposes a table with inline add, edit, and delete.
#### Auto — padded-cell Integration
On gateway startup, if padded-cell is loaded, Dirigent reads `~/.openclaw/ego.json`.
**Detection**: check whether `ego.json`'s `columns` array contains `"discord-id"`. If not, treat padded-cell as absent and skip auto-registration entirely.
**ego.json structure** (padded-cell's `EgoData` format):
```json
{
"columns": ["discord-id", "..."],
"publicColumns": ["..."],
"publicScope": {},
"agentScope": {
"home-developer": { "discord-id": "123456789012345678" },
"home-researcher": { "discord-id": "987654321098765432" }
}
}
```
**Scan logic**:
1. If `columns` does not include `"discord-id"`: skip entirely.
2. For each key in `agentScope`: key is the `agentId`.
3. Read `agentScope[agentId]["discord-id"]`. If present and non-empty: upsert into identity registry (existing entries preserved, new ones appended).
4. Agent name defaults to `agentId` if no dedicated name column exists.
The control page shows a **Re-scan padded-cell** button when padded-cell is detected.
---
## 2. Channel Modes
**Default**: any channel Dirigent has not seen before is treated as `none`.
| Mode | Description | How to set |
|------|-------------|------------|
| `none` | No special behavior. Turn-manager disabled. | Default · `/set-channel-mode none` · control page |
| `work` | Agent workspace channel. Turn-manager disabled. | `create-work-channel` tool only |
| `report` | Agents post via message tool only; not woken by incoming messages. | `create-report-channel` tool · `/set-channel-mode report` · control page |
| `discussion` | Structured agent discussion. | `create-discussion-channel` tool only |
| `chat` | Ongoing multi-agent chat. | `create-chat-channel` tool · `/set-channel-mode chat` · control page |
**Mode-change restrictions**:
- `work` and `discussion` are locked — only settable at channel creation by their respective tools. Cannot be changed to another mode; no other mode can be changed to them.
- `none`, `chat`, and `report` are freely switchable via `/set-channel-mode` or the control page.
### Mode → Turn-Manager State
| Mode | Agent Count | Turn-Manager State |
|------|-------------|-------------------|
| `none` | any | `disabled` |
| `work` | any | `disabled` |
| `report` | any | `dead` |
| `discussion` | 1 | `disabled` |
| `discussion` | 2 | `normal` |
| `discussion` | 3+ | `shuffle` |
| `discussion` | concluded | `archived` |
| `chat` | 1 | `disabled` |
| `chat` | 2 | `normal` |
| `chat` | 3+ | `shuffle` |
---
## 3. Channel Creation Tools & Slash Commands
### Tools
#### `create-chat-channel`
Creates a new Discord channel in the caller's guild and sets its mode to `chat`.
| Parameter | Description |
|-----------|-------------|
| `name` | Channel name |
| `participants` | Discord user IDs to add (optional; moderator bot always added) |
#### `create-report-channel`
Creates a new Discord channel and sets its mode to `report`.
| Parameter | Description |
|-----------|-------------|
| `name` | Channel name |
| `members` | Discord user IDs to add (optional) |
#### `create-work-channel`
Creates a new Discord channel and sets its mode to `work`. Mode is permanently locked.
| Parameter | Description |
|-----------|-------------|
| `name` | Channel name |
| `members` | Additional Discord user IDs to add (optional) |
#### `create-discussion-channel`
See §5 for full details.
#### `dirigent-register`
Registers or updates the calling agent's identity entry.
| Parameter | Description |
|-----------|-------------|
| `discordUserId` | The agent's Discord user ID |
| `agentName` | Display name (optional; defaults to agentId) |
### Slash Command — `/set-channel-mode`
Available in any Discord channel where the moderator bot is present.
```
/set-channel-mode <mode>
```
- Allowed values: `none`, `chat`, `report`
- Rejected with error: `work`, `discussion` (locked to creation tools)
- If the channel is currently `work` or `discussion`: command is rejected, mode is locked
---
## 4. Turn-Manager
### Per-Channel States
| State | Behavior |
|-------|----------|
| `disabled` | All turn-manager logic bypassed. Agents respond normally. |
| `dead` | Discord messages are not routed to any agent session. |
| `normal` | Speaker list rotates in fixed order. |
| `shuffle` | After the last speaker completes a full cycle, the list is reshuffled. Constraint: the previous last speaker cannot become the new first speaker. |
| `archived` | Channel is sealed. No agent is woken. New Discord messages receive a moderator auto-reply: "This channel is archived and no longer active." |
### Speaker List Construction
For `discussion` and `chat` channels:
1. Moderator bot fetches all Discord channel members via Discord API.
2. Each member's Discord user ID is resolved via the identity registry. Members identified as agents are added to the speaker list.
3. At each **cycle boundary** (after the last speaker in the list completes their turn), the list is rebuilt:
- Re-fetch current Discord channel members.
- In `normal` mode: existing members retain relative order; new agents are appended.
- In `shuffle` mode: the rebuilt list is reshuffled, with the constraint above.
### Turn Flow
#### `before_model_resolve`
1. Determine the active speaker for this channel (from turn-manager state).
2. Record the current channel's latest Discord message ID as an **anchor** (used later for delivery confirmation).
3. If the current agent is the active speaker: allow through with their configured model.
4. If not: route to `dirigent/no-reply` — response is suppressed.
#### `agent_end`
1. Check if the agent that finished is the active speaker. If not: ignore.
2. Extract the final reply text from `event.messages`: find the last message with `role === "assistant"`, then concatenate the `text` field from all `{type: "text"}` parts in its `content` array.
3. Classify the turn:
- **Empty turn**: text is `NO_REPLY`, `NO`, or empty/whitespace-only.
- **Real turn**: anything else.
4. Record the result for dormant tracking.
**If empty turn**: advance the speaker pointer immediately — no Discord delivery to wait for.
**If real turn**: wait for Discord delivery confirmation before advancing.
### Delivery Confirmation (Real Turns)
`agent_end` fires when OpenClaw has dispatched the message, not when Discord has delivered it. OpenClaw also splits long messages into multiple Discord messages — the next agent must not be triggered before the last fragment arrives.
**Tail-match polling**:
1. Take the last 40 characters of the final reply text as a **tail fingerprint**.
2. Poll `GET /channels/{channelId}/messages?limit=20` at a short interval, filtering to messages where:
- `message.id > anchor` (only messages from this turn onward)
- `message.author.id === agentDiscordUserId` (only from this agent's Discord account)
3. Take the most recent matching message. If its content ends with the tail fingerprint: match confirmed.
4. On match: advance the speaker pointer and post `{schedule_identifier}` then immediately delete it.
**Interruption**: if any message from a non-current-speaker appears in the channel during the wait, cancel the tail-match and treat the event as a wake-from-dormant (see below).
**Timeout**: if no match within 15 seconds (configurable), log a warning and advance anyway to prevent a permanently stalled turn.
**Fingerprint length**: 40 characters (configurable). The author + anchor filters make false matches negligible at this length.
### Dormant Stage
#### Definitions
- **Cycle**: one complete pass through the current speaker list from first to last.
- **Empty turn**: final reply text is `NO_REPLY`, `NO`, or empty/whitespace-only.
- **Cycle boundary**: the moment the last agent in the current list completes their turn.
#### Intent
Dormant stops the moderator from endlessly triggering agents when no one has anything to say. Entering dormant requires **unanimous** empty turns — any single real message is a veto and the cycle continues. When a new Discord message arrives (from a human or an agent via the message tool), it signals a new topic; the channel wakes and every agent gets another chance to respond.
#### Trigger
At each cycle boundary:
1. Re-fetch Discord channel members and build the new speaker list.
2. Check whether any new agents were added to the list.
3. Check whether **all agents who completed a turn in this cycle** sent empty turns.
Enter dormant **only if both hold**:
- All agents in the completed cycle sent empty turns.
- No new agents were added at this boundary.
If new agents joined: reset empty-turn tracking and start a fresh cycle — do not enter dormant even if all existing agents sent empty.
#### Dormant Behavior
- `currentSpeaker``null`.
- Empty-turn history is cleared.
- Moderator stops posting `{schedule_identifier}`.
#### Wake from Dormant
- **Trigger**: any new Discord message in the channel (human or agent via message tool).
- `currentSpeaker` → first agent in the speaker list.
- Moderator posts `{schedule_identifier}` then deletes it.
- A new cycle begins. Agents that have nothing to say emit empty turns; if all pass again, the channel returns to dormant.
#### Edge Cases
| Scenario | Behavior |
|----------|----------|
| Agent leaves mid-cycle | Turn is skipped; agent removed at next cycle boundary. Dormant check counts only agents who completed a turn. |
| New agent joins mid-cycle | Not added until next cycle boundary. Does not affect current dormant check. |
| Shuffle mode | Reshuffle happens after the dormant check at cycle boundary. Dormant logic is identical to `normal`. |
| Shuffle + new agents | New agents appended before reshuffling. Since new agents were found, dormant is suppressed; full enlarged list starts a new shuffled cycle. |
---
## 5. Discussion Mode
### Creation — `create-discussion-channel`
Called by an agent (the **initiator**). `initiator` is auto-derived from the calling session.
| Parameter | Description |
|-----------|-------------|
| `callback-guild` | Guild ID of the initiator's current channel. Error if moderator bot lacks admin in this guild. |
| `callback-channel` | Channel ID of the initiator's current channel. Error if not a Discord group channel. |
| `discussion-guide` | Minimum context: topic, goals, completion criteria. |
| `participants` | List of Discord user IDs for participating agents. |
### Discussion Lifecycle
```
Agent calls create-discussion-channel
Moderator creates new private Discord channel, adds participants
Moderator posts discussion-guide into the channel → wakes participant agents
Turn-manager governs the discussion (normal / shuffle based on participant count)
├─[dormant]──► Moderator posts reminder to initiator:
│ "Discussion is idle. Please summarize and call discussion-complete."
▼ initiator calls discussion-complete
Turn-manager state → archived
Moderator auto-replies to any new messages: "This discussion is closed."
Moderator posts summary file path to callback-channel
```
### `discussion-complete` Tool
| Parameter | Description |
|-----------|-------------|
| `discussion-channel` | Channel ID where the discussion took place |
| `summary` | File path to the summary (must be under `{workspace}/discussion-summary/`) |
Validation:
- Caller must be the initiator of the specified discussion channel. Otherwise: error.
- Summary file must exist at the given path.
---
## 6. Control Page — `/dirigent`
HTTP route registered on the OpenClaw gateway. Auth: `gateway` (requires the same Bearer token as the gateway API; returns 401 without it).
### Sections
#### Identity Registry
- Table: discord-user-id / agent-id / agent-name
- Inline add, edit, delete
- **Re-scan padded-cell** button (shown only when padded-cell is detected)
#### Guild & Channel Configuration
- Lists all Discord guilds where the moderator bot has admin permissions.
- For each guild: all private group channels.
- Per channel:
- Current mode badge
- Mode dropdown (`none | chat | report`) — hidden for `work` and `discussion` channels
- `work` and `discussion` channels display mode as a read-only badge
- Channels unknown to Dirigent display as `none`
- Current turn-manager state and active speaker name (where applicable)
---
## 7. Migration from v1
| v1 Mechanic | v2 Replacement |
|-------------|----------------|
| End symbol (`🔚`) required in agent replies | Removed — agents no longer need end symbols |
| `before_message_write` drives turn advance | Replaced by `agent_end` hook |
| Moderator posts visible handoff message each turn | Moderator posts `{schedule_identifier}` then immediately deletes it |
| NO_REPLY detected from `before_message_write` content | Derived from last assistant message in `agent_end` `event.messages` |
| Turn advances immediately on agent response | Empty turns advance immediately; real turns wait for Discord delivery confirmation via tail-match polling |
---
## 8. Yonexus Compatibility (Future)
> Yonexus is a planned cross-instance WebSocket communication plugin (hub-and-spoke). Dirigent must work fully without it.
### Topology
```
Instance A (master) Instance B (slave) Instance C (slave)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Dirigent │◄──Yonexus──►│ Dirigent │◄──Yonexus──►│ Dirigent │
│ (authority) │ │ (relay) │ │ (relay) │
└──────────────┘ └──────────────┘ └──────────────┘
Authoritative state:
- Identity registry
- Channel modes & turn-manager states
- Speaker lists & turn pointers
- Discussion metadata
```
### Master / Slave Roles
**Master**:
- Holds all authoritative state.
- Serves read/write operations to slaves via Yonexus message rules.
- Executes all moderator bot actions (post/delete `{schedule_identifier}`, send discussion-guide, etc.).
**Slave**:
- No local state for shared channels.
- `before_model_resolve`: queries master to determine if this agent is the active speaker.
- `agent_end`: notifies master that the turn is complete (`agentId`, `channelId`, `isEmpty`).
- Master handles all speaker advancement and moderator actions.
### Message Rules (provisional)
```
dirigent::check-turn → { allowed: bool, currentSpeaker: string }
dirigent::turn-complete → { agentId, channelId, isEmpty }
dirigent::get-identity → identity registry entry for discordUserId
dirigent::get-channel-state → { mode, tmState, currentSpeaker }
```
### Constraints
- Without Yonexus: Dirigent runs in standalone mode with all state local.
- Role configured via plugin config: `dirigentRole: "master" | "slave"` (default: `"master"`).
- Slave instances skip all local state mutations.
- Identity registry, channel config, and control page are only meaningful on the master instance.

View File

@@ -1,4 +1,4 @@
.PHONY: check check-rules test-api up down smoke render-config package-plugin
.PHONY: check check-rules check-files smoke install
check:
cd plugin && npm run check
@@ -6,21 +6,11 @@ check:
check-rules:
node scripts/validate-rules.mjs
test-api:
node scripts/test-no-reply-api.mjs
up:
./scripts/dev-up.sh
down:
./scripts/dev-down.sh
check-files:
node scripts/check-plugin-files.mjs
smoke:
./scripts/smoke-no-reply-api.sh
render-config:
node scripts/render-openclaw-config.mjs
package-plugin:
node scripts/package-plugin.mjs
install:
node scripts/install.mjs --install

181
README.md
View File

@@ -1,136 +1,117 @@
# Dirigent
Rule-based no-reply gate + turn manager for OpenClaw (Discord).
Turn-management and moderation plugin for OpenClaw (Discord).
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
## What it does
Dirigent adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels:
Dirigent adds deterministic routing and **turn-based speaking** for multi-agent Discord channels:
- **Rule gate (before_model_resolve)**
1. Non-Discord → skip
2. Sender in bypass list / human list → skip
3. Message ends with configured end symbol → skip
4. Otherwise → route to no-reply model/provider
- **Rule gate (`before_model_resolve`)**
- Non-speaker agents → routed to no-reply model (silent turn)
- Dormant channels → all agents suppressed until a human message wakes them
- Configurable per-channel mode: `chat`, `work`, `discussion`, `report`, `none`
- **End-symbol enforcement**
- Injects instruction: `Your response MUST end with 🔚…`
- In group chats, also injects: "If not relevant, reply NO_REPLY"
- **Turn management**
- Only the current speaker responds; others are silenced
- Turn advances when the current speaker ends with a configured symbol or returns `NO_REPLY`
- Full round of silence → channel enters **dormant** state
- Human message → wakes dormant channel, triggers first speaker
- **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
- **Moderator bot sidecar**
- Dedicated Discord Gateway connection (separate bot token) for real-time message push
- Sends schedule-trigger messages (`<@USER_ID>➡️`) to signal speaker turns
- Notifies the plugin via HTTP callback on new messages (wake/interrupt)
- **Turn-based speaking (multi-bot)**
- Only the current speaker is allowed to respond
- Others are forced to no-reply
- Turn advances on **end-symbol** or **NO_REPLY**
- If all bots NO_REPLY, channel becomes **dormant** until a new human message
- **Discussion mode**
- Agents can initiate a structured discussion via `create-discussion-channel` tool
- Initiator calls `discussion-complete` to conclude; summary is posted to the callback channel
- **Agent identity injection**
- Injects agent name, Discord accountId, and Discord userId into group chat prompts
- **Human @mention override**
- When a `humanList` user @mentions agents, temporarily overrides turn order
- Only mentioned agents cycle; original order restores when cycle completes
- **Per-channel policy runtime**
- Policies stored in a standalone JSON file
- Update at runtime via `dirigent_policy_set` / `dirigent_policy_delete` tools
- **Discord control actions (optional)**
- Private channel create/update + member list
- Via `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` tools
- **Channel management tools**
- `create-chat-channel`, `create-work-channel`, `create-report-channel` — create typed channels
- `create-discussion-channel`, `discussion-complete` — discussion lifecycle
- `dirigent-register` — register an agent identity
---
## Repo layout
- `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence)
- `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY`
- Discord admin actions are now handled in-plugin via direct Discord REST API calls (no sidecar service)
- `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`)
- `CHANGELOG.md` — milestone summary
```
plugin/ OpenClaw plugin (hooks, tools, commands, web UI)
core/ Channel store, identity registry, moderator REST helpers
hooks/ before_model_resolve, agent_end, message_received
tools/ Agent-facing tools
commands/ Slash commands
web/ Control page + Dirigent API (HTTP routes)
services/ Sidecar process — spawned automatically by the plugin
main.mjs Unified entry point, routes /no-reply/* and /moderator/*
no-reply-api/ OpenAI-compatible server that always returns NO_REPLY
moderator/ Discord Gateway client + HTTP control endpoints
scripts/ Dev helpers
docs/ Architecture and integration notes
```
---
## Quick start (no Docker)
## Installation
```bash
cd no-reply-api
node server.mjs
node scripts/install.mjs --install
```
Then render config snippet:
This copies `plugin/` and `services/` into the OpenClaw plugin directory and registers skills.
---
## Sidecar
The sidecar (`services/main.mjs`) is spawned automatically when openclaw-gateway starts. It exposes:
| Path prefix | Description |
|---|---|
| `/no-reply/*` | No-reply model API (`/v1/chat/completions`, `/v1/responses`) |
| `/moderator/*` | Moderator bot control (`/send`, `/create-channel`, `/me`, …) |
| `/health` | Combined health check |
Port is configured via `sideCarPort` (default `8787`).
Smoke-test after gateway start:
```bash
node scripts/render-openclaw-config.mjs
```
See `docs/RUN_MODES.md` for Docker mode.
Discord extension capabilities: `docs/DISCORD_CONTROL.md`.
---
## Runtime tools & commands
### Tools (6 individual tools)
**Discord control:**
- `dirigent_discord_channel_create` — Create private channel
- `dirigent_discord_channel_update` — Update channel permissions
- `dirigent_discord_member_list` — List guild members
**Policy management:**
- `dirigent_policy_get` — Get all policies
- `dirigent_policy_set` — Set/update channel policy
- `dirigent_policy_delete` — Delete channel policy
> Turn management is internal to the plugin (not exposed as tools).
> See `FEAT.md` for full feature documentation.
### Slash command (Discord)
```
/dirigent status
/dirigent turn-status
/dirigent turn-advance
/dirigent turn-reset
make smoke
# or:
./scripts/smoke-no-reply-api.sh
```
---
## Config highlights
## Plugin config
Common options (see `docs/INTEGRATION.md`):
Key options (in `openclaw.json` under `plugins.entries.dirigent.config`):
- `listMode`: `human-list` or `agent-list`
- `humanList`, `agentList`
- `endSymbols`
- `schedulingIdentifier` (default `➡️`)
- `waitIdentifier` (default `👤`) — agent ends with this to pause all agents until human replies
- `channelPoliciesFile` (per-channel overrides)
- `moderatorBotToken` (handoff messages)
- `enableDebugLogs`, `debugLogChannelIds`
| Key | Default | Description |
|---|---|---|
| `moderatorBotToken` | — | Discord bot token for the moderator/sidecar bot |
| `scheduleIdentifier` | `➡️` | Symbol appended to schedule-trigger mentions |
| `listMode` | `human-list` | `human-list` or `agent-list` |
| `humanList` | `[]` | Discord user IDs treated as humans (bypass turn gate) |
| `agentList` | `[]` | Discord user IDs treated as agents (when `listMode=agent-list`) |
| `noReplyProvider` | `dirigent` | Provider ID for the no-reply model |
| `noReplyModel` | `no-reply` | Model ID for the no-reply model |
| `sideCarPort` | `8787` | Port the sidecar listens on |
| `debugMode` | `false` | Enable verbose debug logging |
| `debugLogChannelIds` | `[]` | Channel IDs that receive debug log messages |
| `channelPoliciesFile` | `~/.openclaw/dirigent-channel-policies.json` | Per-channel policy overrides |
---
## Development plan (incremental commits)
## Dev commands
- [x] Task 1: project docs + structure
- [x] Task 2: no-reply API MVP
- [x] Task 3: plugin MVP with rule chain
- [x] Task 4: sample config + quick verification scripts
- [x] Task 5: plugin rule extraction + hardening
- [x] Task 6: containerization + compose
- [x] Task 7: plugin usage notes
- [x] Task 8: sender normalization + TTL + one-shot decision
- [x] Task 9: auth-aware no-reply API
- [x] Task 10: smoke test helpers
- [x] Task 11: plugin structure checker
- [x] Task 12: rollout checklist
```bash
make check # TypeScript check (plugin/)
make check-rules # Validate rule-case fixtures
make check-files # Verify required files exist
make smoke # Smoke-test no-reply endpoint (sidecar must be running)
make install # Install plugin + sidecar into OpenClaw
```

281
TEST-PLAN.md Normal file
View File

@@ -0,0 +1,281 @@
# Dirigent 插件测试计划
> 版本v0.3.x | 测试环境OpenClaw 2026.4.9(待升级)
---
## 测试架构说明
**参与者**
- `main``home-developer``test-ph0`:测试 agent
- `CT-Moderator`:主持人 bot发送/删除调度消息)
- `Proxy Bot`:模拟人类用户,所有测试中需要"人工发消息"的操作均通过 Proxy Bot 完成
**核心机制**
- `before_model_resolve`:轮次门控(非当前 speaker → NO_REPLY
- `agent_end`:推进下一轮次,含 tail-match 轮询确认消息已发送
- `message_received`:外部消息处理(唤醒休眠、中断 tail-match、已结束频道自动回复
---
## 一、前置检查
| # | 检查项 | 期望结果 |
|---|--------|---------|
| 1.1 | OpenClaw 升级到 2026.4.9gateway 正常启动 | 日志无报错 |
| 1.2 | Dirigent 插件 deploy`cp -r plugin/* ~/.openclaw/plugins/dirigent/` | 日志显示 `dirigent: plugin registered (v2)` |
| 1.3 | `dirigent-identity.json` 确认三个 agent 均已注册 | `main``home-developer``test-ph0` 均有 `agentId``discordUserId` 映射 |
| 1.4 | Proxy Bot 在目标 guild 内可见,有发消息权限 | 能在私有频道中发消息 |
---
## 二、Chat 模式报数测试2 个 agent
> **目的:** 验证 2-agent 轮次调度和休眠dormant机制
### 2.1 创建频道并发起报数
**步骤:**
1. 在 Discord 中创建一个新的私有文字频道,将 `main``home-developer` 的 bot 账号及 CT-Moderator 加入
2.`127.0.0.1:18789/dirigent` 控制页面中将该频道设置为 **chat** 模式
3. Proxy Bot 在频道中发送指令:
> 请报数,从 0 开始,每次回复你看到的最后一个数字 +1。超过 5 之后,所有人只能回复 `NO_REPLY`。
**期望(调度行为):**
- Proxy Bot 消息触发 `message_received` → speaker list 初始化
- CT-Moderator 发送 `<@discordUserId>➡️` 后立即删除
- `main` 先发言回复 `1`CT-Moderator 触发 `home-developer`
- `home-developer` 回复 `2`CT-Moderator 再次触发 `main`
- 如此交替,直到某 agent 回复 `6`(超过 5
**期望(休眠行为):**
- 两个 agent 均回复 `NO_REPLY` 后,日志显示 `entered dormant`
- Chat 模式:**不发送**空闲提醒
- 频道无任何后续消息
**期望聊天记录:**
```
Proxy Bot: 请报数,从 0 开始 ...
main: 1
home-developer: 2
main: 3
home-developer: 4
main: 5
home-developer: 6
main: NO_REPLY (静默,无 Discord 消息)
home-developer: NO_REPLY (静默,无 Discord 消息)
← 进入休眠,频道沉默
```
### 2.2 唤醒休眠并验证 Moderator 不触发再次唤醒
**前置:** 频道处于休眠
**步骤:**
1. Proxy Bot 在频道发任意消息
**期望:**
- 日志显示 `woke dormant channel`
- CT-Moderator 发调度消息触发第一个 speaker轮次恢复
- CT-Moderator 自身的调度消息**不触发**二次唤醒(日志无第二条 `woke dormant`
---
## 三、Chat 模式报数测试3 个 agent验证 Shuffle
> **目的:** 验证 3-agent shuffle 模式下轮次顺序在每个 cycle 结束后随机重排
### 3.1 创建频道并发起报数
**步骤:**
1. 在 Discord 中创建一个新的私有文字频道,将 `main``home-developer``test-ph0` 的 bot 账号及 CT-Moderator 加入
2.`127.0.0.1:18789/dirigent` 控制页面中将该频道设置为 **chat** 模式
3. Proxy Bot 在频道中发送指令:
> 请报数,从 0 开始,每次回复你看到的最后一个数字 +1。超过 7 之后,所有人只能回复 `NO_REPLY`。
**期望(调度行为):**
- 三个 agent 依次发言Cycle 1 顺序由初始化决定)
- 每轮 3 条消息为一个 cycle
- **Cycle 1 结束后**,下一个 cycle 的顺序应与上一个不同shuffle 生效)
- Shuffle 约束:上一个 cycle 的最后一个 speaker 不能成为下一个 cycle 的第一个 speaker
**验证 Shuffle**
- 观察 Discord 聊天记录,记录每 3 条消息的发言者顺序
- 至少经历 2 个完整 cycle确认顺序发生变化
- 日志中确认 3-agent 场景走 shuffle 分支
**期望(休眠行为):**
- 超过 7 后三个 agent 均 `NO_REPLY` → 日志显示 `entered dormant`
**期望聊天记录示例顺序仅供参考shuffle 后会不同):**
```
Proxy Bot: 请报数,从 0 开始 ...
[Cycle 1]
main: 1
home-developer: 2
test-ph0: 3
[Cycle 2 — shuffle 后顺序可能变化]
test-ph0: 4
main: 5
home-developer: 6
[Cycle 3]
home-developer: 7
test-ph0: 8 (超过 7
main: NO_REPLY
...
← 进入休眠
```
### 3.2 外部消息中断 tail-match
**步骤:**
1. 在某 agent 完成发言、tail-match 轮询期间(约 0-15s 内)
2. Proxy Bot 立即发一条消息
**期望:**
- 日志显示 `tail-match interrupted`
- 轮次正常推进至下一个 speaker不卡住
---
## 四、Discussion 模式测试(完整生命周期)
> **目的:** 验证 discussion 频道从创建到结束的全流程,包括 callback
### 4.1 创建 Channel AChat并通过 agent 发起讨论
**步骤:**
1. 在 Discord 中创建一个新的私有文字频道(即 **Channel A**),将 `main` 的 bot 账号及 CT-Moderator 加入
2.`127.0.0.1:18789/dirigent` 控制页面中将 Channel A 设置为 **chat** 模式
3. Proxy Bot 在 Channel A 发送指令:
> 请使用 `create-discussion-channel` 工具,邀请 `home-developer` 参与一个讨论,主题自定,讨论结束条件是达成至少 2 条共识。
**期望:**
- `main` 调用 `create-discussion-channel` 工具Discord 中出现新私有 **Discussion 频道**
- `dirigent-channels.json` 新增该频道记录mode=discussionconcluded=falseinitiatorAgentId=maincallbackChannelId=Channel A 的 ID
- CT-Moderator 在 Discussion 频道发送讨论指南discussionGuide
- CT-Moderator 发调度消息触发第一个 speaker
### 4.2 Discussion 轮次正常运转
**期望:**
- `main``home-developer` 在 Discussion 频道内交替发言
- 非当前 speaker 静默(`before_model_resolve` 返回 NO_REPLY
### 4.3 Discussion 休眠 → 空闲提醒
**触发条件:** 两个 agent 在同一 cycle 内均输出 NO_REPLY
**期望discussion 独有行为):**
- 日志显示 `entered dormant`
- CT-Moderator 在 Discussion 频道发送空闲提醒给 **initiator**main
```
<@main的discordUserId> Discussion is idle. Please summarize the results and call `discussion-complete`.
```
- 只发一次
### 4.4 `discussion-complete` 结束讨论 → Callback 验证
**步骤:** `main` 在 Discussion 频道中调用 `discussion-complete` 工具
**期望:**
- `dirigent-channels.json` 中该频道 `concluded` 变为 `true`
- CT-Moderator 在 **Channel A**callbackChannel发送
```
Discussion complete. Summary: /path/...
```
- Discussion 频道不再有任何 agent 发言
### 4.5 已结束 Discussion 频道:外部消息自动回复(单次,无循环)
**步骤:** Proxy Bot 在已结束的 Discussion 频道发一条消息
**期望:**
- CT-Moderator 回复**恰好一次**`This discussion is closed and no longer active.`
- CT-Moderator 自己的这条回复**不触发**新的 "closed" 回复(无限循环修复验证)
- 日志确认senderId 匹配 moderatorBotUserId → 跳过 concluded auto-reply
---
## 五、Report / Work 模式测试
### 5.1 创建 Report 频道
**操作:** 让任意 agent 调用 `create-report-channel`
**期望:**
- 频道创建成功
- Proxy Bot 在该频道发消息后agent 不响应mode=report → NO_REPLY
### 5.2 创建 Work 频道
**操作:** 让任意 agent 调用 `create-work-channel`
**期望:**
- 频道创建成功mode=worklocked
- 无轮次管理agent 自由响应
### 5.3 Locked Mode 不可更改
**操作:** 对 discussion/work 频道调用 `/set-channel-mode`
**期望:**
- 报错:`Channel is in locked mode`
---
## 六、边界条件 & 回归验证
| # | 场景 | 期望 |
|---|------|------|
| 6.1 | Gateway 重启后chat 频道收到 Proxy Bot 消息 | 重新初始化 speaker list轮次正常恢复 |
| 6.2 | Proxy Bot 连续快速发多条消息(压力测试) | blocked-pending 计数不超过 MAX=3不形成死循环 |
| 6.3 | 同一事件被多个 VM 上下文处理 | globalThis dedupBMR WeakSet / agent_end Set / concluded Set确保只执行一次 |
| 6.4 | `fetchVisibleChannelBotAccountIds` 返回空列表 | 不崩溃,日志警告,不发调度消息 |
---
## 七、日志关键词速查
正常流程应出现的日志:
```
dirigent: plugin registered (v2)
dirigent: initialized speaker list channel=... speakers=...
dirigent: before_model_resolve anchor set channel=...
dirigent: triggered next speaker agentId=...
dirigent: agent_end channel=... empty=false
dirigent: entered dormant
dirigent: woke dormant channel=...
dirigent: moderator message sent to channel=...
```
异常(需关注):
```
dirigent: tail-match timeout ← 15s 内消息未落地
dirigent: agent_end skipping stale ← 正常stale NO_REPLY 被过滤)
dirigent: before_model_resolve init in progress ← 并发初始化保护(正常)
```
---
## 八、测试顺序建议
```
前置检查 (§1)
2-agent 报数:轮次 + 休眠 + 唤醒 (§2)
3-agent 报数shuffle 验证 + tail-match 中断 (§3)
Discussion 完整生命周期:创建 → 轮次 → 空闲提醒 → 结束 → callback → 防循环 (§4)
Report/Work 频道 (§5)
边界条件 (§6)
```
---
*测试中如遇 agent 卡住超过 10 分钟,重启 gateway 后继续。偶发的 5-10 分钟响应延迟属正常Kimi 模型特性)。*

View File

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

View File

@@ -14,6 +14,9 @@
"agentList": [],
"endSymbols": ["🔚"],
"schedulingIdentifier": "➡️",
"multiMessageStartMarker": "↗️",
"multiMessageEndMarker": "↙️",
"multiMessagePromptMarker": "⤵️",
"channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json",
"noReplyProvider": "dirigentway",
"noReplyModel": "no-reply",

View File

@@ -58,6 +58,9 @@ Environment overrides:
- `CHANNEL_POLICIES_FILE` (standalone channel policy file path)
- `CHANNEL_POLICIES_JSON` (only used to initialize file when missing)
- `END_SYMBOLS_JSON`
- `MULTI_MESSAGE_START_MARKER`
- `MULTI_MESSAGE_END_MARKER`
- `MULTI_MESSAGE_PROMPT_MARKER`
The script:
- writes via `openclaw config set ... --json`
@@ -76,3 +79,5 @@ Policy state semantics:
- Keep no-reply API bound to loopback/private network.
- If you use API auth, set `AUTH_TOKEN` and align provider apiKey usage.
- Multi-message mode markers default to `↗️` / `↙️` / `⤵️` when no overrides are supplied.
- Shuffle mode is not configured globally in the current implementation; it is a per-channel runtime toggle controlled with `/dirigent turn-shuffling`, `/dirigent turn-shuffling on`, and `/dirigent turn-shuffling off`.

View File

@@ -1,2 +0,0 @@
node_modules
npm-debug.log

View File

@@ -1,7 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY server.mjs ./
EXPOSE 8787
ENV PORT=8787
CMD ["node", "server.mjs"]

View File

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

View File

@@ -1,9 +0,0 @@
{
"name": "dirigent-no-reply-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}

View File

@@ -1,112 +0,0 @@
import http from "node:http";
const port = Number(process.env.PORT || 8787);
const modelName = process.env.NO_REPLY_MODEL || "no-reply";
const authToken = process.env.AUTH_TOKEN || "";
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function isAuthorized(req) {
if (!authToken) return true;
const header = req.headers.authorization || "";
return header === `Bearer ${authToken}`;
}
function noReplyChatCompletion(reqBody) {
return {
id: `chatcmpl_dirigent_${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
choices: [
{
index: 0,
message: { role: "assistant", content: "NO_REPLY" },
finish_reason: "stop"
}
],
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 }
};
}
function noReplyResponses(reqBody) {
return {
id: `resp_dirigent_${Date.now()}`,
object: "response",
created_at: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "NO_REPLY" }]
}
],
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 }
};
}
function listModels() {
return {
object: "list",
data: [
{
id: modelName,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "dirigent"
}
]
};
}
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
return sendJson(res, 200, { ok: true, service: "dirigent-no-reply-api", model: modelName });
}
if (req.method === "GET" && req.url === "/v1/models") {
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
return sendJson(res, 200, listModels());
}
if (req.method !== "POST") {
return sendJson(res, 404, { error: "not_found" });
}
if (!isAuthorized(req)) {
return sendJson(res, 401, { error: "unauthorized" });
}
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) req.destroy();
});
req.on("end", () => {
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch {
return sendJson(res, 400, { error: "invalid_json" });
}
if (req.url === "/v1/chat/completions") {
return sendJson(res, 200, noReplyChatCompletion(parsed));
}
if (req.url === "/v1/responses") {
return sendJson(res, 200, noReplyResponses(parsed));
}
return sendJson(res, 404, { error: "not_found" });
});
});
server.listen(port, () => {
console.log(`[dirigent-no-reply-api] listening on :${port}`);
});

View File

@@ -6,7 +6,7 @@
"files": [
"dist/",
"plugin/",
"no-reply-api/",
"services/",
"docs/",
"scripts/install.mjs",
@@ -17,7 +17,8 @@
"TASKLIST.md"
],
"scripts": {
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/",
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/ && cp -r services dist/dirigent/services",
"test": "node --test --experimental-strip-types test/**/*.test.ts",
"postinstall": "node scripts/install.mjs --install",
"uninstall": "node scripts/install.mjs --uninstall",
"update": "node scripts/install.mjs --update"

View File

@@ -28,13 +28,13 @@
### 1.2 配置项
建议新增以下可配置项:
新增并已实现以下可配置项:
- `multiMessageStartMarker`:默认 `↗️`
- `multiMessageEndMarker`:默认 `↙️`
- `multiMessagePromptMarker`:默认 `⤵️`
这些配置加入插件 config schema并在运行时hook/turn-manager 使用。
这些配置加入插件 config schema并在运行时被 `message-received` / `before-message-write` / `before-model-resolve` 使用。
---
@@ -100,13 +100,17 @@ multi-message mode 应与 discussion channel / wait-for-human / no-reply 决策
并新增 slash command
- `/turn-shuffling on`
- `/turn-shuffling off`
- `/turn-shuffling`(查看当前状态)
- `/dirigent turn-shuffling on`
- `/dirigent turn-shuffling off`
- `/dirigent turn-shuffling`(查看当前状态)
该状态应与 channel 级 policy / runtime state 做清晰分工
- 若只是运行时开关,可放 runtime memory
- 若希望重启保留,则需要落盘策略
当前实现结论
- `shuffling`**channel 级 runtime state**,存放在 `plugin/core/channel-modes.ts`
- 默认值为 `false`
- 当前版本**不新增**全局 `shuffle default` 配置项
- 重启后会恢复为默认关闭,如需开启需要再次执行命令
这样与现有实现保持一致,也避免把一次性的实验性调度偏好混入全局静态配置。
---
@@ -194,15 +198,52 @@ multi-message mode 应与 discussion channel / wait-for-human / no-reply 决策
---
## 6. 结论
## 6. 实现状态
`feat/new-feat-notes` 分支中的内容可以整合为 `main` 下的一份独立规划文档,建议命名为
Multi-Message Mode 与 Shuffle Mode 已经在代码中实现,包括
- `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- Multi-Message Mode 实现:
- `plugin/core/channel-modes.ts` - 管理 channel 运行时状态
- `plugin/hooks/message-received.ts` - 检测 start/end marker 并切换模式
- `plugin/hooks/before-model-resolve.ts` - 在 multi-message mode 中强制 no-reply
- 配置项 `multiMessageStartMarker` (默认 `↗️`)、`multiMessageEndMarker` (默认 `↙️`)、`multiMessagePromptMarker` (默认 `⤵️`)
-`plugin/openclaw.plugin.json` 中添加了相应的配置 schema
其定位是
- 汇总 multi-message mode 与 shuffle mode 的产品行为
- 明确它们与现有 turn-manager / moderator / no-reply 机制的关系
- 为后续代码开发和 TASKLIST 拆分提供依据
- Shuffle Mode 实现
- `plugin/core/channel-modes.ts` - 管理 shuffle 状态
- `plugin/turn-manager.ts` - 在每轮结束后根据 shuffle 设置决定是否重洗牌
- `/dirigent turn-shuffling` slash command 实现,支持 `on`/`off`/`status` 操作
- 确保上一轮最后发言者不会在下一轮中成为第一位
- 当前行为是运行时开关,默认关闭,不落盘
后续应将对应开发任务补充进 `plans/TASKLIST.md`
## 7. 验收清单
### Multi-Message Mode 验收
- [x] 人类发送 start marker (`↗️`) 后进入 multi-message 模式
- [x] multi-message 模式中 Agent 被 no-reply 覆盖
- [x] 每条人类追加消息都触发 prompt marker (`⤵️`)
- [x] 人类发送 end marker (`↙️`) 后退出 multi-message 模式
- [x] 退出后 moderator 正确唤醒下一位 Agent
- [x] moderator prompt marker 不会触发回环
- [x] 与 waiting-for-human 模式兼容
- [x] 与 mention override 模式兼容
### Shuffle Mode 验收
- [x] `/dirigent turn-shuffling on/off` 命令生效
- [x] shuffling 关闭时 turn order 保持不变
- [x] shuffling 开启时每轮结束后会重洗牌
- [x] 上一轮最后发言者不会在下一轮中成为第一位
- [x] 双 Agent 场景行为符合预期
- [x] 单 Agent 场景不会异常
- [x] 与 dormant 状态兼容
- [x] 与 mention override 兼容
### 配置项验收
- [x] `multiMessageStartMarker` 配置项生效
- [x] `multiMessageEndMarker` 配置项生效
- [x] `multiMessagePromptMarker` 配置项生效
- [x] 配置项在 `plugin/openclaw.plugin.json` 中正确声明
## 8. 结论
Multi-Message Mode 与 Shuffle Mode 已成功集成到 Dirigent 插件中,与现有的 turn-manager、moderator handoff、no-reply override 机制协同工作,为用户提供更灵活的多 Agent 协作控制能力。

View File

@@ -195,7 +195,7 @@ moderator bot 的工作流完全不使用模型,所有输出均由模板字符
当检测到某 channel 为讨论模式 channel 时moderator bot 自动发 kickoff message。
建议内容结构如下:
建议内容结构如下(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`
```text
[Discussion Started]
@@ -210,7 +210,7 @@ Instructions:
2. Work toward a concrete conclusion.
3. When the initiator decides the goal has been achieved, the initiator must:
- write a summary document to a file
- call the tool: discuss-callback
- call the tool: discuss-callback(summaryPath)
- provide the summary document path
Completion rule:
@@ -255,10 +255,11 @@ After callback:
[Discussion Idle]
No agent responded in the latest discussion round.
If the discussion goal has been achieved, the initiator should now:
If the discussion goal has already been achieved, the initiator should now:
1. write the discussion summary to a file in the workspace
2. call discuss-callback with the summary file path
This reminder does not mean the discussion was automatically summarized or closed.
If more discussion is still needed, continue the discussion in this channel.
```
@@ -284,6 +285,7 @@ If more discussion is still needed, continue the discussion in this channel.
This discussion channel has been closed.
It is now kept for archive/reference only.
Further discussion in this channel is ignored.
If follow-up work is needed, continue it from the origin work channel instead.
```
这部分实现明确采用已有“在指定 session 上临时覆盖为 no-reply 模型”的方式,而不是修改 Agent 的全局默认模型。
@@ -331,7 +333,7 @@ Further discussion in this channel is ignored.
- 提供结果文档路径
- 用新消息唤醒原工作 channel 上的 Agent 继续执行
建议模板:
建议模板(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`
```text
[Discussion Result Ready]
@@ -473,6 +475,8 @@ moderator bot 在新 channel 中发布 kickoff message。
### 12.2 展开讨论
参与 Agent 在该 channel 中按现有顺序讨论机制发言。
讨论 channel 的 participant 集合继续复用现有 channel member bootstrap 逻辑:由 `plugin/core/turn-bootstrap.ts` 调用 `fetchVisibleChannelBotAccountIds(...)` 基于 Discord 可见成员与已有 account 映射发现可参与的 bot account再交给 `initTurnOrder(...)` 建立轮转状态,而不是为 discussion 模式额外维护一套成员发现流程。
如果轮转一圈无人发言,则 moderator 提醒发起者:
- 若已达成目标,请写总结文档并 callback

View File

@@ -3,342 +3,380 @@
## A. CSM / Discussion Callback
### A1. 需求与方案冻结
- [ ] 通读并确认 `plans/CSM.md` 中的 MVP 范围、非目标和边界条件
- [ ] 确认 CSM 第一版只新增一条对外工具:`discuss-callback`
- [ ] 确认 `discord_channel_create` 仅做参数扩展,不改变普通建频道行为
- [ ] 确认讨论结束后的 session 级 no-reply 覆盖继续沿用现有插件机制
- [ ] 确认 `summaryPath` 的合法范围仅限发起讨论 Agent 的 workspace
- [ ] 确认 discussion metadata 是否仅做内存态,还是需要落盘恢复
- [x] 通读并确认 `plans/CSM.md` 中的 MVP 范围、非目标和边界条件
- [x] 确认 CSM 第一版只新增一条对外工具:`discuss-callback`
- [x] 确认 `discord_channel_create` 仅做参数扩展,不改变普通建频道行为
- [x] 确认讨论结束后的 session 级 no-reply 覆盖继续沿用现有插件机制
- [x] 确认 `summaryPath` 的合法范围仅限发起讨论 Agent 的 workspace
- [x] 确认 discussion metadata 是否仅做内存态,还是需要落盘恢复
### A2. 模块拆分与落点确认
- [ ] 确认 `plugin/tools/register-tools.ts` 负责:
- [ ] 扩展 `discord_channel_create`
- [ ] 注册 `discuss-callback`
- [ ] 确认 `plugin/core/` 下新增 discussion metadata 管理模块
- [ ] 确认 `plugin/core/moderator-discord.ts` 继续负责 moderator 发消息能力
- [ ] 确认 `plugin/turn-manager.ts` 仅负责 turn 状态与轮转,不直接承担业务文案拼接
- [ ] 确认 discussion 业务编排逻辑应放在新模块,而不是散落到多个 hook 中
- [ ] 确认 origin callback 与 discussion close 逻辑的调用路径
- [. ] 确认 `plugin/tools/register-tools.ts` 负责:
- [. ] 扩展 `discord_channel_create`
- [. ] 注册 `discuss-callback`
- [. ] 确认 `plugin/core/` 下新增 discussion metadata 管理模块
- [x] 确认 `plugin/core/moderator-discord.ts` 继续负责 moderator 发消息能力
- [x] 确认 `plugin/turn-manager.ts` 仅负责 turn 状态与轮转,不直接承担业务文案拼接
- [x] 确认 discussion 业务编排逻辑应放在新模块,而不是散落到多个 hook 中
- [x] 确认 origin callback 与 discussion close 逻辑的调用路径
### A3. `plugin/tools/register-tools.ts`
#### A3.1 扩展 `discord_channel_create`
- [ ] 阅读当前 `discord_channel_create` 的参数定义与执行逻辑
- [ ]`discord_channel_create` 增加可选参数 `callbackChannelId`
- [ ]`discord_channel_create` 增加可选参数 `discussGuide`
- [ ] 添加联动校验:若传 `callbackChannelId`,则必须传 `discussGuide`
- [ ] 保证不传 `callbackChannelId` 时,行为与当前版本完全一致
- [ ] 创建成功后识别是否为 discussion 模式 channel
- [ ] 在 discussion 模式下调用 metadata 初始化逻辑
- [ ] 在 discussion 模式下调用 moderator kickoff 发送逻辑
- [x] 阅读当前 `discord_channel_create` 的参数定义与执行逻辑
- [x]`discord_channel_create` 增加可选参数 `callbackChannelId`
- [x]`discord_channel_create` 增加可选参数 `discussGuide`
- [x] 添加联动校验:若传 `callbackChannelId`,则必须传 `discussGuide`
- [x] 保证不传 `callbackChannelId` 时,行为与当前版本完全一致
- [x] 创建成功后识别是否为 discussion 模式 channel
- [x] 在 discussion 模式下调用 metadata 初始化逻辑
- [x] 在 discussion 模式下调用 moderator kickoff 发送逻辑
#### A3.2 注册 `discuss-callback`
- [ ] 定义 `discuss-callback` 的 parameters schema
- [ ] 注册新工具 `discuss-callback`
- [ ]`discuss-callback` 执行逻辑接到 discussion service / manager
- [ ] 为工具失败场景返回可读错误信息
- [x] 定义 `discuss-callback` 的 parameters schema
- [x] 注册新工具 `discuss-callback`
- [x]`discuss-callback` 执行逻辑接到 discussion service / manager
- [x] 为工具失败场景返回可读错误信息
### A4. `plugin/core/` 新增 discussion metadata/service 模块
#### A4.1 新建 metadata/state 模块
- [ ] 新建 discussion state 类型定义文件(如 `plugin/core/discussion-state.ts`
- [ ] 定义 discussion metadata 类型:
- [ ] `mode`
- [ ] `discussionChannelId`
- [ ] `originChannelId`
- [ ] `initiatorAgentId`
- [ ] `initiatorSessionId`
- [ ] `discussGuide`
- [ ] `status`
- [ ] `createdAt`
- [ ] `completedAt`
- [ ] `summaryPath`
- [ ] 提供按 `discussionChannelId` 查询 metadata 的方法
- [ ] 提供创建 metadata 的方法
- [ ] 提供更新状态的方法
- [ ] 提供关闭 discussion channel 的状态写入方法
- [x] 新建 discussion state 类型定义文件(如 `plugin/core/discussion-state.ts`
- [x] 定义 discussion metadata 类型:
- [x] `mode`
- [x] `discussionChannelId`
- [x] `originChannelId`
- [x] `initiatorAgentId`
- [x] `initiatorSessionId`
- [x] `discussGuide`
- [x] `status`
- [x] `createdAt`
- [x] `completedAt`
- [x] `summaryPath`
- [x] 提供按 `discussionChannelId` 查询 metadata 的方法
- [x] 提供创建 metadata 的方法
- [x] 提供更新状态的方法
- [x] 提供关闭 discussion channel 的状态写入方法
#### A4.2 新建 discussion service 模块
- [ ] 新建 discussion service`plugin/core/discussion-service.ts`
- [ ] 封装 discussion channel 创建后的初始化逻辑
- [ ] 封装 callback 校验逻辑
- [ ] 封装 callback 成功后的收尾逻辑
- [ ] 封装 origin channel moderator 通知逻辑
- [ ] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑
- [x] 新建 discussion service`plugin/core/discussion-service.ts`
- [x] 封装 discussion channel 创建后的初始化逻辑
- [x] 封装 callback 校验逻辑
- [x] 封装 callback 成功后的收尾逻辑
- [x] 封装 origin channel moderator 通知逻辑
- [x] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑
#### A4.3 workspace 路径校验
- [ ] 新增 path 校验辅助函数
- [ ] 校验 `summaryPath` 文件存在
- [ ] 校验 `summaryPath` 位于 initiator workspace 下
- [ ] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题
- [x] 新增 path 校验辅助函数
- [x] 校验 `summaryPath` 文件存在
- [x] 校验 `summaryPath` 位于 initiator workspace 下
- [x] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题
### A5. `plugin/core/moderator-discord.ts`
- [ ] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程
- [ ] 如有必要,补充统一错误日志和返回值处理
- [ ] 确认可被 discussion service 复用发送:
- [ ] kickoff message
- [ ] idle reminder
- [ ] callback 完成通知
- [ ] channel closed 固定回复
- [x] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程
- [x] 如有必要,补充统一错误日志和返回值处理
- [x] 确认可被 discussion service 复用发送:
- [x] kickoff message
- [x] idle reminder
- [x] callback 完成通知
- [x] channel closed 固定回复
### A6. `plugin/turn-manager.ts`
#### A6.1 理解现有轮转机制
- [ ] 梳理 `initTurnOrder` / `checkTurn` / `onNewMessage` / `onSpeakerDone` / `advanceTurn`
- [ ] 确认“轮转一圈无人发言”在现有实现中的判定条件
- [ ] 确认 discussion 模式需要在哪个信号点插入“idle reminder”
- [x] 梳理 `initTurnOrder` / `checkTurn` / `onNewMessage` / `onSpeakerDone` / `advanceTurn`
- [x] 确认“轮转一圈无人发言”在现有实现中的判定条件
- [x] 确认 discussion 模式需要在哪个信号点插入“idle reminder”
#### A6.2 discussion 模式的空转处理
- [ ] 设计 discussion 模式下的 idle reminder 触发方式
- [ ] 确定是直接改 `turn-manager.ts`,还是由上层在 `nextSpeaker === null` 时识别 discussion channel
- [ ] 确保 discussion channel 空转时 moderator 会提醒 initiator 收尾
- [ ] 确保普通 channel 仍保持原有 dormant 行为
- [x] 设计 discussion 模式下的 idle reminder 触发方式
- [x] 确定是直接改 `turn-manager.ts`,还是由上层在 `nextSpeaker === null` 时识别 discussion channel
- [x] 确保 discussion channel 空转时 moderator 会提醒 initiator 收尾
- [x] 确保普通 channel 仍保持原有 dormant 行为
#### A6.3 关闭后禁言
- [ ] 明确 discussion channel `closed` 后 turn-manager 是否还需要保留状态
- [ ] 如需要,增加对 closed discussion channel 的快速短路判断
- [ ] 避免 closed channel 再次进入正常轮转
- [x] 明确 discussion channel `closed` 后 turn-manager 是否还需要保留状态
- [x] 如需要,增加对 closed discussion channel 的快速短路判断
- [x] 避免 closed channel 再次进入正常轮转
### A7. Hooks 与 session 状态
#### A7.1 `plugin/hooks/before-model-resolve.ts`
- [ ] 梳理当前 session 级 no-reply 覆盖的触发路径
- [ ] 确认如何将 closed discussion channel 相关 session 强制落到 `noReplyProvider` / `noReplyModel`
- [ ] 确认该逻辑是通过 metadata 状态判断,还是通过额外 session 标记判断
- [ ] 确保该覆盖只作用于指定 discussion session不影响其他 channel/session
- [ ] 为 closed discussion channel 的覆盖路径补充调试日志
- [x] 梳理当前 session 级 no-reply 覆盖的触发路径
- [x] 确认如何将 closed discussion channel 相关 session 强制落到 `noReplyProvider` / `noReplyModel`
- [x] 确认该逻辑是通过 metadata 状态判断,还是通过额外 session 标记判断
- [x] 确保该覆盖只作用于指定 discussion session不影响其他 channel/session
- [x] 为 closed discussion channel 的覆盖路径补充调试日志
#### A7.2 `plugin/hooks/before-message-write.ts`
- [ ] 梳理当前 NO_REPLY / end-symbol / waitIdentifier 的处理逻辑
- [ ] 找到 discussion channel 中“轮转一圈无人发言”后最适合触发 idle reminder 的位置
- [ ] 如果 `nextSpeaker === null` 且当前 channel 是 active discussion channel
- [ ] 调用 moderator idle reminder
- [ ] 不直接让流程无提示沉默结束
- [ ] 避免重复发送 idle reminder
- [ ] closed discussion channel 下,阻止继续进入正常 handoff 流程
- [x] 梳理当前 NO_REPLY / end-symbol / waitIdentifier 的处理逻辑
- [x] 找到 discussion channel 中“轮转一圈无人发言”后最适合触发 idle reminder 的位置
- [x] 如果 `nextSpeaker === null` 且当前 channel 是 active discussion channel
- [x] 调用 moderator idle reminder
- [x] 不直接让流程无提示沉默结束
- [x] 避免重复发送 idle reminder
- [x] closed discussion channel 下,阻止继续进入正常 handoff 流程
#### A7.3 `plugin/hooks/message-sent.ts`
- [ ] 确认该 hook 是否也会参与 turn 收尾,避免与 `before-message-write.ts` 重复处理
- [ ] 检查 discussion channel 场景下是否需要同步补充 closed/idle 分支保护
- [ ] 确保 callback 完成后的 closed channel 不会继续触发 handoff
- [x] 确认该 hook 是否也会参与 turn 收尾,避免与 `before-message-write.ts` 重复处理
- [x] 检查 discussion channel 场景下是否需要同步补充 closed/idle 分支保护
- [x] 确保 callback 完成后的 closed channel 不会继续触发 handoff
#### A7.4 `plugin/hooks/message-received.ts`
- [ ] 梳理 moderator bot 消息当前是否已被过滤,避免 moderator 自己再次触发讨论链路
- [ ] 对 closed discussion channel 的新消息增加统一处理入口
- [ ] 若 closed discussion channel 收到新消息:
- [ ] 不再唤醒任何 Agent 正常讨论
- [ ] 由 moderator 回复“channel 已关闭,仅做留档使用”
- [ ] 避免 moderator 的 closed 提示消息反复触发自身处理
- [x] 梳理 moderator bot 消息当前是否已被过滤,避免 moderator 自己再次触发讨论链路
- [x] 对 closed discussion channel 的新消息增加统一处理入口
- [x] 若 closed discussion channel 收到新消息:
- [x] 不再唤醒任何 Agent 正常讨论
- [x] 由 moderator 回复“channel 已关闭,仅做留档使用”
- [x] 避免 moderator 的 closed 提示消息反复触发自身处理
#### A7.5 `plugin/core/session-state.ts`(如需)
- [ ] 检查现有 session 相关缓存是否适合扩展 discussion 状态
- [ ] 若需要,为 discussion session 增加专用标记缓存
- [ ] 区分:普通 no-reply 决策 vs discussion close 强制 no-reply
- [ ] 确保 session 生命周期结束后相关缓存可清理
- [x] 检查现有 session 相关缓存是否适合扩展 discussion 状态
- [x] 若需要,为 discussion session 增加专用标记缓存
- [x] 区分:普通 no-reply 决策 vs discussion close 强制 no-reply
- [x] 确保 session 生命周期结束后相关缓存可清理
### A8. `plugin/core/identity.ts` / `plugin/core/channel-members.ts` / `plugin/core/turn-bootstrap.ts`
- [ ] 梳理 initiator identity 的可获取路径
- [ ] 确认 callback 时如何稳定识别 initiator account/session
- [ ] 确认 discussion channel 创建后 turn order 是否需立即 bootstrap
- [ ] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑
- [x] 梳理 initiator identity 的可获取路径
- [x] 确认 callback 时如何稳定识别 initiator account/session
- [x] 确认 discussion channel 创建后 turn order 是否需立即 bootstrap
- [x] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑
### A9. `plugin/index.ts`
- [ ] 注入新增 discussion metadata/service 模块依赖
- [ ] 将 discussion service 传入工具注册逻辑
- [ ] 将 discussion 相关辅助能力传入需要的 hooks
- [ ] 保持插件初始化结构清晰,避免在 `index.ts` 中堆业务细节
- [x] 注入新增 discussion metadata/service 模块依赖
- [x] 将 discussion service 传入工具注册逻辑
- [x] 将 discussion 相关辅助能力传入需要的 hooks
- [x] 保持插件初始化结构清晰,避免在 `index.ts` 中堆业务细节
### A13.2 metadata / service 测试
- [x] 测试 discussion metadata 创建成功
- [x] 测试按 channelId 查询 metadata 成功
- [x] 测试状态流转 `active -> completed/closed` 成功
- [x] 测试重复 callback 被拒绝
### A13.4 路径校验测试
- [x] 测试合法 `summaryPath` 通过
- [x] 测试不存在文件失败
- [x] 测试 workspace 外路径失败
- [x] 测试 `..` 路径逃逸失败
- [x] 测试绝对路径越界失败
### A13.5 回调链路测试
- [x] 测试 callback 成功后 moderator 在 origin channel 发出通知
- [x] 测试 origin channel 收到路径后能继续原工作流
- [x] 测试 discussion channel 后续只保留留档行为
### B10.2 Shuffle Mode
- [x] 测试 `/turn-shuffling on/off` 生效
- [x] 测试 shuffling 关闭时 turn order 不变
- [x] 测试 shuffling 开启时每轮结束后会 reshuffle
- [x] 测试上一轮最后 speaker 不会成为下一轮第一位
- [x] 测试双 Agent 场景行为符合预期
- [x] 测试单 Agent 场景不会异常
### B10.3 兼容性测试
- [x] 测试 multi-message mode 与 waiting-for-human 的边界
- [x] 测试 multi-message mode 与 mention override 的边界
- [x] 测试 shuffle mode 与 dormant 状态的边界
- [x] 测试 shuffle mode 与 mention override 的边界
### B11. 文档收尾
- [x] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [x] 为新增配置项补文档
- [x]`/turn-shuffling` 补使用说明
- [x] 输出 Multi-Message Mode / Shuffle Mode 的验收清单
### A10. moderator 消息模板整理
#### A10.1 kickoff message
- [ ] 定稿 discussion started 模板
- [ ] 模板中包含 `discussGuide`
- [ ] 模板中明确 initiator 结束责任
- [ ] 模板中明确 `discuss-callback(summaryPath)` 调用要求
- [x] 定稿 discussion started 模板
- [x] 模板中包含 `discussGuide`
- [x] 模板中明确 initiator 结束责任
- [x] 模板中明确 `discuss-callback(summaryPath)` 调用要求
#### A10.2 idle reminder
- [ ] 定稿 discussion idle 模板
- [ ] 模板中提醒 initiator写总结文件并 callback
- [ ] 避免提醒文案歧义或像自动总结器
- [x] 定稿 discussion idle 模板
- [x] 模板中提醒 initiator写总结文件并 callback
- [x] 避免提醒文案歧义或像自动总结器
#### A10.3 origin callback message
- [ ] 定稿发回原工作 channel 的结果通知模板
- [ ] 模板中包含 `summaryPath`
- [ ] 模板中包含来源 discussion channel
- [ ] 模板中明确“继续基于该总结文件推进原任务”
- [x] 定稿发回原工作 channel 的结果通知模板
- [x] 模板中包含 `summaryPath`
- [x] 模板中包含来源 discussion channel
- [x] 模板中明确“继续基于该总结文件推进原任务”
#### A10.4 closed reply
- [ ] 定稿 closed channel 固定回复模板
- [ ] 明确 channel 已关闭,仅做留档使用
- [x] 定稿 closed channel 固定回复模板
- [x] 明确 channel 已关闭,仅做留档使用
### A11. `discuss-callback` 详细校验任务
- [ ] 校验当前 channel 必须是 discussion channel
- [ ] 校验当前 discussion 状态必须是 `active`
- [ ] 校验调用者必须是 initiator
- [ ] 校验 `summaryPath` 非空
- [ ] 校验 `summaryPath` 文件存在
- [ ] 校验 `summaryPath` 路径在 initiator workspace 内
- [ ] 校验 callback 未重复执行
- [ ] callback 成功后写入 `completedAt`
- [ ] callback 成功后记录 `summaryPath`
- [ ] callback 成功后切换 discussion 状态为 `completed` / `closed`
- [x] 校验当前 channel 必须是 discussion channel
- [x] 校验当前 discussion 状态必须是 `active`
- [x] 校验调用者必须是 initiator
- [x] 校验 `summaryPath` 非空
- [x] 校验 `summaryPath` 文件存在
- [x] 校验 `summaryPath` 路径在 initiator workspace 内
- [x] 校验 callback 未重复执行
- [x] callback 成功后写入 `completedAt`
- [x] callback 成功后记录 `summaryPath`
- [x] callback 成功后切换 discussion 状态为 `completed` / `closed`
### A12. 关闭后的行为封口
- [ ] closed discussion channel 中所有旧 session 继续使用 no-reply 覆盖
- [ ] closed discussion channel 中任何新消息都不再进入真实讨论
- [ ] closed discussion channel 的任何新消息统一走 moderator 固定回复
- [ ] 防止 closed channel 中 moderator 自己的回复再次触发回环
- [ ] 明确 archived-only 的最终行为与边界
- [x] closed discussion channel 中所有旧 session 继续使用 no-reply 覆盖
- [x] closed discussion channel 中任何新消息都不再进入真实讨论
- [x] closed discussion channel 的任何新消息统一走 moderator 固定回复
- [x] 防止 closed channel 中 moderator 自己的回复再次触发回环
- [x] 明确 archived-only 的最终行为与边界
### A13. 测试与文档收尾
#### A13.1 工具层测试
- [ ] 测试普通 `discord_channel_create` 不带新参数时行为不变
- [ ] 测试 `discord_channel_create``callbackChannelId` 但缺 `discussGuide` 时失败
- [ ] 测试 discussion 模式 channel 创建成功
- [ ] 测试 `discuss-callback` 注册成功并可调用
- [x] 测试普通 `discord_channel_create` 不带新参数时行为不变
- [x] 测试 `discord_channel_create``callbackChannelId` 但缺 `discussGuide` 时失败
- [x] 测试 discussion 模式 channel 创建成功
- [x] 测试 `discuss-callback` 注册成功并可调用
#### A13.2 metadata / service 测试
- [ ] 测试 discussion metadata 创建成功
- [ ] 测试按 channelId 查询 metadata 成功
- [ ] 测试状态流转 `active -> completed/closed` 成功
- [ ] 测试重复 callback 被拒绝
- [x] 测试 discussion metadata 创建成功
- [x] 测试按 channelId 查询 metadata 成功
- [x] 测试状态流转 `active -> completed/closed` 成功
- [x] 测试重复 callback 被拒绝
#### A13.3 turn / hook 测试
- [ ] 测试 discussion channel 空转后发送 idle reminder
- [ ] 测试普通 channel 空转逻辑不受影响
- [ ] 测试 callback 成功后 discussion channel 不再 handoff
- [ ] 测试 closed discussion channel 新消息不会继续唤醒 Agent
- [x] 测试 discussion channel 空转后发送 idle reminder
- [x] 测试普通 channel 空转逻辑不受影响
- [x] 测试 callback 成功后 discussion channel 不再 handoff
- [x] 测试 closed discussion channel 新消息不会继续唤醒 Agent
#### A13.4 路径校验测试
- [ ] 测试合法 `summaryPath` 通过
- [ ] 测试不存在文件失败
- [ ] 测试 workspace 外路径失败
- [ ] 测试 `..` 路径逃逸失败
- [ ] 测试绝对路径越界失败
- [x] 测试合法 `summaryPath` 通过
- [x] 测试不存在文件失败
- [x] 测试 workspace 外路径失败
- [x] 测试 `..` 路径逃逸失败
- [x] 测试绝对路径越界失败
#### A13.5 回调链路测试
- [ ] 测试 callback 成功后 moderator 在 origin channel 发出通知
- [ ] 测试 origin channel 收到路径后能继续原工作流
- [ ] 测试 discussion channel 后续只保留留档行为
- [x] 测试 callback 成功后 moderator 在 origin channel 发出通知
- [x] 测试 origin channel 收到路径后能继续原工作流
- [x] 测试 discussion channel 后续只保留留档行为
#### A13.6 文档交付
- [ ] 根据最终代码实现更新 `plans/CSM.md`
- [ ]`discord_channel_create` 新增参数补文档
- [ ]`discuss-callback` 补工具说明文档
- [ ] 补 discussion metadata 与状态机说明
- [ ] 补开发/调试说明
- [ ] 输出 MVP 验收清单
- [x] 根据最终代码实现更新 `plans/CSM.md`
- [x]`discord_channel_create` 新增参数补文档
- [x]`discuss-callback` 补工具说明文档
- [x] 补 discussion metadata 与状态机说明
- [x] 补开发/调试说明
- [x] 输出 MVP 验收清单
---
## B. Multi-Message Mode / Shuffle Mode
### B1. 方案整理
- [ ] 通读并确认 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [ ] 确认 Multi-Message Mode 与 Shuffle Mode 的 MVP 范围
- [ ] 确认两项能力是否都只做 channel 级 runtime state不立即落盘
- [ ] 明确它们与 discussion channel / waiting-for-human / dormant 的优先级关系
- [x] 通读并确认 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [x] 确认 Multi-Message Mode 与 Shuffle Mode 的 MVP 范围
- [x] 确认两项能力是否都只做 channel 级 runtime state不立即落盘
- [x] 明确它们与 discussion channel / waiting-for-human / dormant 的优先级关系
### B2. 配置与 schema
#### B2.1 `plugin/openclaw.plugin.json`
- [ ] 增加 `multiMessageStartMarker`
- [ ] 增加 `multiMessageEndMarker`
- [ ] 增加 `multiMessagePromptMarker`
- [ ] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️`
- [ ] 评估是否需要增加 shuffle 默认配置项
- [x] 增加 `multiMessageStartMarker`
- [x] 增加 `multiMessageEndMarker`
- [x] 增加 `multiMessagePromptMarker`
- [x] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️`
- [x] 评估是否需要增加 shuffle 默认配置项
#### B2.2 `plugin/rules.ts` / config 类型
- [ ] 为 multi-message mode 相关配置补类型定义
- [ ] 为 shuffle mode 相关 channel state / config 补类型定义
- [ ] 确保运行时读取配置逻辑可访问新增字段
- [x] 为 multi-message mode 相关配置补类型定义
- [x] 为 shuffle mode 相关 channel state / config 补类型定义
- [x] 确保运行时读取配置逻辑可访问新增字段
### B3. `plugin/core/` 新增 channel mode / shuffle state 模块
- [ ] 新增 channel mode state 模块(如 `plugin/core/channel-modes.ts`
- [ ] 定义 channel mode`normal` / `multi-message`
- [ ] 提供 `enterMultiMessageMode(channelId)`
- [ ] 提供 `exitMultiMessageMode(channelId)`
- [ ] 提供 `isMultiMessageMode(channelId)`
- [ ] 提供 shuffle 开关状态存取方法
- [ ] 评估 shuffle state 是否应并入 turn-manager 内部状态
- [x] 新增 channel mode state 模块(如 `plugin/core/channel-modes.ts`
- [x] 定义 channel mode`normal` / `multi-message`
- [x] 提供 `enterMultiMessageMode(channelId)`
- [x] 提供 `exitMultiMessageMode(channelId)`
- [x] 提供 `isMultiMessageMode(channelId)`
- [x] 提供 shuffle 开关状态存取方法
- [x] 评估 shuffle state 是否应并入 turn-manager 内部状态
### B4. `plugin/hooks/message-received.ts`
#### B4.1 Multi-Message Mode 入口/出口
- [ ] 检测 human 消息中的 multi-message start marker
- [ ] start marker 命中时,将 channel 切换到 multi-message mode
- [ ] 检测 human 消息中的 multi-message end marker
- [ ] end marker 命中时,将 channel 退出 multi-message mode
- [ ] 避免 moderator 自己的 prompt marker 消息触发 mode 切换
- [x] 检测 human 消息中的 multi-message start marker
- [x] start marker 命中时,将 channel 切换到 multi-message mode
- [x] 检测 human 消息中的 multi-message end marker
- [x] end marker 命中时,将 channel 退出 multi-message mode
- [x] 避免 moderator 自己的 prompt marker 消息触发 mode 切换
#### B4.2 Multi-Message Mode 中的 moderator 提示
- [ ] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker
- [ ] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker`
- [ ] 避免重复触发或回环
- [x] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker
- [x] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker`
- [x] 避免重复触发或回环
#### B4.3 与现有 mention override 的兼容
- [ ] 明确 multi-message mode 下 human @mention 是否忽略
- [ ] 避免 multi-message mode 与 mention override 冲突
- [x] 明确 multi-message mode 下 human @mention 是否忽略
- [x] 避免 multi-message mode 与 mention override 冲突
### B5. `plugin/hooks/before-model-resolve.ts`
- [ ] 当 channel 处于 multi-message mode 时,强制相关 session 走 `noReplyProvider` / `noReplyModel`
- [ ] 确保 multi-message mode 的 no-reply 覆盖优先于普通 turn 决策
- [ ] 确保退出 multi-message mode 后恢复正常 turn 逻辑
- [ ] 补充必要调试日志
- [x] 当 channel 处于 multi-message mode 时,强制相关 session 走 `noReplyProvider` / `noReplyModel`
- [x] 确保 multi-message mode 的 no-reply 覆盖优先于普通 turn 决策
- [x] 确保退出 multi-message mode 后恢复正常 turn 逻辑
- [x] 补充必要调试日志
### B6. `plugin/turn-manager.ts`
#### B6.1 Multi-Message Mode 与 turn pause/resume
- [ ] 设计 multi-message mode 下 turn manager 的暂停语义
- [ ] 明确 pause 是通过外层 gating还是 turn-manager 内显式状态
- [ ] 退出 multi-message mode 后恢复 turn manager
- [ ] 退出时确定下一位 speaker 的选择逻辑
- [x] 设计 multi-message mode 下 turn manager 的暂停语义
- [x] 明确 pause 是通过外层 gating还是 turn-manager 内显式状态
- [x] 退出 multi-message mode 后恢复 turn manager
- [x] 退出时确定下一位 speaker 的选择逻辑
#### B6.2 Shuffle Mode
- [ ] 为每个 channel 增加 `shuffling` 开关状态
- [ ] 识别“一轮最后位 speaker 发言完成”的边界点
- [ ] 在进入下一轮前执行 reshuffle
- [ ] 保证上一轮最后 speaker 不会成为新一轮第一位
- [ ] 处理单 Agent 场景
- [ ] 处理双 Agent 场景
- [ ] 处理 mention override / waiting-for-human / dormant 状态下的 reshape 边界
- [x] 为每个 channel 增加 `shuffling` 开关状态
- [x] 识别“一轮最后位 speaker 发言完成”的边界点
- [x] 在进入下一轮前执行 reshuffle
- [x] 保证上一轮最后 speaker 不会成为新一轮第一位
- [x] 处理单 Agent 场景
- [x] 处理双 Agent 场景
- [x] 处理 mention override / waiting-for-human / dormant 状态下的 reshape 边界
### B7. `plugin/commands/dirigent-command.ts`
- [ ] 新增 `/turn-shuffling` 子命令
- [ ] 支持:
- [ ] `/turn-shuffling`
- [ ] `/turn-shuffling on`
- [ ] `/turn-shuffling off`
- [ ] 命令返回当前 channel 的 shuffling 状态
- [ ] 命令帮助文本补充说明
- [x] 新增 `/turn-shuffling` 子命令
- [x] 支持:
- [x] `/turn-shuffling`
- [x] `/turn-shuffling on`
- [x] `/turn-shuffling off`
- [x] 命令返回当前 channel 的 shuffling 状态
- [x] 命令帮助文本补充说明
### B8. `plugin/index.ts`
- [ ] 注入 channel mode / shuffle state 模块依赖
- [ ] 将新状态能力传给相关 hooks / turn-manager
- [ ] 保持初始化关系清晰,避免 mode 逻辑散落
- [x] 注入 channel mode / shuffle state 模块依赖
- [x] 将新状态能力传给相关 hooks / turn-manager
- [x] 保持初始化关系清晰,避免 mode 逻辑散落
### B9. moderator 消息模板
- [ ] 定义 multi-message mode 下的 prompt marker 发送规则
- [ ] 明确是否需要 start / end 的 moderator 确认消息
- [ ] 定义退出 multi-message mode 后的 scheduling handoff 触发格式
- [x] 定义 multi-message mode 下的 prompt marker 发送规则
- [x] 明确是否需要 start / end 的 moderator 确认消息
- [x] 定义退出 multi-message mode 后的 scheduling handoff 触发格式
### B10. 测试
#### B10.1 Multi-Message Mode
- [ ] 测试 human 发送 start marker 后进入 multi-message mode
- [ ] 测试 multi-message mode 中 Agent 被 no-reply 覆盖
- [ ] 测试每条 human 追加消息都触发 prompt marker
- [ ] 测试 human 发送 end marker 后退出 multi-message mode
- [ ] 测试退出后 moderator 正确 handoff 给下一位 Agent
- [ ] 测试 moderator prompt marker 不会触发回环
- [x] 测试 human 发送 start marker 后进入 multi-message mode
- [x] 测试 multi-message mode 中 Agent 被 no-reply 覆盖
- [x] 测试每条 human 追加消息都触发 prompt marker
- [x] 测试 human 发送 end marker 后退出 multi-message mode
- [x] 测试退出后 moderator 正确 handoff 给下一位 Agent
- [x] 测试 moderator prompt marker 不会触发回环
#### B10.2 Shuffle Mode
- [ ] 测试 `/turn-shuffling on/off` 生效
- [ ] 测试 shuffling 关闭时 turn order 不变
- [ ] 测试 shuffling 开启时每轮结束后会 reshuffle
- [ ] 测试上一轮最后 speaker 不会成为下一轮第一位
- [ ] 测试双 Agent 场景行为符合预期
- [ ] 测试单 Agent 场景不会异常
- [x] 测试 `/turn-shuffling on/off` 生效
- [x] 测试 shuffling 关闭时 turn order 不变
- [x] 测试 shuffling 开启时每轮结束后会 reshuffle
- [x] 测试上一轮最后 speaker 不会成为下一轮第一位
- [x] 测试双 Agent 场景行为符合预期
- [x] 测试单 Agent 场景不会异常
#### B10.3 兼容性测试
- [ ] 测试 multi-message mode 与 waiting-for-human 的边界
- [ ] 测试 multi-message mode 与 mention override 的边界
- [ ] 测试 shuffle mode 与 dormant 状态的边界
- [ ] 测试 shuffle mode 与 mention override 的边界
- [x] 测试 multi-message mode 与 waiting-for-human 的边界
- [x] 测试 multi-message mode 与 mention override 的边界
- [x] 测试 shuffle mode 与 dormant 状态的边界
- [x] 测试 shuffle mode 与 mention override 的边界
### B11. 文档收尾
- [ ] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [ ] 为新增配置项补文档
- [ ]`/turn-shuffling` 补使用说明
- [ ] 输出 Multi-Message Mode / Shuffle Mode 的验收清单
- [x] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [x] 为新增配置项补文档
- [x]`/turn-shuffling` 补使用说明
- [x] 输出 Multi-Message Mode / Shuffle Mode 的验收清单

View File

@@ -30,6 +30,9 @@ Optional:
- `channelPoliciesFile` (per-channel overrides in a standalone JSON file)
- `schedulingIdentifier` (default `➡️`) — moderator handoff identifier
- `enableDirigentPolicyTool` (default true)
- `multiMessageStartMarker` (default `↗️`)
- `multiMessageEndMarker` (default `↙️`)
- `multiMessagePromptMarker` (default `⤵️`)
Unified optional tool:
- `dirigent_tools`
@@ -59,6 +62,15 @@ When the current speaker NO_REPLYs, the moderator bot sends: `<@NEXT_USER_ID>➡
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.
## Multi-message mode / shuffle mode
- Human sends the configured start marker (default `↗️`) → channel enters multi-message mode.
- While active, agents are forced to no-reply and the moderator sends only the configured prompt marker (default `⤵️`) after each additional human message.
- Human sends the configured end marker (default `↙️`) → channel exits multi-message mode and normal scheduling resumes.
- No separate moderator "entered/exited mode" confirmation message is sent; the markers themselves are the protocol.
- The first moderator message after exit uses the normal scheduling handoff format: `<@NEXT_USER_ID>➡️`.
- `/dirigent turn-shuffling`, `/dirigent turn-shuffling on`, and `/dirigent turn-shuffling off` control per-channel reshuffling between completed rounds.
## Slash command (Discord)
```
@@ -66,6 +78,9 @@ This is a non-semantic scheduling message. The scheduling identifier (`➡️` b
/dirigent turn-status
/dirigent turn-advance
/dirigent turn-reset
/dirigent turn-shuffling
/dirigent turn-shuffling on
/dirigent turn-shuffling off
```
Debug logging:

View File

@@ -1,73 +0,0 @@
export function extractDiscordChannelId(ctx: Record<string, unknown>, event?: Record<string, unknown>): string | undefined {
const candidates: unknown[] = [
ctx.conversationId,
ctx.OriginatingTo,
event?.to,
(event?.metadata as Record<string, unknown>)?.to,
];
for (const c of candidates) {
if (typeof c !== "string" || !c.trim()) continue;
const s = c.trim();
if (s.startsWith("channel:")) {
const id = s.slice("channel:".length);
if (/^\d+$/.test(id)) return id;
}
if (s.startsWith("discord:channel:")) {
const id = s.slice("discord:channel:".length);
if (/^\d+$/.test(id)) return id;
}
if (/^\d{15,}$/.test(s)) return s;
}
return undefined;
}
export function extractDiscordChannelIdFromSessionKey(sessionKey?: string): string | undefined {
if (!sessionKey) return undefined;
const canonical = sessionKey.match(/discord:(?:channel|group):(\d+)/);
if (canonical?.[1]) return canonical[1];
const suffix = sessionKey.match(/:channel:(\d+)$/);
if (suffix?.[1]) return suffix[1];
return undefined;
}
export function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
const marker = "Conversation info (untrusted metadata):";
const idx = text.indexOf(marker);
if (idx < 0) return undefined;
const tail = text.slice(idx + marker.length);
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
if (!m) return undefined;
try {
const parsed = JSON.parse(m[1]);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}
export function extractDiscordChannelIdFromConversationMetadata(conv: Record<string, unknown>): string | undefined {
if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) {
const id = conv.chat_id.slice("channel:".length);
if (/^\d+$/.test(id)) return id;
}
if (typeof conv.conversation_label === "string") {
const labelMatch = conv.conversation_label.match(/channel id:(\d+)/);
if (labelMatch?.[1]) return labelMatch[1];
}
if (typeof conv.channel_id === "string" && /^\d+$/.test(conv.channel_id)) {
return conv.channel_id;
}
return undefined;
}

View File

@@ -0,0 +1,136 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { execFileSync } from "node:child_process";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
function getSkillBaseDir(api: OpenClawPluginApi): string {
return (api.config as Record<string, unknown>)?.["dirigentStateDir"] as string || path.join(os.homedir(), ".openclaw");
}
function parseGuildTable(skillMdContent: string): Array<{ guildId: string; description: string }> {
const lines = skillMdContent.split("\n");
const rows: Array<{ guildId: string; description: string }> = [];
let inTable = false;
for (const line of lines) {
// Detect table header
if (line.includes("guild-id") && line.includes("description")) {
inTable = true;
continue;
}
// Skip separator line
if (inTable && /^\|[-\s|]+\|$/.test(line)) {
continue;
}
// Parse data rows
if (inTable) {
const match = line.match(/^\| \s*(\d+) \s*\| \s*(.+?) \s*\|$/);
if (match) {
rows.push({ guildId: match[1].trim(), description: match[2].trim() });
}
}
}
return rows;
}
export function registerAddGuildCommand(api: OpenClawPluginApi): void {
// Register add-guild command
api.registerCommand({
name: "add-guild",
description: "Add a Discord guild to the discord-guilds skill",
acceptsArgs: true,
handler: async (cmdCtx) => {
const args = (cmdCtx.args || "").trim();
if (!args) {
return {
text: "Usage: /add-guild <guild-id> <description>\nExample: /add-guild 123456789012345678 \"Production server\"",
isError: true,
};
}
const parts = args.split(/\s+/);
if (parts.length < 2) {
return {
text: "Error: Both guild-id and description are required.\nUsage: /add-guild <guild-id> <description>",
isError: true,
};
}
const guildId = parts[0];
const description = parts.slice(1).join(" ");
// Validate guild ID
if (!/^\d+$/.test(guildId)) {
return {
text: "Error: guild-id must be a numeric Discord snowflake (e.g., 123456789012345678)",
isError: true,
};
}
// Resolve the skill script path
const openClawDir = getSkillBaseDir(api);
const scriptPath = path.join(openClawDir, "skills", "discord-guilds", "scripts", "add-guild");
try {
const result = execFileSync(process.execPath, [scriptPath, guildId, description], {
encoding: "utf8",
timeout: 5000,
});
return { text: result.trim() };
} catch (e: any) {
const stderr = e?.stderr?.toString?.() || "";
const stdout = e?.stdout?.toString?.() || "";
return {
text: `Failed to add guild: ${stderr || stdout || String(e)}`,
isError: true,
};
}
},
});
// Register list-guilds command
api.registerCommand({
name: "list-guilds",
description: "List all Discord guilds in the discord-guilds skill",
acceptsArgs: false,
handler: async () => {
const openClawDir = getSkillBaseDir(api);
const skillMdPath = path.join(openClawDir, "skills", "discord-guilds", "SKILL.md");
if (!fs.existsSync(skillMdPath)) {
return {
text: "Error: discord-guilds skill not found. Run Dirigent install first.",
isError: true,
};
}
try {
const content = fs.readFileSync(skillMdPath, "utf8");
const guilds = parseGuildTable(content);
if (guilds.length === 0) {
return { text: "No guilds configured yet.\n\nUse /add-guild <guild-id> <description> to add one." };
}
const lines = [
`**Available Guilds (${guilds.length}):**`,
"",
"| guild-id | description |",
"|----------|-------------|",
...guilds.map(g => `| ${g.guildId} | ${g.description} |`),
"",
"Use /add-guild <guild-id> <description> to add more.",
];
return { text: lines.join("\n") };
} catch (e: any) {
return {
text: `Failed to read guild list: ${String(e)}`,
isError: true,
};
}
},
});
}

View File

@@ -0,0 +1,13 @@
/** Extract Discord channel ID from slash command context. */
export function parseDiscordChannelIdFromCommand(cmdCtx: Record<string, unknown>): string | undefined {
// OpenClaw passes channel context in various ways depending on the trigger
const sessionKey = String(cmdCtx.sessionKey ?? "");
const m = sessionKey.match(/:discord:channel:(\d+)$/);
if (m) return m[1];
// Fallback: channelId directly on context
const cid = String(cmdCtx.channelId ?? "");
if (/^\d+$/.test(cid)) return cid;
return undefined;
}

View File

@@ -1,132 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js";
import type { DirigentConfig } from "../rules.js";
type CommandDeps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
policyState: { filePath: string; channelPolicies: Record<string, unknown> };
persistPolicies: (api: OpenClawPluginApi) => void;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
};
export function registerDirigentCommand(deps: CommandDeps): void {
const { api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded } = deps;
api.registerCommand({
name: "dirigent",
description: "Dirigent runtime commands",
acceptsArgs: true,
handler: async (cmdCtx) => {
const args = cmdCtx.args || "";
const parts = args.trim().split(/\s+/);
const subCmd = parts[0] || "help";
if (subCmd === "help") {
return {
text:
`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\n` +
`/dirigent_policy get <discordChannelId>\n` +
`/dirigent_policy set <discordChannelId> <policy-json>\n` +
`/dirigent_policy delete <discordChannelId>`,
};
}
if (subCmd === "status") {
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
}
if (subCmd === "turn-status") {
const channelId = cmdCtx.channelId;
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: "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: "Cannot get channel ID", isError: true };
resetTurn(channelId);
return { text: JSON.stringify({ ok: true }) };
}
return { text: `Unknown subcommand: ${subCmd}`, isError: true };
},
});
api.registerCommand({
name: "dirigent_policy",
description: "Dirigent channel policy CRUD",
acceptsArgs: true,
handler: async (cmdCtx) => {
const live = baseConfig;
ensurePolicyStateLoaded(api, live);
const args = (cmdCtx.args || "").trim();
if (!args) {
return {
text:
"Usage:\n" +
"/dirigent_policy get <discordChannelId>\n" +
"/dirigent_policy set <discordChannelId> <policy-json>\n" +
"/dirigent_policy delete <discordChannelId>",
isError: true,
};
}
const [opRaw, channelIdRaw, ...rest] = args.split(/\s+/);
const op = (opRaw || "").toLowerCase();
const channelId = (channelIdRaw || "").trim();
if (!channelId || !/^\d+$/.test(channelId)) {
return { text: "channelId is required and must be numeric Discord channel id", isError: true };
}
if (op === "get") {
const policy = (policyState.channelPolicies as Record<string, unknown>)[channelId];
return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) };
}
if (op === "delete") {
delete (policyState.channelPolicies as Record<string, unknown>)[channelId];
persistPolicies(api);
return { text: JSON.stringify({ ok: true, channelId, deleted: true }) };
}
if (op === "set") {
const jsonText = rest.join(" ").trim();
if (!jsonText) {
return { text: "set requires <policy-json>", isError: true };
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(jsonText);
} catch (e) {
return { text: `invalid policy-json: ${String(e)}`, isError: true };
}
const next: Record<string, unknown> = {};
if (typeof parsed.listMode === "string") next.listMode = parsed.listMode;
if (Array.isArray(parsed.humanList)) next.humanList = parsed.humanList.map(String);
if (Array.isArray(parsed.agentList)) next.agentList = parsed.agentList.map(String);
if (Array.isArray(parsed.endSymbols)) next.endSymbols = parsed.endSymbols.map(String);
(policyState.channelPolicies as Record<string, unknown>)[channelId] = next;
persistPolicies(api);
return { text: JSON.stringify({ ok: true, channelId, policy: next }, null, 2) };
}
return { text: `unsupported op: ${op}. use get|set|delete`, isError: true };
},
});
}

View File

@@ -0,0 +1,70 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore, ChannelMode } from "../core/channel-store.js";
import { parseDiscordChannelIdFromCommand } from "./command-utils.js";
const SWITCHABLE_MODES = new Set<ChannelMode>(["none", "chat", "report"]);
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
type Deps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
};
export function registerSetChannelModeCommand(deps: Deps): void {
const { api, channelStore } = deps;
api.registerCommand({
name: "set-channel-mode",
description: "Set the mode of the current Discord channel: none | chat | report",
acceptsArgs: true,
handler: async (cmdCtx) => {
const raw = (cmdCtx.args || "").trim().toLowerCase() as ChannelMode;
if (!raw) {
return {
text: "Usage: /set-channel-mode <none|chat|report>\n\nModes work and discussion are locked and can only be set via creation tools.",
isError: true,
};
}
if (LOCKED_MODES.has(raw)) {
return {
text: `Mode "${raw}" cannot be set via command — it is locked to its creation tool (create-${raw}-channel or create-discussion-channel).`,
isError: true,
};
}
if (!SWITCHABLE_MODES.has(raw)) {
return {
text: `Unknown mode "${raw}". Valid values: none, chat, report`,
isError: true,
};
}
// Extract channel ID from command context
const channelId = parseDiscordChannelIdFromCommand(cmdCtx);
if (!channelId) {
return {
text: "Could not determine Discord channel ID. Run this command inside a Discord channel.",
isError: true,
};
}
const current = channelStore.getMode(channelId);
if (LOCKED_MODES.has(current)) {
return {
text: `Channel ${channelId} is in locked mode "${current}" and cannot be changed.`,
isError: true,
};
}
try {
channelStore.setMode(channelId, raw);
} catch (err) {
return { text: `Failed: ${String(err)}`, isError: true };
}
return { text: `Channel ${channelId} mode set to "${raw}".` };
},
});
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { buildUserIdToAccountIdMap } from "./identity.js";
import type { IdentityRegistry } from "./identity-registry.js";
const PERM_VIEW_CHANNEL = 1n << 10n;
const PERM_ADMINISTRATOR = 1n << 3n;
@@ -84,7 +84,14 @@ function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bi
return (perms & PERM_VIEW_CHANNEL) !== 0n;
}
function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
function getDiscoveryToken(api: OpenClawPluginApi): string | undefined {
// Prefer moderator bot token from pluginConfig — it has guild member access
const pluginCfg = (api.pluginConfig as Record<string, unknown>) || {};
const moderatorToken = pluginCfg.moderatorBotToken;
if (typeof moderatorToken === "string" && moderatorToken) {
return moderatorToken;
}
// Fall back to any discord account token
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
@@ -95,8 +102,15 @@ function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
return undefined;
}
export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise<string[]> {
const token = getAnyDiscordToken(api);
/**
* Returns agentIds for all agents visible in the channel, resolved via the identity registry.
*/
export async function fetchVisibleChannelBotAccountIds(
api: OpenClawPluginApi,
channelId: string,
identityRegistry?: IdentityRegistry,
): Promise<string[]> {
const token = getDiscoveryToken(api);
if (!token) return [];
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
@@ -131,11 +145,13 @@ export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, c
.map((m) => String(m?.user?.id || ""))
.filter(Boolean);
const userToAccount = buildUserIdToAccountIdMap(api);
const out = new Set<string>();
if (identityRegistry) {
const discordToAgent = identityRegistry.buildDiscordToAgentMap();
for (const uid of visibleUserIds) {
const aid = userToAccount.get(uid);
const aid = discordToAgent.get(uid);
if (aid) out.add(aid);
}
}
return [...out];
}

View File

@@ -0,0 +1,136 @@
import fs from "node:fs";
import path from "node:path";
export type ChannelMode = "none" | "work" | "report" | "discussion" | "chat";
export type TurnManagerState = "disabled" | "dead" | "normal" | "shuffle" | "archived";
/** Modes that cannot be changed once set. */
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
/** Derive turn-manager state from mode + agent count. */
export function deriveTurnManagerState(mode: ChannelMode, agentCount: number, concluded = false): TurnManagerState {
if (mode === "none" || mode === "work") return "disabled";
if (mode === "report") return "dead";
if (mode === "discussion") {
if (concluded) return "archived";
if (agentCount <= 1) return "disabled";
if (agentCount === 2) return "normal";
return "shuffle";
}
if (mode === "chat") {
if (agentCount <= 1) return "disabled";
if (agentCount === 2) return "normal";
return "shuffle";
}
return "disabled";
}
export type DiscussionMeta = {
initiatorAgentId: string;
callbackGuildId: string;
callbackChannelId: string;
concluded: boolean;
};
export type ChannelRecord = {
mode: ChannelMode;
/** For discussion channels: metadata about the discussion. */
discussion?: DiscussionMeta;
};
export class ChannelStore {
private filePath: string;
private records: Record<string, ChannelRecord> = {};
private loaded = false;
constructor(filePath: string) {
this.filePath = filePath;
}
private load(): void {
if (this.loaded) return;
this.loaded = true;
if (!fs.existsSync(this.filePath)) {
this.records = {};
return;
}
try {
const raw = fs.readFileSync(this.filePath, "utf8");
this.records = JSON.parse(raw) ?? {};
} catch {
this.records = {};
}
}
private save(): void {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(this.records, null, 2), "utf8");
}
getMode(channelId: string): ChannelMode {
this.load();
return this.records[channelId]?.mode ?? "none";
}
getRecord(channelId: string): ChannelRecord {
this.load();
return this.records[channelId] ?? { mode: "none" };
}
/**
* Set channel mode. Throws if the channel is currently in a locked mode,
* or if the requested mode is locked (must use setLockedMode instead).
*/
setMode(channelId: string, mode: ChannelMode): void {
this.load();
const current = this.records[channelId]?.mode ?? "none";
if (LOCKED_MODES.has(current)) {
throw new Error(`Channel ${channelId} is in locked mode "${current}" and cannot be changed.`);
}
if (LOCKED_MODES.has(mode)) {
throw new Error(`Mode "${mode}" can only be set at channel creation via the dedicated tool.`);
}
this.records[channelId] = { ...this.records[channelId], mode };
this.save();
}
/**
* Set a locked mode (work or discussion). Only callable from creation tools.
* Throws if the channel already has any mode set.
*/
setLockedMode(channelId: string, mode: ChannelMode, discussion?: DiscussionMeta): void {
this.load();
if (this.records[channelId]) {
throw new Error(`Channel ${channelId} already has mode "${this.records[channelId].mode}".`);
}
const record: ChannelRecord = { mode };
if (discussion) record.discussion = discussion;
this.records[channelId] = record;
this.save();
}
/** Mark a discussion as concluded (sets archived state). */
concludeDiscussion(channelId: string): void {
this.load();
const rec = this.records[channelId];
if (!rec || rec.mode !== "discussion") {
throw new Error(`Channel ${channelId} is not a discussion channel.`);
}
if (!rec.discussion) {
throw new Error(`Channel ${channelId} has no discussion metadata.`);
}
rec.discussion = { ...rec.discussion, concluded: true };
this.save();
}
isLocked(channelId: string): boolean {
this.load();
return LOCKED_MODES.has(this.records[channelId]?.mode ?? "none");
}
listAll(): Array<{ channelId: string } & ChannelRecord> {
this.load();
return Object.entries(this.records).map(([channelId, rec]) => ({ channelId, ...rec }));
}
}

View File

@@ -0,0 +1,93 @@
import fs from "node:fs";
import path from "node:path";
export type IdentityEntry = {
discordUserId: string;
agentId: string;
agentName: string;
};
export class IdentityRegistry {
private filePath: string;
private entries: IdentityEntry[] = [];
private loaded = false;
constructor(filePath: string) {
this.filePath = filePath;
}
private load(): void {
if (this.loaded) return;
this.loaded = true;
if (!fs.existsSync(this.filePath)) {
this.entries = [];
return;
}
try {
const raw = fs.readFileSync(this.filePath, "utf8");
const parsed = JSON.parse(raw);
this.entries = Array.isArray(parsed) ? parsed : [];
} catch {
this.entries = [];
}
}
private save(): void {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(this.entries, null, 2), "utf8");
}
upsert(entry: IdentityEntry): void {
this.load();
const idx = this.entries.findIndex((e) => e.agentId === entry.agentId);
if (idx >= 0) {
this.entries[idx] = entry;
} else {
this.entries.push(entry);
}
this.save();
}
remove(agentId: string): boolean {
this.load();
const before = this.entries.length;
this.entries = this.entries.filter((e) => e.agentId !== agentId);
if (this.entries.length !== before) {
this.save();
return true;
}
return false;
}
findByAgentId(agentId: string): IdentityEntry | undefined {
this.load();
return this.entries.find((e) => e.agentId === agentId);
}
findByDiscordUserId(discordUserId: string): IdentityEntry | undefined {
this.load();
return this.entries.find((e) => e.discordUserId === discordUserId);
}
list(): IdentityEntry[] {
this.load();
return [...this.entries];
}
/** Build a map from discordUserId → agentId for fast lookup. */
buildDiscordToAgentMap(): Map<string, string> {
this.load();
const map = new Map<string, string>();
for (const e of this.entries) map.set(e.discordUserId, e.agentId);
return map;
}
/** Build a map from agentId → discordUserId for fast lookup. */
buildAgentToDiscordMap(): Map<string, string> {
this.load();
const map = new Map<string, string>();
for (const e of this.entries) map.set(e.agentId, e.discordUserId);
return map;
}
}

View File

@@ -1,79 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
function userIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}
function resolveDiscordUserIdFromAccount(api: OpenClawPluginApi, accountId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const acct = accounts[accountId];
if (!acct?.token || typeof acct.token !== "string") return undefined;
return userIdFromToken(acct.token);
}
export function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
return match.accountId;
}
}
}
return undefined;
}
export function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
if (!Array.isArray(bindings)) return undefined;
let accountId: string | undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
accountId = match.accountId;
break;
}
}
}
if (!accountId) return undefined;
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
const name = (agent?.name as string) || agentId;
const discordUserId = resolveDiscordUserIdFromAccount(api, accountId);
let identity = `You are ${name} (Discord account: ${accountId}`;
if (discordUserId) identity += `, Discord userId: ${discordUserId}`;
identity += `).`;
return identity;
}
export function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map<string, string> {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const map = new Map<string, string>();
for (const [accountId, acct] of Object.entries(accounts)) {
if (typeof acct.token === "string") {
const userId = userIdFromToken(acct.token);
if (userId) map.set(userId, accountId);
}
}
return map;
}

View File

@@ -1,5 +1,3 @@
import type { DirigentConfig } from "../rules.js";
function userIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
@@ -25,7 +23,7 @@ export function extractMentionedUserIds(content: string): string[] {
return ids;
}
export function getModeratorUserId(config: DirigentConfig): string | undefined {
if (!config.moderatorBotToken) return undefined;
return userIdFromToken(config.moderatorBotToken);
export function getModeratorUserIdFromToken(token: string | undefined): string | undefined {
if (!token) return undefined;
return userIdFromToken(token);
}

View File

@@ -1,6 +1,8 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
function userIdFromToken(token: string): string | undefined {
type Logger = { info: (m: string) => void; warn: (m: string) => void };
export function userIdFromBotToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
@@ -17,15 +19,19 @@ export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string):
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const acct = accounts[accountId];
if (!acct?.token || typeof acct.token !== "string") return undefined;
return userIdFromToken(acct.token);
return userIdFromBotToken(acct.token);
}
export type ModeratorMessageResult =
| { ok: true; status: number; channelId: string; messageId?: string }
| { ok: false; status?: number; channelId: string; error: string };
export async function sendModeratorMessage(
token: string,
channelId: string,
content: string,
logger: { info: (msg: string) => void; warn: (msg: string) => void },
): Promise<boolean> {
logger: Logger,
): Promise<ModeratorMessageResult> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST",
@@ -35,15 +41,232 @@ export async function sendModeratorMessage(
},
body: JSON.stringify({ content }),
});
if (!r.ok) {
const text = await r.text();
logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`);
return false;
let json: Record<string, unknown> | null = null;
try {
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
} catch {
json = null;
}
logger.info(`dirigent: moderator message sent to channel=${channelId}`);
return true;
if (!r.ok) {
const error = `discord api error (${r.status}): ${text || "<empty response>"}`;
logger.warn(`dirigent: moderator send failed channel=${channelId} ${error}`);
return { ok: false, status: r.status, channelId, error };
}
const messageId = typeof json?.id === "string" ? json.id : undefined;
logger.info(`dirigent: moderator message sent to channel=${channelId} messageId=${messageId ?? "unknown"}`);
return { ok: true, status: r.status, channelId, messageId };
} catch (err) {
logger.warn(`dirigent: moderator send error: ${String(err)}`);
return false;
const error = String(err);
logger.warn(`dirigent: moderator send error channel=${channelId}: ${error}`);
return { ok: false, channelId, error };
}
}
/** Delete a Discord message. */
export async function deleteMessage(
token: string,
channelId: string,
messageId: string,
logger: Logger,
): Promise<void> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, {
method: "DELETE",
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) {
logger.warn(`dirigent: deleteMessage failed channel=${channelId} msg=${messageId} status=${r.status}`);
}
} catch (err) {
logger.warn(`dirigent: deleteMessage error: ${String(err)}`);
}
}
/**
* Per-channel last schedule-trigger message ID.
* Stored on globalThis so it survives VM-context hot-reloads.
* Used by sendScheduleTrigger to delete the PREVIOUS trigger when a new one is sent.
*/
const _LAST_TRIGGER_KEY = "_dirigentLastTriggerMsgId";
if (!(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY]) {
(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] = new Map<string, string>();
}
const lastTriggerMsgId: Map<string, string> = (globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] as Map<string, string>;
/**
* Send a schedule-identifier trigger message, then delete the PREVIOUS one for
* this channel (so only the current trigger is visible at any time).
*
* In debugMode the previous message is NOT deleted, leaving a full trigger
* history visible in Discord for inspection.
*/
export async function sendScheduleTrigger(
token: string,
channelId: string,
content: string,
logger: Logger,
debugMode = false,
): Promise<void> {
const prevMsgId = lastTriggerMsgId.get(channelId);
const result = await sendModeratorMessage(token, channelId, content, logger);
if (!result.ok || !result.messageId) return;
if (!debugMode) {
// Track the new message so the NEXT call can delete it
lastTriggerMsgId.set(channelId, result.messageId);
// Delete the previous trigger with a small delay
if (prevMsgId) {
await new Promise((r) => setTimeout(r, 300));
await deleteMessage(token, channelId, prevMsgId, logger);
}
}
// debugMode: don't track, don't delete — every trigger stays in history
}
/** Get the latest message ID in a channel (for use as poll anchor). */
export async function getLatestMessageId(
token: string,
channelId: string,
): Promise<string | undefined> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?limit=1`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return undefined;
const msgs = (await r.json()) as Array<{ id: string }>;
return msgs[0]?.id;
} catch {
return undefined;
}
}
type DiscordMessage = { id: string; author: { id: string }; content: string };
/**
* Poll the channel until any message from agentDiscordUserId with id > anchorId
* appears. The anchor is set in before_model_resolve just before the LLM call,
* so any agent message after it must belong to this turn.
*
* Uses exponential back-off starting at initialPollMs, doubling each miss,
* capped at maxPollMs. Times out after timeoutMs (default 30 s) to avoid
* getting permanently stuck when the agent's Discord message is never delivered.
*
* @returns { matched: true } when found, { interrupted: true } when aborted,
* { matched: false, interrupted: false } on timeout.
*/
export async function pollForTailMatch(opts: {
token: string;
channelId: string;
anchorId: string;
agentDiscordUserId: string;
/** Initial poll interval in ms (default 800). */
initialPollMs?: number;
/** Maximum poll interval in ms (default 8 000). */
maxPollMs?: number;
/** Give up and return after this many ms (default 30 000). */
timeoutMs?: number;
/** Callback checked before each poll; if true, polling is aborted. */
isInterrupted?: () => boolean;
}): Promise<{ matched: boolean; interrupted: boolean }> {
const {
token, channelId, anchorId, agentDiscordUserId,
initialPollMs = 800,
maxPollMs = 8_000,
timeoutMs = 30_000,
isInterrupted = () => false,
} = opts;
let interval = initialPollMs;
const deadline = Date.now() + timeoutMs;
while (true) {
if (isInterrupted()) return { matched: false, interrupted: true };
if (Date.now() >= deadline) return { matched: false, interrupted: false };
try {
const r = await fetch(
`https://discord.com/api/v10/channels/${channelId}/messages?limit=20`,
{ headers: { Authorization: `Bot ${token}` } },
);
if (r.ok) {
const msgs = (await r.json()) as DiscordMessage[];
const found = msgs.some(
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
);
if (found) return { matched: true, interrupted: false };
}
} catch {
// ignore transient errors, keep polling
}
await new Promise((r) => setTimeout(r, interval));
// Exponential back-off, capped at maxPollMs
interval = Math.min(interval * 2, maxPollMs);
}
}
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
export async function createDiscordChannel(opts: {
token: string;
guildId: string;
name: string;
/** Permission overwrites: [{id, type (0=role/1=member), allow, deny}] */
permissionOverwrites?: Array<{ id: string; type: number; allow?: string; deny?: string }>;
logger: Logger;
}): Promise<string> {
const { token, guildId, name, permissionOverwrites = [], logger } = opts;
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
method: "POST",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, type: 0, permission_overwrites: permissionOverwrites }),
});
const json = (await r.json()) as Record<string, unknown>;
if (!r.ok) {
const err = `Discord channel create failed (${r.status}): ${JSON.stringify(json)}`;
logger.warn(`dirigent: ${err}`);
throw new Error(err);
}
const channelId = json.id as string;
logger.info(`dirigent: created Discord channel ${name} id=${channelId} in guild=${guildId}`);
return channelId;
}
/** Fetch guilds where the moderator bot has admin permissions. */
export async function fetchAdminGuilds(token: string): Promise<Array<{ id: string; name: string }>> {
const r = await fetch("https://discord.com/api/v10/users/@me/guilds", {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return [];
const guilds = (await r.json()) as Array<{ id: string; name: string; permissions: string }>;
const ADMIN = 8n;
return guilds.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN);
}
/** Fetch private group channels in a guild visible to the moderator bot. */
export async function fetchGuildChannels(
token: string,
guildId: string,
): Promise<Array<{ id: string; name: string; type: number }>> {
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return [];
const channels = (await r.json()) as Array<{ id: string; name: string; type: number }>;
// type 0 = GUILD_TEXT; filter to text channels only (group private channels are type 0)
return channels.filter((c) => c.type === 0);
}
/** Get bot's own Discord user ID from token. */
export function getBotUserIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}

View File

@@ -1,51 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
let noReplyProcess: ChildProcess | null = null;
export function startNoReplyApi(
logger: { info: (m: string) => void; warn: (m: string) => void },
pluginDir: string,
port = 8787,
): void {
logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`);
if (noReplyProcess) {
logger.info("dirigent: no-reply API already running, skipping");
return;
}
const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs");
logger.info(`dirigent: resolved serverPath=${serverPath}`);
if (!fs.existsSync(serverPath)) {
logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`);
return;
}
logger.info("dirigent: no-reply API server found, spawning process...");
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})`);
}
export function stopNoReplyApi(logger: { info: (m: string) => void }): void {
if (!noReplyProcess) return;
logger.info("dirigent: stopping no-reply API");
noReplyProcess.kill("SIGTERM");
noReplyProcess = null;
}

View File

@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
import type { IdentityRegistry } from "./identity-registry.js";
type EgoData = {
columns?: string[];
agentScope?: Record<string, Record<string, string>>;
};
/**
* Scan padded-cell's ego.json and upsert agent Discord IDs into the identity registry.
* Only runs if ego.json contains the "discord-id" column — otherwise treated as absent.
*
* @returns number of entries upserted, or -1 if padded-cell is not detected.
*/
export function scanPaddedCell(
registry: IdentityRegistry,
openclawDir: string,
logger: { info: (m: string) => void; warn: (m: string) => void },
): number {
const egoPath = path.join(openclawDir, "ego.json");
if (!fs.existsSync(egoPath)) {
logger.info("dirigent: padded-cell ego.json not found — skipping auto-registration");
return -1;
}
let ego: EgoData;
try {
ego = JSON.parse(fs.readFileSync(egoPath, "utf8"));
} catch (e) {
logger.warn(`dirigent: failed to parse ego.json: ${String(e)}`);
return -1;
}
if (!Array.isArray(ego.columns) || !ego.columns.includes("discord-id")) {
logger.info('dirigent: ego.json does not have "discord-id" column — padded-cell not configured for Discord, skipping');
return -1;
}
const agentScope = ego.agentScope ?? {};
let count = 0;
for (const [agentId, fields] of Object.entries(agentScope)) {
const discordUserId = fields["discord-id"];
if (!discordUserId || typeof discordUserId !== "string") continue;
const existing = registry.findByAgentId(agentId);
registry.upsert({
agentId,
discordUserId,
agentName: existing?.agentName ?? agentId,
});
count++;
}
logger.info(`dirigent: padded-cell scan complete — upserted ${count} identity entries`);
return count;
}

View File

@@ -1,31 +0,0 @@
import type { Decision } from "../rules.js";
export type DecisionRecord = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
export const MAX_SESSION_DECISIONS = 2000;
export const DECISION_TTL_MS = 5 * 60 * 1000;
export const sessionDecision = new Map<string, DecisionRecord>();
export const sessionAllowed = new Map<string, boolean>();
export const sessionInjected = new Set<string>();
export const sessionChannelId = new Map<string, string>();
export const sessionAccountId = new Map<string, string>();
export const sessionTurnHandled = new Set<string>();
export function pruneDecisionMap(now = Date.now()): void {
for (const [k, v] of sessionDecision.entries()) {
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
}
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
const keys = sessionDecision.keys();
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
const k = keys.next();
if (k.done) break;
sessionDecision.delete(k.value);
}
}

View File

@@ -0,0 +1,119 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
let noReplyProcess: ChildProcess | null = null;
const LOCK_FILE = path.join(os.tmpdir(), "dirigent-sidecar.lock");
function readLock(): { pid: number } | null {
try {
const raw = fs.readFileSync(LOCK_FILE, "utf8").trim();
return { pid: Number(raw) };
} catch {
return null;
}
}
function writeLock(pid: number): void {
try { fs.writeFileSync(LOCK_FILE, String(pid)); } catch { /* ignore */ }
}
function clearLock(): void {
try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
}
function isLockHeld(): boolean {
const lock = readLock();
if (!lock) return false;
try {
process.kill(lock.pid, 0);
return true;
} catch {
return false;
}
}
export function startSideCar(
logger: { info: (m: string) => void; warn: (m: string) => void },
pluginDir: string,
port = 8787,
moderatorToken?: string,
pluginApiToken?: string,
gatewayPort?: number,
debugMode?: boolean,
): void {
logger.info(`dirigent: startSideCar called, pluginDir=${pluginDir}`);
if (noReplyProcess) {
logger.info("dirigent: no-reply API already running (local ref), skipping");
return;
}
if (isLockHeld()) {
logger.info("dirigent: no-reply API already running (lock file), skipping");
return;
}
// services/main.mjs lives alongside the plugin directory in the distribution
const serverPath = path.resolve(pluginDir, "services", "main.mjs");
logger.info(`dirigent: resolved serverPath=${serverPath}`);
if (!fs.existsSync(serverPath)) {
logger.warn(`dirigent: services/main.mjs not found at ${serverPath}, skipping`);
return;
}
logger.info("dirigent: services/main.mjs found, spawning process...");
// Build plugin API URL from gateway port, or use a default
const pluginApiUrl = gatewayPort
? `http://127.0.0.1:${gatewayPort}`
: "http://127.0.0.1:18789";
const env: NodeJS.ProcessEnv = {
...process.env,
SERVICES_PORT: String(port),
PLUGIN_API_URL: pluginApiUrl,
};
if (moderatorToken) {
env.MODERATOR_TOKEN = moderatorToken;
}
if (pluginApiToken) {
env.PLUGIN_API_TOKEN = pluginApiToken;
}
if (debugMode !== undefined) {
env.DEBUG_MODE = debugMode ? "true" : "false";
}
noReplyProcess = spawn(process.execPath, [serverPath], {
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
if (noReplyProcess.pid) {
writeLock(noReplyProcess.pid);
}
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: services: ${d.toString().trim()}`));
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: services: ${d.toString().trim()}`));
noReplyProcess.on("exit", (code, signal) => {
logger.info(`dirigent: services exited (code=${code}, signal=${signal})`);
clearLock();
noReplyProcess = null;
});
logger.info(`dirigent: services started (pid=${noReplyProcess.pid}, port=${port})`);
}
export function stopSideCar(logger: { info: (m: string) => void }): void {
if (!noReplyProcess) return;
logger.info("dirigent: stopping sidecar");
noReplyProcess.kill("SIGTERM");
noReplyProcess = null;
clearLock();
}

View File

@@ -1,127 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { initTurnOrder } from "../turn-manager.js";
import { fetchVisibleChannelBotAccountIds } from "./channel-members.js";
const channelSeenAccounts = new Map<string, Set<string>>();
const channelBootstrapTried = new Set<string>();
let cacheLoaded = false;
function cachePath(api: OpenClawPluginApi): string {
return api.resolvePath("~/.openclaw/dirigent-channel-members.json");
}
function loadCache(api: OpenClawPluginApi): void {
if (cacheLoaded) return;
cacheLoaded = true;
const p = cachePath(api);
try {
if (!fs.existsSync(p)) return;
const raw = fs.readFileSync(p, "utf8");
const parsed = JSON.parse(raw) as Record<string, { botAccountIds?: string[]; source?: string; guildId?: string; updatedAt?: string }>;
for (const [channelId, rec] of Object.entries(parsed || {})) {
const ids = Array.isArray(rec?.botAccountIds) ? rec.botAccountIds.filter(Boolean) : [];
if (ids.length > 0) channelSeenAccounts.set(channelId, new Set(ids));
}
} catch (err) {
api.logger.warn?.(`dirigent: failed loading channel member cache: ${String(err)}`);
}
}
function inferGuildIdFromChannelId(api: OpenClawPluginApi, channelId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
for (const rec of Object.values(accounts)) {
const chMap = (rec?.channels as Record<string, Record<string, unknown>> | undefined) || undefined;
if (!chMap) continue;
const direct = chMap[channelId];
const prefixed = chMap[`channel:${channelId}`];
const found = (direct || prefixed) as Record<string, unknown> | undefined;
if (found && typeof found.guildId === "string" && found.guildId.trim()) return found.guildId.trim();
}
return undefined;
}
function persistCache(api: OpenClawPluginApi): void {
const p = cachePath(api);
const out: Record<string, { botAccountIds: string[]; updatedAt: string; source: string; guildId?: string }> = {};
for (const [channelId, set] of channelSeenAccounts.entries()) {
out[channelId] = {
botAccountIds: [...set],
updatedAt: new Date().toISOString(),
source: "dirigent/turn-bootstrap",
guildId: inferGuildIdFromChannelId(api, channelId),
};
}
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, "utf8");
fs.renameSync(tmp, p);
} catch (err) {
api.logger.warn?.(`dirigent: failed persisting channel member cache: ${String(err)}`);
}
}
function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return [];
const ids: string[] = [];
for (const b of bindings) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
ids.push(match.accountId);
}
}
return ids;
}
function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] {
const allBots = new Set(getAllBotAccountIds(api));
const seen = channelSeenAccounts.get(channelId);
if (!seen) return [];
return [...seen].filter((id) => allBots.has(id));
}
export function recordChannelAccount(api: OpenClawPluginApi, channelId: string, accountId: string): boolean {
loadCache(api);
let seen = channelSeenAccounts.get(channelId);
if (!seen) {
seen = new Set();
channelSeenAccounts.set(channelId, seen);
}
if (seen.has(accountId)) return false;
seen.add(accountId);
persistCache(api);
return true;
}
export async function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): Promise<void> {
loadCache(api);
let botAccounts = getChannelBotAccountIds(api, channelId);
api.logger.info(
`dirigent: turn-debug ensureTurnOrder enter channel=${channelId} cached=${JSON.stringify(botAccounts)} bootstrapTried=${channelBootstrapTried.has(channelId)}`,
);
if (botAccounts.length === 0 && !channelBootstrapTried.has(channelId)) {
channelBootstrapTried.add(channelId);
const discovered = await fetchVisibleChannelBotAccountIds(api, channelId).catch(() => [] as string[]);
api.logger.info(
`dirigent: turn-debug ensureTurnOrder bootstrap-discovered channel=${channelId} discovered=${JSON.stringify(discovered)}`,
);
for (const aid of discovered) recordChannelAccount(api, channelId, aid);
botAccounts = getChannelBotAccountIds(api, channelId);
}
if (botAccounts.length > 0) {
api.logger.info(
`dirigent: turn-debug ensureTurnOrder initTurnOrder channel=${channelId} members=${JSON.stringify(botAccounts)}`,
);
initTurnOrder(channelId, botAccounts);
}
}

View File

@@ -1,45 +0,0 @@
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
export function pickDefined(input: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (v !== undefined) out[k] = v;
}
return out;
}
export 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;
return allow.includes(channelId);
}
export function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
return {
sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined,
commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined,
messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined,
channel: typeof ctx.channel === "string" ? ctx.channel : undefined,
channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined,
senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined,
from: typeof ctx.from === "string" ? ctx.from : undefined,
metaSenderId:
typeof meta.senderId === "string"
? meta.senderId
: typeof meta.sender_id === "string"
? meta.sender_id
: undefined,
metaUserId:
typeof meta.userId === "string"
? meta.userId
: typeof meta.user_id === "string"
? meta.user_id
: undefined,
};
}

View File

@@ -1,37 +0,0 @@
import {
extractDiscordChannelId,
extractDiscordChannelIdFromConversationMetadata,
extractDiscordChannelIdFromSessionKey,
extractUntrustedConversationInfo,
} from "./channel-resolver.js";
export type DerivedDecisionInput = {
channel: string;
channelId?: string;
senderId?: string;
content: string;
conv: Record<string, unknown>;
};
export function deriveDecisionInputFromPrompt(params: {
prompt: string;
messageProvider?: string;
sessionKey?: string;
ctx?: Record<string, unknown>;
event?: Record<string, unknown>;
}): DerivedDecisionInput {
const { prompt, messageProvider, sessionKey, ctx, event } = params;
const conv = extractUntrustedConversationInfo(prompt) || {};
const channel = (messageProvider || "").toLowerCase();
let channelId = extractDiscordChannelId(ctx || {}, event);
if (!channelId) channelId = extractDiscordChannelIdFromSessionKey(sessionKey);
if (!channelId) channelId = extractDiscordChannelIdFromConversationMetadata(conv);
const senderId =
(typeof conv.sender_id === "string" && conv.sender_id) ||
(typeof conv.sender === "string" && conv.sender) ||
undefined;
return { channel, channelId, senderId, content: prompt, conv };
}

272
plugin/hooks/agent-end.ts Normal file
View File

@@ -0,0 +1,272 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { parseDiscordChannelId } from "./before-model-resolve.js";
import {
isCurrentSpeaker,
getAnchor,
advanceSpeaker,
wakeFromDormant,
isDormant,
hasSpeakers,
getDebugInfo,
isTurnPending,
clearTurnPending,
consumeBlockedPending,
type SpeakerEntry,
} from "../turn-manager.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
import { pollForTailMatch, sendScheduleTrigger } from "../core/moderator-discord.js";
/**
* Process-level deduplication for agent_end events.
* OpenClaw hot-reloads plugin modules (re-imports), stacking duplicate handlers.
* Using globalThis ensures the dedup Set survives module reloads and is shared
* by all handler instances in the same process.
*/
const _AGENT_END_DEDUP_KEY = "_dirigentProcessedAgentEndRunIds";
if (!(globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY]) {
(globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] = new Set<string>();
}
const processedAgentEndRunIds: Set<string> = (globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] as Set<string>;
/**
* Per-channel advance lock: prevents two concurrent agent_end handler instances
* (from different VM contexts with different runIds) from both calling advanceSpeaker
* for the same channel at the same time, which would double-advance the speaker index.
*/
const _ADVANCE_LOCK_KEY = "_dirigentAdvancingChannels";
if (!(globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY]) {
(globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY] = new Set<string>();
}
const advancingChannels: Set<string> = (globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY] as Set<string>;
/** Extract plain text from agent_end event.messages last assistant entry. */
function extractFinalText(messages: unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i] as Record<string, unknown> | null;
if (!msg || msg.role !== "assistant") continue;
const content = msg.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
let text = "";
for (const part of content) {
const p = part as Record<string, unknown>;
if (p?.type === "text" && typeof p.text === "string") text += p.text;
}
return text;
}
break;
}
return "";
}
function isEmptyTurn(text: string): boolean {
const t = text.trim();
if (t === "") return true;
// Check if the last non-empty line is NO or NO_REPLY (agents often write
// explanatory text before the final answer on the last line).
const lines = t.split("\n").map((l) => l.trim()).filter((l) => l !== "");
const last = lines[lines.length - 1] ?? "";
return /^NO$/i.test(last) || /^NO_REPLY$/i.test(last);
}
export type AgentEndDeps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
debugMode: boolean;
/** Called when discussion channel enters dormant — to send idle reminder. */
onDiscussionDormant?: (channelId: string) => Promise<void>;
};
/** Exposed so message-received can interrupt an in-progress tail-match wait. */
export type InterruptFn = (channelId: string) => void;
export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, onDiscussionDormant } = deps;
const interruptedChannels = new Set<string>();
function interrupt(channelId: string): void {
interruptedChannels.add(channelId);
setTimeout(() => interruptedChannels.delete(channelId), 5000);
}
async function buildSpeakerList(channelId: string): Promise<SpeakerEntry[]> {
if (!moderatorBotToken) return [];
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const result: SpeakerEntry[] = [];
for (const agentId of agentIds) {
const identity = identityRegistry.findByAgentId(agentId);
if (identity) result.push({ agentId, discordUserId: identity.discordUserId });
}
return result;
}
async function triggerNextSpeaker(channelId: string, next: SpeakerEntry): Promise<void> {
if (!moderatorBotToken) return;
const msg = `<@${next.discordUserId}>${scheduleIdentifier}`;
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
api.logger.info(`dirigent: triggered next speaker agentId=${next.agentId} channel=${channelId}`);
}
api.on("agent_end", async (event, ctx) => {
try {
// Deduplicate: skip if this runId was already processed by another handler
// instance (can happen when OpenClaw hot-reloads the plugin in the same process).
const runId = (event as Record<string, unknown>).runId as string | undefined;
if (runId) {
if (processedAgentEndRunIds.has(runId)) return;
processedAgentEndRunIds.add(runId);
// Evict old entries to prevent unbounded growth (keep last 500)
if (processedAgentEndRunIds.size > 500) {
const oldest = processedAgentEndRunIds.values().next().value;
if (oldest) processedAgentEndRunIds.delete(oldest);
}
}
const sessionKey = ctx.sessionKey;
if (!sessionKey) return;
const channelId = parseDiscordChannelId(sessionKey);
if (!channelId) return;
const mode = channelStore.getMode(channelId);
if (mode === "none" || mode === "work" || mode === "report") return;
if (!hasSpeakers(channelId)) return;
const agentId = ctx.agentId;
if (!agentId) return;
// Extract final text early so we can drain blocked_pending at every early-return point.
// This prevents the counter from staying inflated when stale NO_REPLYs are discarded
// by !isCurrentSpeaker or !isTurnPending without reaching consumeBlockedPending.
const messages = Array.isArray((event as Record<string, unknown>).messages)
? ((event as Record<string, unknown>).messages as unknown[])
: [];
const finalText = extractFinalText(messages);
const empty = isEmptyTurn(finalText);
if (!isCurrentSpeaker(channelId, agentId)) {
// Drain blocked_pending for non-speaker stale NO_REPLYs. Without this, suppressions
// that happen while this agent is not the current speaker inflate the counter and cause
// its subsequent real empty turn to be misidentified as stale.
if (empty) consumeBlockedPending(channelId, agentId);
return;
}
// Only process agent_ends for turns that were explicitly started by before_model_resolve.
// This prevents stale NO_REPLY completions (from initial suppression) from being counted.
if (!isTurnPending(channelId, agentId)) {
api.logger.info(`dirigent: agent_end skipping stale turn agentId=${agentId} channel=${channelId}`);
// Also drain here: stale may arrive after clearTurnPending but before markTurnStarted.
if (empty) consumeBlockedPending(channelId, agentId);
return;
}
// Consume a blocked-pending slot only for self-wakeup stales: NO_REPLY completions
// from suppressed self-wakeup before_model_resolve calls that fire while the agent
// is current speaker with a turn already in progress.
if (empty && consumeBlockedPending(channelId, agentId)) {
api.logger.info(`dirigent: agent_end skipping blocked-pending NO_REPLY agentId=${agentId} channel=${channelId}`);
return;
}
api.logger.info(
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
);
if (!empty) {
// Real turn: wait for Discord delivery before triggering next speaker.
// Anchor was set in before_model_resolve just before the LLM call, so any
// message from the agent after the anchor must be from this turn.
// NOTE: clearTurnPending is intentionally deferred until after pollForTailMatch
// returns. While waiting, isTurnPending remains true so that any re-trigger of
// this agent is correctly treated as a self-wakeup (suppressed), preventing it
// from starting a second real turn during the tail-match window.
const identity = identityRegistry.findByAgentId(agentId);
if (identity && moderatorBotToken) {
const anchorId = getAnchor(channelId, agentId) ?? "0";
const { matched: _matched, interrupted } = await pollForTailMatch({
token: moderatorBotToken,
channelId,
anchorId,
agentDiscordUserId: identity.discordUserId,
isInterrupted: () => interruptedChannels.has(channelId),
});
if (interrupted) {
if (isDormant(channelId)) {
// Channel is dormant: a new external message woke it — restart from first speaker
api.logger.info(`dirigent: tail-match interrupted (dormant) channel=${channelId} — waking`);
clearTurnPending(channelId, agentId);
const first = wakeFromDormant(channelId);
if (first) await triggerNextSpeaker(channelId, first);
return;
}
// Not dormant: interrupt was a spurious trigger (e.g. moderator bot message).
// Fall through to normal advance so the turn cycle continues correctly.
api.logger.info(`dirigent: tail-match interrupted (non-dormant) channel=${channelId} — advancing normally`);
}
}
}
// Guard against concurrent advanceSpeaker calls for the same channel.
// Two VM context instances may both reach this point with different runIds
// (when the dedup Set doesn't catch them); only the first should advance.
if (advancingChannels.has(channelId)) {
api.logger.info(`dirigent: agent_end advance already in progress, skipping channel=${channelId} agentId=${agentId}`);
return;
}
advancingChannels.add(channelId);
let next: ReturnType<typeof import("../turn-manager.js").getCurrentSpeaker> | null = null;
let enteredDormant = false;
try {
// Determine shuffle mode from current list size
const debugBefore = getDebugInfo(channelId);
const currentListSize = debugBefore.exists ? (debugBefore.speakerList?.length ?? 0) : 0;
const isShuffle = currentListSize > 2;
// In shuffle mode, pass agentId as previousLastAgentId so new list avoids it as first
const previousLastAgentId = isShuffle ? agentId : undefined;
({ next, enteredDormant } = await advanceSpeaker(
channelId,
agentId,
empty,
() => buildSpeakerList(channelId),
previousLastAgentId,
));
} finally {
advancingChannels.delete(channelId);
}
// Clear turn pending AFTER advanceSpeaker completes. This ensures isTurnPending
// remains true during the async rebuildFn window at cycle boundaries, preventing
// re-triggers from starting a second real turn while currentIndex is still at the
// outgoing speaker's position.
clearTurnPending(channelId, agentId);
if (enteredDormant) {
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
if (mode === "discussion") {
await onDiscussionDormant?.(channelId).catch((err) => {
api.logger.warn(`dirigent: onDiscussionDormant failed: ${String(err)}`);
});
}
return;
}
if (next) await triggerNextSpeaker(channelId, next);
} catch (err) {
api.logger.warn(`dirigent: agent_end hook error: ${String(err)}`);
}
});
return interrupt;
}

View File

@@ -1,202 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolvePolicy, type DirigentConfig } from "../rules.js";
import { getTurnDebugInfo, onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
type BeforeMessageWriteDeps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
policyState: { channelPolicies: Record<string, unknown> };
sessionAllowed: Map<string, boolean>;
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
sessionTurnHandled: Set<string>;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
sendModeratorMessage: (
botToken: string,
channelId: string,
content: string,
logger: { info: (m: string) => void; warn: (m: string) => void },
) => Promise<void>;
};
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
const {
api,
baseConfig,
policyState,
sessionAllowed,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded,
shouldDebugLog,
ensureTurnOrder,
resolveDiscordUserId,
sendModeratorMessage,
} = deps;
api.on("before_message_write", (event, ctx) => {
try {
api.logger.info(
`dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
);
const key = ctx.sessionKey;
let channelId: string | undefined;
let accountId: string | undefined;
if (key) {
channelId = sessionChannelId.get(key);
accountId = sessionAccountId.get(key);
}
let content = "";
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
const msgContent = msg?.content;
if (msg) {
const role = msg.role as string | undefined;
if (role && role !== "assistant") return;
// Detect tool calls — intermediate model step, not a final response.
// Skip turn processing entirely to avoid false NO_REPLY detection.
if (Array.isArray(msgContent)) {
const hasToolCalls = (msgContent as Record<string, unknown>[]).some(
(part) => part?.type === "toolCall" || part?.type === "tool_call" || part?.type === "tool_use",
);
if (hasToolCalls) {
api.logger.info(
`dirigent: before_message_write skipping tool-call message session=${key ?? "undefined"} channel=${channelId ?? "undefined"}`,
);
return;
}
}
if (typeof msg.content === "string") {
content = msg.content;
} else if (Array.isArray(msg.content)) {
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") {
content += (part as Record<string, unknown>).text;
}
}
}
}
if (!content) {
content = ((event as Record<string, unknown>).content as string) || "";
}
api.logger.info(
`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;
const currentTurn = getTurnDebugInfo(channelId);
if (currentTurn.currentSpeaker !== accountId) {
api.logger.info(
`dirigent: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`,
);
return;
}
const live = baseConfig as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
const trimmed = content.trim();
const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed);
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
const waitId = live.waitIdentifier || "👤";
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
// Treat explicit NO/NO_REPLY keywords as no-reply.
const wasNoReply = isNoReply;
const turnDebug = getTurnDebugInfo(channelId);
api.logger.info(
`dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
);
if (hasWaitIdentifier) {
setWaitingForHuman(channelId);
sessionAllowed.delete(key);
sessionTurnHandled.add(key);
api.logger.info(
`dirigent: before_message_write wait-for-human triggered session=${key} channel=${channelId} accountId=${accountId}`,
);
return;
}
const wasAllowed = sessionAllowed.get(key);
if (wasNoReply) {
const noReplyKeyword = /^NO$/i.test(trimmed) ? "NO" : "NO_REPLY";
api.logger.info(
`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed} keyword=${noReplyKeyword}`,
);
if (wasAllowed === undefined) return;
if (wasAllowed === false) {
sessionAllowed.delete(key);
api.logger.info(
`dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
);
return;
}
void ensureTurnOrder(api, channelId);
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
sessionAllowed.delete(key);
sessionTurnHandled.add(key);
api.logger.info(
`dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
);
if (!nextSpeaker) {
if (shouldDebugLog(live, channelId)) {
api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`);
}
return;
}
if (live.moderatorBotToken) {
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
if (nextUserId) {
const schedulingId = live.schedulingIdentifier || "➡️";
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => {
api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`);
});
} else {
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
}
}
} else if (hasEndSymbol) {
void ensureTurnOrder(api, channelId);
const nextSpeaker = onSpeakerDone(channelId, accountId, false);
sessionAllowed.delete(key);
sessionTurnHandled.add(key);
api.logger.info(
`dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
);
} else {
api.logger.info(`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`);
return;
}
} catch (err) {
api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`);
}
});
}

View File

@@ -1,138 +1,165 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js";
import { checkTurn } from "../turn-manager.js";
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { isCurrentSpeaker, isTurnPending, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { getLatestMessageId, sendScheduleTrigger } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
export function parseDiscordChannelId(sessionKey: string): string | undefined {
const m = sessionKey.match(/:discord:channel:(\d+)$/);
return m?.[1];
}
type DecisionRecord = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
type BeforeModelResolveDeps = {
type Deps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
sessionDecision: Map<string, DecisionRecord>;
sessionAllowed: Map<string, boolean>;
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
policyState: { channelPolicies: Record<string, unknown> };
DECISION_TTL_MS: number;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
pruneDecisionMap: () => void;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
debugMode: boolean;
noReplyProvider: string;
noReplyModel: string;
};
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
const {
api,
baseConfig,
sessionDecision,
sessionAllowed,
sessionChannelId,
sessionAccountId,
policyState,
DECISION_TTL_MS,
ensurePolicyStateLoaded,
resolveAccountId,
pruneDecisionMap,
shouldDebugLog,
ensureTurnOrder,
} = deps;
/**
* Process-level deduplication for before_model_resolve events.
* Uses a WeakSet keyed on the event object — works when OpenClaw passes
* the same event reference to all stacked handlers (hot-reload scenario).
* Stored on globalThis so it persists across module reloads.
*/
const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents";
if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] = new WeakSet<object>();
}
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
export function registerBeforeModelResolveHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, noReplyProvider, noReplyModel } = deps;
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
const initializingChannels = getInitializingChannels();
api.on("before_model_resolve", async (event, ctx) => {
const key = ctx.sessionKey;
if (!key) return;
// Deduplicate: if another handler instance already processed this event
// object, skip. Prevents double-counting from hot-reload stacked handlers.
const eventObj = event as object;
if (processedBeforeModelResolveEvents.has(eventObj)) return;
processedBeforeModelResolveEvents.add(eventObj);
const live = baseConfig as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
const sessionKey = ctx.sessionKey;
if (!sessionKey) return;
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
// Only handle Discord group channel sessions
const channelId = parseDiscordChannelId(sessionKey);
if (!channelId) return;
if (live.enableDebugLogs) {
api.logger.info(
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
`promptPreview=${prompt.slice(0, 300)}`,
);
const mode = channelStore.getMode(channelId);
// dead/report mode: suppress all via no-reply model
if (mode === "report" || mode === "dead" as string) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
const derived = deriveDecisionInputFromPrompt({
prompt,
messageProvider: ctx.messageProvider,
sessionKey: key,
ctx: ctx as Record<string, unknown>,
event: event as Record<string, unknown>,
});
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
if (derived.channelId) {
sessionChannelId.set(key, derived.channelId);
}
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
if (resolvedAccountId) {
sessionAccountId.set(key, resolvedAccountId);
}
let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key);
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies as Record<string, any>,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
sessionDecision.set(key, rec);
pruneDecisionMap();
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`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 ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
// concluded discussion: suppress via no-reply model
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
}
if (derived.channelId) {
await ensureTurnOrder(api, derived.channelId);
const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId);
if (!turnCheck.allowed) {
sessionAllowed.set(key, false);
api.logger.info(
`dirigent: before_model_resolve blocking out-of-turn speaker session=${key} channel=${derived.channelId} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker}`,
);
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
// disabled modes: no turn management
if (mode === "none" || mode === "work") return;
// chat / discussion (active): check turn
const agentId = ctx.agentId;
if (!agentId) return;
// If speaker list not yet loaded, initialize it now
if (!hasSpeakers(channelId)) {
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
// incrementBlockedPending so agent_end knows to expect a stale NO_REPLY completion later
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
sessionAllowed.set(key, true);
initializingChannels.add(channelId);
try {
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const speakers: SpeakerEntry[] = agentIds
.map((aid) => {
const entry = identityRegistry.findByAgentId(aid);
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
})
.filter((s): s is SpeakerEntry => s !== null);
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
// If this agent is NOT the first speaker, trigger first speaker and suppress self
if (first.agentId !== agentId && moderatorBotToken) {
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// Fall through — this agent IS the first speaker
} else {
// No registered agents visible — let everyone respond freely
return;
}
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
return;
} finally {
initializingChannels.delete(channelId);
}
}
if (!rec.decision.shouldUseNoReply) return;
// Channel is dormant: suppress via no-reply model
if (isDormant(channelId)) {
api.logger.info(`dirigent: before_model_resolve suppressing dormant agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
const out: Record<string, unknown> = { noReply: true };
if (rec.decision.provider) out.provider = rec.decision.provider;
if (rec.decision.model) out.model = rec.decision.model;
return out;
if (!isCurrentSpeaker(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve suppressing non-speaker agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// If a turn is already in progress for this agent, this is a duplicate wakeup
// (e.g. agent woke itself via a message-tool send). Suppress it.
if (isTurnPending(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve turn already in progress, suppressing self-wakeup agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
markTurnStarted(channelId, agentId);
// Current speaker: record anchor message ID for tail-match polling
if (moderatorBotToken) {
try {
const anchorId = await getLatestMessageId(moderatorBotToken, channelId);
if (anchorId) {
setAnchor(channelId, agentId, anchorId);
api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`);
}
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`);
}
}
// Verify agent has a known Discord user ID (needed for tail-match later)
const identity = identityRegistry.findByAgentId(agentId);
if (!identity) {
api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`);
}
});
}

View File

@@ -1,128 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, resolvePolicy, type Decision, type DirigentConfig } from "../rules.js";
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
type DecisionRecord = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
type BeforePromptBuildDeps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
sessionDecision: Map<string, DecisionRecord>;
sessionInjected: Set<string>;
policyState: { channelPolicies: Record<string, unknown> };
DECISION_TTL_MS: number;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
buildEndMarkerInstruction: (endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string) => string;
buildSchedulingIdentifierInstruction: (schedulingIdentifier: string) => string;
buildAgentIdentity: (api: OpenClawPluginApi, agentId: string) => string;
};
export function registerBeforePromptBuildHook(deps: BeforePromptBuildDeps): void {
const {
api,
baseConfig,
sessionDecision,
sessionInjected,
policyState,
DECISION_TTL_MS,
ensurePolicyStateLoaded,
shouldDebugLog,
buildEndMarkerInstruction,
buildSchedulingIdentifierInstruction,
buildAgentIdentity,
} = deps;
api.on("before_prompt_build", async (event, ctx) => {
const key = ctx.sessionKey;
if (!key) return;
const live = baseConfig as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key);
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
const derived = deriveDecisionInputFromPrompt({
prompt,
messageProvider: ctx.messageProvider,
sessionKey: key,
ctx: ctx as Record<string, unknown>,
event: event as Record<string, unknown>,
});
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies as Record<string, any>,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`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 ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
}
}
sessionDecision.delete(key);
if (sessionInjected.has(key)) {
if (shouldDebugLog(live, undefined)) {
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`);
}
return;
}
if (!rec.decision.shouldInjectEndMarkerPrompt) {
if (shouldDebugLog(live, undefined)) {
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`);
}
return;
}
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
const derived = deriveDecisionInputFromPrompt({
prompt,
messageProvider: ctx.messageProvider,
sessionKey: key,
ctx: ctx as Record<string, unknown>,
event: event as Record<string, unknown>,
});
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record<string, any>);
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
const schedulingId = live.schedulingIdentifier || "➡️";
const waitId = live.waitIdentifier || "👤";
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId);
let identity = "";
if (isGroupChat && ctx.agentId) {
const idStr = buildAgentIdentity(api, ctx.agentId);
if (idStr) {
identity = `\n\nYour agent identity: ${idStr}.`;
}
}
const schedulingInstruction = isGroupChat ? buildSchedulingIdentifierInstruction(schedulingId) : "";
(event as Record<string, unknown>).prompt = `${prompt}\n\n${instruction}${identity}${schedulingInstruction}`;
sessionInjected.add(key);
});
}

View File

@@ -1,113 +1,128 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js";
import { extractDiscordChannelId } from "../channel-resolver.js";
import type { DirigentConfig } from "../rules.js";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { parseDiscordChannelId } from "./before-model-resolve.js";
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { sendScheduleTrigger, userIdFromBotToken } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
import type { InterruptFn } from "./agent-end.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
type MessageReceivedDeps = {
type Deps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
debugCtxSummary: (ctx: Record<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
getModeratorUserId: (cfg: DirigentConfig) => string | undefined;
recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean;
extractMentionedUserIds: (content: string) => string[];
buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map<string, string>;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
interruptTailMatch: InterruptFn;
debugMode: boolean;
/**
* When true, the moderator service handles wake-from-dormant and
* interrupt-tail-match via HTTP callback. This hook only runs speaker-list
* initialization in that case.
*/
moderatorHandlesMessages?: boolean;
};
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void {
const {
api,
baseConfig,
shouldDebugLog,
debugCtxSummary,
ensureTurnOrder,
getModeratorUserId,
recordChannelAccount,
extractMentionedUserIds,
buildUserIdToAccountIdMap,
} = deps;
export function registerMessageReceivedHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch, debugMode, moderatorHandlesMessages } = deps;
const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined;
api.on("message_received", async (event, ctx) => {
try {
const c = (ctx || {}) as Record<string, unknown>;
const e = (event || {}) as Record<string, unknown>;
const preChannelId = extractDiscordChannelId(c, e);
const livePre = baseConfig as DirigentConfig & DebugConfig;
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
const e = event as Record<string, unknown>;
const c = ctx as Record<string, unknown>;
// Extract Discord channel ID from ctx or event metadata
let channelId: string | undefined;
if (typeof c.channelId === "string") {
const bare = c.channelId.match(/^(\d+)$/)?.[1] ?? c.channelId.match(/:(\d+)$/)?.[1];
if (bare) channelId = bare;
}
if (preChannelId) {
await ensureTurnOrder(api, preChannelId);
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
const from =
(typeof metadata?.senderId === "string" && metadata.senderId) ||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
const moderatorUserId = getModeratorUserId(livePre);
if (moderatorUserId && from === moderatorUserId) {
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`);
if (!channelId && typeof c.sessionKey === "string") {
channelId = parseDiscordChannelId(c.sessionKey);
}
} else {
const humanList = livePre.humanList || livePre.bypassUserIds || [];
const isHuman = humanList.includes(from);
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
if (!channelId) {
const metadata = e.metadata as Record<string, unknown> | undefined;
const to = String(metadata?.to ?? metadata?.originatingTo ?? "");
const toMatch = to.match(/:(\d+)$/);
if (toMatch) channelId = toMatch[1];
}
if (!channelId) {
const metadata = e.metadata as Record<string, unknown> | undefined;
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
const raw = String(convInfo?.channel_id ?? metadata?.channelId ?? "");
if (/^\d+$/.test(raw)) channelId = raw;
}
if (!channelId) return;
if (senderAccountId && senderAccountId !== "default") {
const isNew = recordChannelAccount(api, preChannelId, senderAccountId);
if (isNew) {
await ensureTurnOrder(api, preChannelId);
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
const mode = channelStore.getMode(channelId);
if (mode === "report") return;
if (mode === "none" || mode === "work") return;
// ── Speaker-list initialization (always runs, even with moderator service) ──
const initializingChannels = getInitializingChannels();
if (!hasSpeakers(channelId) && moderatorBotToken) {
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
return;
}
initializingChannels.add(channelId);
try {
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const speakers: SpeakerEntry[] = agentIds
.map((aid) => {
const entry = identityRegistry.findByAgentId(aid);
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
})
.filter((s): s is SpeakerEntry => s !== null);
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`);
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
return;
}
} finally {
initializingChannels.delete(channelId);
}
}
if (isHuman) {
const messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || "";
const mentionedUserIds = extractMentionedUserIds(messageContent);
// ── Wake / interrupt (skipped when moderator service handles it via HTTP callback) ──
if (moderatorHandlesMessages) return;
if (mentionedUserIds.length > 0) {
const userIdMap = buildUserIdToAccountIdMap(api);
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid);
if (mentionedAccountIds.length > 0) {
await ensureTurnOrder(api, preChannelId);
const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds);
if (overrideSet) {
api.logger.info(
`dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`,
const senderId = String(
(e.metadata as Record<string, unknown>)?.senderId ??
(e.metadata as Record<string, unknown>)?.sender_id ??
e.from ?? "",
);
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`);
}
} else {
onNewMessage(preChannelId, senderAccountId, isHuman);
}
} else {
onNewMessage(preChannelId, senderAccountId, isHuman);
}
} else {
onNewMessage(preChannelId, senderAccountId, isHuman);
}
} else {
onNewMessage(preChannelId, senderAccountId, isHuman);
const currentSpeakerIsThisSender = (() => {
if (!senderId) return false;
const entry = identityRegistry.findByDiscordUserId(senderId);
if (!entry) return false;
return isCurrentSpeaker(channelId!, entry.agentId);
})();
if (!currentSpeakerIsThisSender) {
if (senderId !== moderatorBotUserId) {
interruptTailMatch(channelId);
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
}
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`,
);
if (isDormant(channelId) && moderatorBotToken && senderId !== moderatorBotUserId) {
const first = wakeFromDormant(channelId);
if (first) {
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
}
}
}
} catch (err) {
api.logger.warn(`dirigent: message hook failed: ${String(err)}`);
api.logger.warn(`dirigent: message_received hook error: ${String(err)}`);
}
});
}

View File

@@ -1,123 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolvePolicy, type DirigentConfig } from "../rules.js";
import { onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "../channel-resolver.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
type MessageSentDeps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
policyState: { channelPolicies: Record<string, unknown> };
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
sessionTurnHandled: Set<string>;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
sendModeratorMessage: (
botToken: string,
channelId: string,
content: string,
logger: { info: (m: string) => void; warn: (m: string) => void },
) => Promise<void>;
};
export function registerMessageSentHook(deps: MessageSentDeps): void {
const {
api,
baseConfig,
policyState,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded,
resolveDiscordUserId,
sendModeratorMessage,
} = deps;
api.on("message_sent", async (event, ctx) => {
try {
const key = ctx.sessionKey;
const c = (ctx || {}) as Record<string, unknown>;
const e = (event || {}) as Record<string, unknown>;
api.logger.info(
`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"}`,
);
let channelId = extractDiscordChannelId(c, e);
if (!channelId && key) {
channelId = sessionChannelId.get(key);
}
if (!channelId && key) {
channelId = extractDiscordChannelIdFromSessionKey(key);
}
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
const content = (event.content as string) || "";
api.logger.info(
`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 = baseConfig as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
const trimmed = content.trim();
const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed);
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
const waitId = live.waitIdentifier || "👤";
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
// Treat explicit NO/NO_REPLY keywords as no-reply.
const wasNoReply = isNoReply;
if (key && sessionTurnHandled.has(key)) {
sessionTurnHandled.delete(key);
api.logger.info(
`dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
);
return;
}
if (hasWaitIdentifier) {
setWaitingForHuman(channelId);
api.logger.info(
`dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`,
);
return;
}
if (wasNoReply || hasEndSymbol) {
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
const trigger = wasNoReply ? "no_reply_keyword" : "end_symbol";
const noReplyKeyword = wasNoReply ? (/^NO$/i.test(trimmed) ? "NO" : "NO_REPLY") : "";
const keywordNote = wasNoReply ? ` keyword=${noReplyKeyword}` : "";
api.logger.info(
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}${keywordNote}`,
);
if (wasNoReply && nextSpeaker && live.moderatorBotToken) {
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
if (nextUserId) {
const schedulingId = live.schedulingIdentifier || "➡️";
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
await sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
} else {
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
}
}
}
} catch (err) {
api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`);
}
});
}

View File

@@ -1,206 +1,294 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { DirigentConfig } from "./rules.js";
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
import { registerMessageReceivedHook } from "./hooks/message-received.js";
import { IdentityRegistry } from "./core/identity-registry.js";
import { ChannelStore } from "./core/channel-store.js";
import { scanPaddedCell } from "./core/padded-cell.js";
import { startSideCar, stopSideCar } from "./core/sidecar-process.js";
import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
import { registerBeforePromptBuildHook } from "./hooks/before-prompt-build.js";
import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js";
import { registerMessageSentHook } from "./hooks/message-sent.js";
import { registerDirigentCommand } from "./commands/dirigent-command.js";
import { registerAgentEndHook } from "./hooks/agent-end.js";
import { registerMessageReceivedHook } from "./hooks/message-received.js";
import { registerDirigentTools } from "./tools/register-tools.js";
import { ensurePolicyStateLoaded, persistPolicies, policyState } from "./policy/store.js";
import { buildAgentIdentity, buildUserIdToAccountIdMap, resolveAccountId } from "./core/identity.js";
import { extractMentionedUserIds, getModeratorUserId } from "./core/mentions.js";
import { ensureTurnOrder, recordChannelAccount } from "./core/turn-bootstrap.js";
import { debugCtxSummary, pickDefined, shouldDebugLog } from "./core/utils.js";
import { resolveDiscordUserId, sendModeratorMessage } from "./core/moderator-discord.js";
import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js";
import {
DECISION_TTL_MS,
pruneDecisionMap,
sessionAccountId,
sessionAllowed,
sessionChannelId,
sessionDecision,
sessionInjected,
sessionTurnHandled,
} from "./core/session-state.js";
import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js";
import { registerAddGuildCommand } from "./commands/add-guild-command.js";
import { registerControlPage } from "./web/control-page.js";
import { registerDirigentApi } from "./web/dirigent-api.js";
import { sendModeratorMessage, sendScheduleTrigger, getBotUserIdFromToken } from "./core/moderator-discord.js";
import { setSpeakerList, isCurrentSpeaker, isDormant, wakeFromDormant } from "./turn-manager.js";
import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
type PluginConfig = {
moderatorBotToken?: string;
scheduleIdentifier?: string;
identityFilePath?: string;
channelStoreFilePath?: string;
debugMode?: boolean;
noReplyProvider?: string;
noReplyModel?: string;
sideCarPort?: number;
};
type NormalizedDirigentConfig = DirigentConfig & {
enableDiscordControlTool: boolean;
enableDirigentPolicyTool: boolean;
};
function normalizePluginConfig(api: OpenClawPluginApi): NormalizedDirigentConfig {
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
return {
enableDiscordControlTool: true,
enableDirigentPolicyTool: true,
enableDebugLogs: false,
debugLogChannelIds: [],
noReplyPort: 8787,
schedulingIdentifier: "➡️",
waitIdentifier: "👤",
...(api.pluginConfig || {}),
} as NormalizedDirigentConfig;
moderatorBotToken: cfg.moderatorBotToken ?? "",
scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️",
identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"),
channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"),
debugMode: cfg.debugMode ?? false,
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
noReplyModel: cfg.noReplyModel ?? "no-reply",
sideCarPort: cfg.sideCarPort ?? 8787,
};
}
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string {
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK, NO, or an empty response) must NOT include ${symbols}.`;
if (isGroupChat) {
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.`;
instruction += `\n\nWait for human reply: If you need a human to respond to your message, end with ${waitIdentifier} instead of ${symbols}. This pauses all agents until a human speaks. Use this sparingly — only when you are confident the human is actively participating in the discussion (has sent a message recently). Do NOT use it speculatively.`;
function getGatewayPort(api: OpenClawPluginApi): number {
try {
return ((api.config as Record<string, unknown>)?.gateway as Record<string, unknown>)?.port as number ?? 18789;
} catch {
return 18789;
}
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.`;
/**
* Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once
* when the gateway process starts/stops, not per agent session. We guard these on
* globalThis so only the first register() call adds the lifecycle handlers.
*
* Agent-session events (before_model_resolve, agent_end, message_received) are
* delivered via the api instance that belongs to each individual agent session.
* OpenClaw creates a new VM context (and calls register() again) for each hot-reload
* within a session. We register those handlers unconditionally — event-level dedup
* (WeakSet / runId Set, also stored on globalThis) prevents double-processing.
*
* All VM contexts share the real globalThis because they run in the same Node.js
* process as openclaw-gateway.
*/
const _G = globalThis as Record<string, unknown>;
const _GATEWAY_LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered";
function isGatewayLifecycleRegistered(): boolean {
return !!_G[_GATEWAY_LIFECYCLE_KEY];
}
function markGatewayLifecycleRegistered(): void {
_G[_GATEWAY_LIFECYCLE_KEY] = true;
}
export default {
id: "dirigent",
name: "Dirigent",
register(api: OpenClawPluginApi) {
const baseConfig = normalizePluginConfig(api);
ensurePolicyStateLoaded(api, baseConfig);
// Resolve plugin directory for locating sibling modules (no-reply-api/)
// Note: api.resolvePath(".") returns cwd, not script directory. Use import.meta.url instead.
const config = normalizeConfig(api);
const pluginDir = path.dirname(new URL(import.meta.url).pathname);
api.logger.info(`dirigent: pluginDir resolved from import.meta.url: ${pluginDir}`);
const openclawDir = path.join(os.homedir(), ".openclaw");
// Gateway lifecycle: start/stop no-reply API and moderator bot with the gateway
api.on("gateway_start", () => {
api.logger.info(`dirigent: gateway_start event received`);
const identityRegistry = new IdentityRegistry(config.identityFilePath);
const channelStore = new ChannelStore(config.channelStoreFilePath);
const live = normalizePluginConfig(api);
const moderatorBotToken = config.moderatorBotToken || undefined;
const moderatorBotUserId = moderatorBotToken ? getBotUserIdFromToken(moderatorBotToken) : undefined;
const moderatorServiceUrl = `http://127.0.0.1:${config.sideCarPort}/moderator`;
// Check no-reply-api server file exists
const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs");
api.logger.info(`dirigent: checking no-reply-api server at ${serverPath}, exists=${fs.existsSync(serverPath)}`);
let paddedCellDetected = false;
// Additional debug: list what's in the plugin directory
try {
const entries = fs.readdirSync(pluginDir);
api.logger.info(`dirigent: plugin dir (${pluginDir}) entries: ${JSON.stringify(entries)}`);
} catch (e) {
api.logger.warn(`dirigent: cannot read plugin dir: ${String(e)}`);
function hasPaddedCell(): boolean {
return paddedCellDetected;
}
startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787));
api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`);
if (live.moderatorBotToken) {
api.logger.info("dirigent: starting moderator bot presence...");
startModeratorPresence(live.moderatorBotToken, api.logger);
api.logger.info("dirigent: moderator bot presence started");
} else {
api.logger.info("dirigent: moderator bot not starting - no moderatorBotToken in config");
function tryAutoScanPaddedCell(): void {
const count = scanPaddedCell(identityRegistry, openclawDir, api.logger);
paddedCellDetected = count >= 0;
if (paddedCellDetected) {
api.logger.info(`dirigent: padded-cell detected — ${count} identity entries auto-registered`);
}
});
}
// ── Gateway lifecycle (once per gateway process) ───────────────────────
if (!isGatewayLifecycleRegistered()) {
markGatewayLifecycleRegistered();
const gatewayPort = getGatewayPort(api);
// Start unified services (no-reply API + moderator bot)
startSideCar(
api.logger,
pluginDir,
config.sideCarPort,
moderatorBotToken,
undefined, // pluginApiToken — gateway handles auth for plugin routes
gatewayPort,
config.debugMode,
);
if (!moderatorBotToken) {
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
}
tryAutoScanPaddedCell();
api.on("gateway_stop", () => {
stopNoReplyApi(api.logger);
stopModeratorPresence();
api.logger.info("dirigent: gateway stopping, services shut down");
});
// Register tools
registerDirigentTools({
api,
baseConfig,
pickDefined,
});
// Turn management is handled internally by the plugin (not exposed as tools).
// Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control.
registerMessageReceivedHook({
api,
baseConfig,
shouldDebugLog,
debugCtxSummary,
ensureTurnOrder,
getModeratorUserId,
recordChannelAccount,
extractMentionedUserIds,
buildUserIdToAccountIdMap,
stopSideCar(api.logger);
});
}
// ── Hooks (registered on every api instance — event-level dedup handles duplicates) ──
registerBeforeModelResolveHook({
api,
baseConfig,
sessionDecision,
sessionAllowed,
sessionChannelId,
sessionAccountId,
policyState,
DECISION_TTL_MS,
ensurePolicyStateLoaded,
resolveAccountId,
pruneDecisionMap,
shouldDebugLog,
ensureTurnOrder,
channelStore,
identityRegistry,
moderatorBotToken,
scheduleIdentifier: config.scheduleIdentifier,
debugMode: config.debugMode,
noReplyProvider: config.noReplyProvider,
noReplyModel: config.noReplyModel,
});
registerBeforePromptBuildHook({
const interruptTailMatch = registerAgentEndHook({
api,
baseConfig,
sessionDecision,
sessionInjected,
policyState,
DECISION_TTL_MS,
ensurePolicyStateLoaded,
shouldDebugLog,
buildEndMarkerInstruction,
buildSchedulingIdentifierInstruction,
buildAgentIdentity,
channelStore,
identityRegistry,
moderatorBotToken,
scheduleIdentifier: config.scheduleIdentifier,
debugMode: config.debugMode,
onDiscussionDormant: async (channelId: string) => {
const live = normalizeConfig(api);
if (!live.moderatorBotToken) return;
const rec = channelStore.getRecord(channelId);
if (!rec.discussion || rec.discussion.concluded) return;
const initiatorEntry = identityRegistry.findByAgentId(rec.discussion.initiatorAgentId);
const mention = initiatorEntry ? `<@${initiatorEntry.discordUserId}>` : rec.discussion.initiatorAgentId;
await sendModeratorMessage(
live.moderatorBotToken,
channelId,
`${mention} Discussion is idle. Please summarize the results and call \`discussion-complete\`.`,
api.logger,
).catch(() => undefined);
},
});
// Register slash commands for Discord
registerDirigentCommand({
// Speaker-list init still handled via message_received (needs OpenClaw API for channel member lookup)
registerMessageReceivedHook({
api,
baseConfig,
policyState,
persistPolicies,
ensurePolicyStateLoaded,
channelStore,
identityRegistry,
moderatorBotToken,
scheduleIdentifier: config.scheduleIdentifier,
interruptTailMatch,
debugMode: config.debugMode,
// When moderator service is active it handles wake/interrupt via HTTP callback;
// message_received only needs to run speaker-list initialization.
moderatorHandlesMessages: !!moderatorBotToken,
});
// Handle NO_REPLY detection before message write
registerBeforeMessageWriteHook({
// ── Dirigent API (moderator service → plugin callbacks) ───────────────
registerDirigentApi({
api,
baseConfig,
policyState,
sessionAllowed,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded,
shouldDebugLog,
ensureTurnOrder,
resolveDiscordUserId,
sendModeratorMessage,
channelStore,
moderatorBotUserId,
scheduleIdentifier: config.scheduleIdentifier,
moderatorServiceUrl,
moderatorServiceToken: undefined,
debugMode: config.debugMode,
onNewMessage: async ({ channelId, senderId }) => {
const mode = channelStore.getMode(channelId);
// Modes where agents don't participate
if (mode === "none" || mode === "work" || mode === "report") return;
// Skip messages from the moderator bot itself (schedule triggers, etc.)
if (senderId === moderatorBotUserId) return;
// Concluded discussion: send "closed" reply via moderator service
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded && moderatorBotToken) {
await sendModeratorMessage(
moderatorBotToken,
channelId,
"This discussion is closed and no longer active.",
api.logger,
).catch(() => undefined);
return;
}
}
// Identify sender — is it the current speaker?
const senderEntry = identityRegistry.findByDiscordUserId(senderId);
const currentSpeakerIsThisSender = senderEntry
? isCurrentSpeaker(channelId, senderEntry.agentId)
: false;
if (!currentSpeakerIsThisSender) {
// Non-current-speaker: interrupt any ongoing tail-match poll
interruptTailMatch(channelId);
api.logger.info(`dirigent: moderator-callback interrupt tail-match channel=${channelId} senderId=${senderId}`);
// Wake from dormant if needed
if (isDormant(channelId) && moderatorBotToken) {
const first = wakeFromDormant(channelId);
if (first) {
const msg = `<@${first.discordUserId}>${config.scheduleIdentifier}`;
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, config.debugMode);
api.logger.info(`dirigent: moderator-callback woke dormant channel=${channelId} first=${first.agentId}`);
}
}
}
},
});
// Turn advance: when an agent sends a message, check if it signals end of turn
registerMessageSentHook({
// ── Tools ──────────────────────────────────────────────────────────────
registerDirigentTools({
api,
baseConfig,
policyState,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded,
resolveDiscordUserId,
sendModeratorMessage,
channelStore,
identityRegistry,
moderatorBotToken,
scheduleIdentifier: config.scheduleIdentifier,
onDiscussionCreate: async ({ channelId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => {
const live = normalizeConfig(api);
if (!live.moderatorBotToken) return;
// Post discussion-guide to wake participants
await sendModeratorMessage(live.moderatorBotToken, channelId, discussionGuide, api.logger)
.catch(() => undefined);
// Initialize speaker list
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const speakers = agentIds
.map((aid) => {
const entry = identityRegistry.findByAgentId(aid);
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
})
.filter((s): s is NonNullable<typeof s> => s !== null);
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
await sendScheduleTrigger(
live.moderatorBotToken,
channelId,
`<@${first.discordUserId}>${live.scheduleIdentifier}`,
api.logger,
live.debugMode,
).catch(() => undefined);
}
},
});
// ── Commands ───────────────────────────────────────────────────────────
registerSetChannelModeCommand({ api, channelStore });
registerAddGuildCommand(api);
// ── Control page ───────────────────────────────────────────────────────
registerControlPage({
api,
channelStore,
identityRegistry,
moderatorBotToken,
openclawDir,
hasPaddedCell,
});
api.logger.info("dirigent: plugin registered (v2)");
},
};

View File

@@ -1,257 +0,0 @@
/**
* Minimal Discord Gateway connection to keep the moderator bot "online".
* Uses Node.js built-in WebSocket (Node 22+).
*
* IMPORTANT: Only ONE instance should exist per bot token.
* Uses a singleton guard to prevent multiple connections.
*/
let ws: WebSocket | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let heartbeatAcked = true;
let lastSequence: number | null = null;
let sessionId: string | null = null;
let resumeUrl: string | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let destroyed = false;
let started = false; // singleton guard
type Logger = {
info: (msg: string) => void;
warn: (msg: string) => void;
};
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
const MAX_RECONNECT_DELAY_MS = 60_000;
let reconnectAttempts = 0;
function sendPayload(data: Record<string, unknown>) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
function startHeartbeat(intervalMs: number) {
stopHeartbeat();
heartbeatAcked = true;
// First heartbeat after jitter
const jitter = Math.floor(Math.random() * intervalMs);
const firstTimer = setTimeout(() => {
if (destroyed) return;
if (!heartbeatAcked) {
// Missed ACK — zombie connection, close and reconnect
ws?.close(4000, "missed heartbeat ack");
return;
}
heartbeatAcked = false;
sendPayload({ op: 1, d: lastSequence });
heartbeatInterval = setInterval(() => {
if (destroyed) return;
if (!heartbeatAcked) {
ws?.close(4000, "missed heartbeat ack");
return;
}
heartbeatAcked = false;
sendPayload({ op: 1, d: lastSequence });
}, intervalMs);
}, jitter);
// Store the first timer so we can clear it
heartbeatInterval = firstTimer as unknown as ReturnType<typeof setInterval>;
}
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
clearTimeout(heartbeatInterval as unknown as ReturnType<typeof setTimeout>);
heartbeatInterval = null;
}
}
function cleanup() {
stopHeartbeat();
if (ws) {
// Remove all handlers to avoid ghost callbacks
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
try { ws.close(1000); } catch { /* ignore */ }
ws = null;
}
}
function connect(token: string, logger: Logger, isResume = false) {
if (destroyed) return;
// Clean up any existing connection first
cleanup();
const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL;
try {
ws = new WebSocket(url);
} catch (err) {
logger.warn(`dirigent: moderator ws constructor failed: ${String(err)}`);
scheduleReconnect(token, logger, false);
return;
}
const currentWs = ws; // capture for closure
ws.onopen = () => {
if (currentWs !== ws || destroyed) return; // stale
reconnectAttempts = 0; // reset on successful open
if (isResume && sessionId) {
sendPayload({
op: 6,
d: { token, session_id: sessionId, seq: lastSequence },
});
} else {
sendPayload({
op: 2,
d: {
token,
intents: 0,
properties: {
os: "linux",
browser: "dirigent",
device: "dirigent",
},
presence: {
status: "online",
activities: [{ name: "Moderating", type: 3 }],
},
},
});
}
};
ws.onmessage = (evt: MessageEvent) => {
if (currentWs !== ws || destroyed) return;
try {
const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
const { op, t, s, d } = msg;
if (s != null) lastSequence = s;
switch (op) {
case 10: // Hello
startHeartbeat(d.heartbeat_interval);
break;
case 11: // Heartbeat ACK
heartbeatAcked = true;
break;
case 1: // Heartbeat request
sendPayload({ op: 1, d: lastSequence });
break;
case 0: // Dispatch
if (t === "READY") {
sessionId = d.session_id;
resumeUrl = d.resume_gateway_url;
logger.info("dirigent: moderator bot connected and online");
}
if (t === "RESUMED") {
logger.info("dirigent: moderator bot resumed");
}
break;
case 7: // Reconnect request
logger.info("dirigent: moderator bot reconnect requested by Discord");
cleanup();
scheduleReconnect(token, logger, true);
break;
case 9: // Invalid Session
logger.warn(`dirigent: moderator bot invalid session, resumable=${d}`);
cleanup();
sessionId = d ? sessionId : null;
// Wait longer before re-identifying
setTimeout(() => {
if (!destroyed) connect(token, logger, !!d && !!sessionId);
}, 3000 + Math.random() * 2000);
break;
}
} catch {
// ignore parse errors
}
};
ws.onclose = (evt: CloseEvent) => {
if (currentWs !== ws) return; // stale ws
stopHeartbeat();
if (destroyed) return;
const code = evt.code;
// Non-recoverable codes — stop reconnecting
if (code === 4004) {
logger.warn("dirigent: moderator bot token invalid (4004), stopping");
started = false;
return;
}
if (code === 4010 || code === 4011 || code === 4013 || code === 4014) {
logger.warn(`dirigent: moderator bot fatal close (${code}), re-identifying`);
sessionId = null;
scheduleReconnect(token, logger, false);
return;
}
logger.info(`dirigent: moderator bot disconnected (code=${code}), will reconnect`);
const canResume = !!sessionId && code !== 4012;
scheduleReconnect(token, logger, canResume);
};
ws.onerror = () => {
// onclose will fire after this
};
}
function scheduleReconnect(token: string, logger: Logger, resume: boolean) {
if (destroyed) return;
if (reconnectTimer) clearTimeout(reconnectTimer);
// Exponential backoff with cap
reconnectAttempts++;
const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS);
const jitter = Math.random() * 1000;
const delay = baseDelay + jitter;
logger.info(`dirigent: moderator reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect(token, logger, resume);
}, delay);
}
/**
* Start the moderator bot's Discord Gateway connection.
* Singleton: calling multiple times with the same token is safe (no-op).
*/
export function startModeratorPresence(token: string, logger: Logger): void {
if (started) {
logger.info("dirigent: moderator presence already started, skipping");
return;
}
started = true;
destroyed = false;
reconnectAttempts = 0;
connect(token, logger);
}
/**
* Disconnect the moderator bot.
*/
export function stopModeratorPresence(): void {
destroyed = true;
started = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
cleanup();
}

View File

@@ -8,25 +8,15 @@
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean", "default": true },
"discordOnly": { "type": "boolean", "default": true },
"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/dirigent-channel-policies.json" },
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
"schedulingIdentifier": { "type": "string", "default": "➡️" },
"waitIdentifier": { "type": "string", "default": "👤" },
"noReplyProvider": { "type": "string" },
"noReplyModel": { "type": "string" },
"noReplyPort": { "type": "number", "default": 8787 },
"enableDiscordControlTool": { "type": "boolean", "default": true },
"enableDirigentPolicyTool": { "type": "boolean", "default": true },
"enableDebugLogs": { "type": "boolean", "default": false },
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"moderatorBotToken": { "type": "string" }
"moderatorBotToken": { "type": "string" },
"scheduleIdentifier": { "type": "string", "default": "➡️" },
"identityFilePath": { "type": "string" },
"channelStoreFilePath": { "type": "string" },
"debugMode": { "type": "boolean", "default": false },
"noReplyProvider": { "type": "string", "default": "dirigent" },
"noReplyModel": { "type": "string", "default": "no-reply" },
"sideCarPort": { "type": "number", "default": 8787 }
},
"required": ["noReplyProvider", "noReplyModel"]
"required": []
}
}

View File

@@ -1,50 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelPolicy, DirigentConfig } from "../rules.js";
export type PolicyState = {
filePath: string;
channelPolicies: Record<string, ChannelPolicy>;
};
export const policyState: PolicyState = {
filePath: "",
channelPolicies: {},
};
export function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string {
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json");
}
export function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig): void {
if (policyState.filePath) return;
const filePath = resolvePoliciesPath(api, config);
policyState.filePath = filePath;
try {
if (!fs.existsSync(filePath)) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "{}\n", "utf8");
policyState.channelPolicies = {};
return;
}
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
} catch (err) {
api.logger.warn(`dirigent: failed init policy file ${filePath}: ${String(err)}`);
policyState.channelPolicies = {};
}
}
export function persistPolicies(api: OpenClawPluginApi): void {
if (!policyState.filePath) throw new Error("policy state not initialized");
const dir = path.dirname(policyState.filePath);
fs.mkdirSync(dir, { recursive: true });
const tmp = `${policyState.filePath}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(policyState.channelPolicies, null, 2)}\n`, "utf8");
fs.renameSync(tmp, policyState.filePath);
api.logger.info(`dirigent: policy file updated at ${policyState.filePath}`);
}

View File

@@ -1,139 +0,0 @@
export type DirigentConfig = {
enabled?: boolean;
discordOnly?: boolean;
listMode?: "human-list" | "agent-list";
humanList?: string[];
agentList?: string[];
channelPoliciesFile?: string;
// backward compatibility
bypassUserIds?: string[];
endSymbols?: string[];
/** Scheduling identifier sent by moderator to activate agents (default: ➡️) */
schedulingIdentifier?: string;
/** Wait identifier: agent ends with this when waiting for a human reply (default: 👤) */
waitIdentifier?: string;
noReplyProvider: string;
noReplyModel: string;
noReplyPort?: number;
/** Discord bot token for the moderator bot (used for turn handoff messages) */
moderatorBotToken?: string;
};
export type ChannelPolicy = {
listMode?: "human-list" | "agent-list";
humanList?: string[];
agentList?: string[];
endSymbols?: string[];
};
export type Decision = {
shouldUseNoReply: boolean;
shouldInjectEndMarkerPrompt: boolean;
reason: string;
};
/**
* Strip trailing OpenClaw metadata blocks from the prompt to get the actual message content.
* The prompt format is: [prepend instruction]\n\n[message]\n\nConversation info (untrusted metadata):\n```json\n{...}\n```\n\nSender (untrusted metadata):\n```json\n{...}\n```
*/
function stripTrailingMetadata(input: string): string {
// Remove all trailing "XXX (untrusted metadata):\n```json\n...\n```" blocks
let text = input;
// eslint-disable-next-line no-constant-condition
while (true) {
const m = text.match(/\n*[A-Z][^\n]*\(untrusted metadata\):\s*```json\s*[\s\S]*?```\s*$/);
if (!m) break;
text = text.slice(0, text.length - m[0].length);
}
return text;
}
function getLastChar(input: string): string {
const t = stripTrailingMetadata(input).trim();
if (!t.length) return "";
// Use Array.from to handle multi-byte characters (emoji, surrogate pairs)
const chars = Array.from(t);
return chars[chars.length - 1] || "";
}
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 || [];
const globalEnd = config.endSymbols || ["🔚"];
if (!channelId) {
return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd };
}
const cp = channelPolicies || {};
const scoped = cp[channelId];
if (!scoped) {
return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd };
}
return {
listMode: scoped.listMode || globalMode,
humanList: scoped.humanList || globalHuman,
agentList: scoped.agentList || globalAgent,
endSymbols: scoped.endSymbols || globalEnd,
};
}
export function evaluateDecision(params: {
config: DirigentConfig;
channel?: string;
channelId?: string;
channelPolicies?: Record<string, ChannelPolicy>;
senderId?: string;
content?: string;
}): Decision {
const { config } = params;
if (config.enabled === false) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "disabled" };
}
const channel = (params.channel || "").toLowerCase();
if (config.discordOnly !== false && channel !== "discord") {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" };
}
// DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId),
// this is a DM session where untrusted metadata is not injected. Always allow through.
if (!params.senderId && !params.channelId) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "dm_no_metadata_bypass" };
}
const policy = resolvePolicy(config, params.channelId, params.channelPolicies);
const mode = policy.listMode;
const humanList = policy.humanList;
const agentList = policy.agentList;
const senderId = params.senderId || "";
const inHumanList = !!senderId && humanList.includes(senderId);
const inAgentList = !!senderId && agentList.includes(senderId);
const lastChar = getLastChar(params.content || "");
const hasEnd = !!lastChar && policy.endSymbols.includes(lastChar);
if (mode === "human-list") {
if (inHumanList) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "human_list_sender" };
}
if (hasEnd) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` };
}
return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "rule_match_no_end_symbol" };
}
// agent-list mode: listed senders require end symbol; others bypass requirement.
if (!inAgentList) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "non_agent_list_sender" };
}
if (hasEnd) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` };
}
return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "agent_list_missing_end_symbol" };
}

View File

@@ -1,160 +1,353 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { DirigentConfig } from "../rules.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { createDiscordChannel, getBotUserIdFromToken } from "../core/moderator-discord.js";
import { setSpeakerList } from "../turn-manager.js";
type ToolDeps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
pickDefined: (obj: Record<string, unknown>) => Record<string, unknown>;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
/** Called by create-discussion-channel to initialize the discussion. */
onDiscussionCreate?: (params: {
channelId: string;
guildId: string;
initiatorAgentId: string;
callbackGuildId: string;
callbackChannelId: string;
discussionGuide: string;
participants: string[];
}) => Promise<void>;
};
function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") {
return { accountId, token: accounts[accountId].token as string };
}
for (const [aid, rec] of Object.entries(accounts)) {
if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token };
}
return null;
function getGuildIdFromSessionKey(sessionKey: string): string | undefined {
// sessionKey doesn't encode guild — it's not available directly.
// Guild is passed explicitly by the agent.
return undefined;
}
async function discordRequest(token: string, method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; text: string; json: any }> {
const r = await fetch(`https://discord.com/api/v10${path}`, {
method,
headers: {
Authorization: `Bot ${token}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await r.text();
let json: any = null;
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
return { ok: r.ok, status: r.status, text, json };
function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined {
const m = sessionKey.match(/:discord:channel:(\d+)$/);
return m?.[1];
}
function roleOrMemberType(v: unknown): number {
if (typeof v === "number") return v;
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
return 0;
function textResult(text: string) {
return { content: [{ type: "text" as const, text }], details: undefined };
}
function errorResult(text: string) {
return { content: [{ type: "text" as const, text }], details: { error: true } };
}
export function registerDirigentTools(deps: ToolDeps): void {
const { api, baseConfig, pickDefined } = deps;
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps;
async function executeDiscordAction(action: DiscordControlAction, params: Record<string, unknown>) {
const live = baseConfig as DirigentConfig & {
enableDiscordControlTool?: boolean;
discordControlAccountId?: string;
};
if (live.enableDiscordControlTool === false) {
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
}
const selected = parseAccountToken(api, (params.accountId as string | undefined) || live.discordControlAccountId);
if (!selected) {
return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true };
}
const token = selected.token;
if (action === "channel-private-create") {
const guildId = String(params.guildId || "").trim();
const name = String(params.name || "").trim();
if (!guildId || !name) return { content: [{ type: "text", text: "guildId and name are required" }], isError: true };
const allowedUserIds = Array.isArray(params.allowedUserIds) ? params.allowedUserIds.map(String) : [];
const allowedRoleIds = Array.isArray(params.allowedRoleIds) ? params.allowedRoleIds.map(String) : [];
const allowMask = String(params.allowMask || "1024");
const denyEveryoneMask = String(params.denyEveryoneMask || "1024");
const overwrites: any[] = [
{ id: guildId, type: 0, allow: "0", deny: denyEveryoneMask },
...allowedRoleIds.map((id) => ({ id, type: 0, allow: allowMask, deny: "0" })),
...allowedUserIds.map((id) => ({ id, type: 1, allow: allowMask, deny: "0" })),
];
const body = pickDefined({
name,
type: typeof params.type === "number" ? params.type : 0,
parent_id: params.parentId,
topic: params.topic,
position: params.position,
nsfw: params.nsfw,
permission_overwrites: overwrites,
});
const resp = await discordRequest(token, "POST", `/guilds/${guildId}/channels`, body);
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
}
const channelId = String(params.channelId || "").trim();
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge";
const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : [];
const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : [];
const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : [];
const allowMask = String(params.allowMask || "1024");
const denyMask = String(params.denyMask || "0");
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
if (!ch.ok) return { content: [{ type: "text", text: `discord action failed (${ch.status}): ${ch.text}` }], isError: true };
const current = Array.isArray(ch.json?.permission_overwrites) ? [...ch.json.permission_overwrites] : [];
const guildId = String(ch.json?.guild_id || "");
const everyone = current.find((x: any) => String(x?.id || "") === guildId && roleOrMemberType(x?.type) === 0);
let next: any[] = mode === "replace" ? (everyone ? [everyone] : []) : current.filter((x: any) => !removeTargetIds.includes(String(x?.id || "")));
for (const id of addRoleIds) {
next = next.filter((x: any) => String(x?.id || "") !== id);
next.push({ id, type: 0, allow: allowMask, deny: denyMask });
}
for (const id of addUserIds) {
next = next.filter((x: any) => String(x?.id || "") !== id);
next.push({ id, type: 1, allow: allowMask, deny: denyMask });
}
const resp = await discordRequest(token, "PATCH", `/channels/${channelId}`, { permission_overwrites: next });
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
}
api.registerTool({
name: "dirigent_discord_control",
description: "Create/update Discord private channels using the configured Discord bot token",
inputSchema: {
// ───────────────────────────────────────────────
// dirigent-register
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "dirigent-register",
label: "Dirigent Register",
description: "Register or update this agent's Discord user ID in Dirigent's identity registry.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
action: { type: "string", enum: ["channel-private-create", "channel-private-update"] },
accountId: { type: "string" },
guildId: { type: "string" },
channelId: { type: "string" },
name: { type: "string" },
type: { type: "number" },
parentId: { type: "string" },
topic: { type: "string" },
position: { type: "number" },
nsfw: { type: "boolean" },
allowedUserIds: { type: "array", items: { type: "string" } },
allowedRoleIds: { type: "array", items: { type: "string" } },
allowMask: { type: "string" },
denyEveryoneMask: { type: "string" },
mode: { type: "string", enum: ["merge", "replace"] },
addUserIds: { type: "array", items: { type: "string" } },
addRoleIds: { type: "array", items: { type: "string" } },
removeTargetIds: { type: "array", items: { type: "string" } },
denyMask: { type: "string" },
discordUserId: { type: "string", description: "The agent's Discord user ID" },
agentName: { type: "string", description: "Display name (optional, defaults to agentId)" },
},
required: ["action"],
required: ["discordUserId"],
},
handler: async (params) => executeDiscordAction(params.action as DiscordControlAction, params as Record<string, unknown>),
execute: async (_toolCallId: string, params: unknown) => {
const agentId = ctx?.agentId;
if (!agentId) return errorResult("Cannot resolve agentId from session context");
const p = params as { discordUserId: string; agentName?: string };
identityRegistry.upsert({
agentId,
discordUserId: p.discordUserId,
agentName: p.agentName ?? agentId,
});
return textResult(`Registered: agentId=${agentId} discordUserId=${p.discordUserId}`);
},
}));
// ───────────────────────────────────────────────
// Helper: create channel + set mode
// ───────────────────────────────────────────────
async function createManagedChannel(opts: {
guildId: string;
name: string;
memberDiscordIds: string[];
mode: "chat" | "report" | "work";
}): Promise<{ ok: boolean; channelId?: string; error?: string }> {
if (!moderatorBotToken) return { ok: false, error: "moderatorBotToken not configured" };
const botId = getBotUserIdFromToken(moderatorBotToken);
const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [
{ id: opts.guildId, type: 0, deny: "1024" }, // deny everyone
];
if (botId) overwrites.push({ id: botId, type: 1, allow: "1024" });
for (const uid of opts.memberDiscordIds) {
if (uid) overwrites.push({ id: uid, type: 1, allow: "1024" });
}
let channelId: string;
try {
channelId = await createDiscordChannel({
token: moderatorBotToken,
guildId: opts.guildId,
name: opts.name,
permissionOverwrites: overwrites,
logger: api.logger,
});
} catch (err) {
return { ok: false, error: String(err) };
}
try {
channelStore.setLockedMode(channelId, opts.mode);
} catch {
channelStore.setMode(channelId, opts.mode);
}
return { ok: true, channelId };
}
// ───────────────────────────────────────────────
// create-chat-channel
// ───────────────────────────────────────────────
api.registerTool({
name: "create-chat-channel",
label: "Create Chat Channel",
description: "Create a new private Discord channel in the specified guild with mode=chat.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
guildId: { type: "string", description: "Guild ID to create the channel in" },
name: { type: "string", description: "Channel name" },
participants: {
type: "array", items: { type: "string" },
description: "Discord user IDs to add (moderator bot always added)",
},
},
required: ["guildId", "name"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { guildId: string; name: string; participants?: string[] };
const result = await createManagedChannel({
guildId: p.guildId, name: p.name,
memberDiscordIds: p.participants ?? [],
mode: "chat",
});
if (!result.ok) return errorResult(`Failed: ${result.error}`);
return textResult(`Created chat channel: ${result.channelId}`);
},
});
// ───────────────────────────────────────────────
// create-report-channel
// ───────────────────────────────────────────────
api.registerTool({
name: "create-report-channel",
label: "Create Report Channel",
description: "Create a new private Discord channel with mode=report. Agents can post to it but are not woken by messages.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
guildId: { type: "string", description: "Guild ID" },
name: { type: "string", description: "Channel name" },
members: { type: "array", items: { type: "string" }, description: "Discord user IDs to add" },
},
required: ["guildId", "name"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { guildId: string; name: string; members?: string[] };
const result = await createManagedChannel({
guildId: p.guildId, name: p.name,
memberDiscordIds: p.members ?? [],
mode: "report",
});
if (!result.ok) return errorResult(`Failed: ${result.error}`);
return textResult(`Created report channel: ${result.channelId}`);
},
});
// ───────────────────────────────────────────────
// create-work-channel
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "create-work-channel",
label: "Create Work Channel",
description: "Create a new private Discord workspace channel with mode=work (turn-manager disabled, mode locked).",
parameters: {
type: "object",
additionalProperties: false,
properties: {
guildId: { type: "string", description: "Guild ID" },
name: { type: "string", description: "Channel name" },
members: { type: "array", items: { type: "string" }, description: "Additional Discord user IDs to add" },
},
required: ["guildId", "name"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { guildId: string; name: string; members?: string[] };
// Include calling agent's Discord ID if known
const callerDiscordId = ctx?.agentId ? identityRegistry.findByAgentId(ctx.agentId)?.discordUserId : undefined;
const members = [...(p.members ?? [])];
if (callerDiscordId && !members.includes(callerDiscordId)) members.push(callerDiscordId);
const result = await createManagedChannel({
guildId: p.guildId, name: p.name,
memberDiscordIds: members,
mode: "work",
});
if (!result.ok) return errorResult(`Failed: ${result.error}`);
return textResult(`Created work channel: ${result.channelId}`);
},
}));
// ───────────────────────────────────────────────
// create-discussion-channel
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "create-discussion-channel",
label: "Create Discussion Channel",
description: "Create a structured discussion channel between agents. The calling agent becomes the initiator.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
callbackGuildId: { type: "string", description: "Guild ID of your current channel (for callback after discussion)" },
callbackChannelId: { type: "string", description: "Channel ID to post the summary to after discussion completes" },
name: { type: "string", description: "Discussion channel name" },
discussionGuide: { type: "string", description: "Topic, goals, and completion criteria for the discussion" },
participants: { type: "array", items: { type: "string" }, description: "Discord user IDs of participating agents" },
},
required: ["callbackGuildId", "callbackChannelId", "name", "discussionGuide", "participants"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as {
callbackGuildId: string;
callbackChannelId: string;
name: string;
discussionGuide: string;
participants: string[];
};
const initiatorAgentId = ctx?.agentId;
if (!initiatorAgentId) {
return errorResult("Cannot resolve initiator agentId from session");
}
if (!moderatorBotToken) {
return errorResult("moderatorBotToken not configured");
}
if (!onDiscussionCreate) {
return errorResult("Discussion service not available");
}
const botId = getBotUserIdFromToken(moderatorBotToken);
const initiatorDiscordId = identityRegistry.findByAgentId(initiatorAgentId)?.discordUserId;
const memberIds = [...new Set([
...(initiatorDiscordId ? [initiatorDiscordId] : []),
...p.participants,
...(botId ? [botId] : []),
])];
const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [
{ id: p.callbackGuildId, type: 0, deny: "1024" },
...memberIds.map((id) => ({ id, type: 1, allow: "1024" })),
];
let channelId: string;
try {
channelId = await createDiscordChannel({
token: moderatorBotToken,
guildId: p.callbackGuildId,
name: p.name,
permissionOverwrites: overwrites,
logger: api.logger,
});
} catch (err) {
return errorResult(`Failed to create channel: ${String(err)}`);
}
try {
channelStore.setLockedMode(channelId, "discussion", {
initiatorAgentId,
callbackGuildId: p.callbackGuildId,
callbackChannelId: p.callbackChannelId,
concluded: false,
});
} catch (err) {
return errorResult(`Failed to register channel: ${String(err)}`);
}
await onDiscussionCreate({
channelId,
guildId: p.callbackGuildId,
initiatorAgentId,
callbackGuildId: p.callbackGuildId,
callbackChannelId: p.callbackChannelId,
discussionGuide: p.discussionGuide,
participants: p.participants,
});
return textResult(`Discussion channel created: ${channelId}`);
},
}));
// ───────────────────────────────────────────────
// discussion-complete
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "discussion-complete",
label: "Discussion Complete",
description: "Mark a discussion as complete, archive the channel, and post the summary path to the callback channel.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
discussionChannelId: { type: "string", description: "The discussion channel ID" },
summary: { type: "string", description: "File path to the summary (must be under {workspace}/discussion-summary/)" },
},
required: ["discussionChannelId", "summary"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { discussionChannelId: string; summary: string };
const callerAgentId = ctx?.agentId;
if (!callerAgentId) {
return errorResult("Cannot resolve agentId from session");
}
const rec = channelStore.getRecord(p.discussionChannelId);
if (rec.mode !== "discussion") {
return errorResult(`Channel ${p.discussionChannelId} is not a discussion channel`);
}
if (!rec.discussion) {
return errorResult("Discussion metadata not found");
}
if (rec.discussion.initiatorAgentId !== callerAgentId) {
return errorResult(`Only the initiator (${rec.discussion.initiatorAgentId}) may call discussion-complete`);
}
if (!p.summary.includes("discussion-summary")) {
return errorResult("Summary path must be under {workspace}/discussion-summary/");
}
channelStore.concludeDiscussion(p.discussionChannelId);
if (moderatorBotToken) {
const { sendModeratorMessage } = await import("../core/moderator-discord.js");
await sendModeratorMessage(
moderatorBotToken, rec.discussion.callbackChannelId,
`Discussion complete. Summary: ${p.summary}`,
api.logger,
).catch(() => undefined);
}
return textResult(`Discussion ${p.discussionChannelId} concluded. Summary posted to ${rec.discussion.callbackChannelId}.`);
},
}));
}

View File

@@ -1,412 +1,284 @@
/**
* Turn-based speaking manager for group channels.
* Turn Manager (v2)
*
* Rules:
* - Humans (humanList) are never in the turn order
* - Turn order is auto-populated from channel/server members minus humans
* - currentSpeaker can be null (dormant state)
* - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null)
* - Dormant → any new message reactivates:
* - If sender is NOT in turn order → current = first in list
* - If sender IS in turn order → current = next after sender
* Per-channel state machine governing who speaks when.
* Called from before_model_resolve (check turn) and agent_end (advance turn).
*/
export type ChannelTurnState = {
/** Ordered accountIds for this channel (auto-populated, shuffled) */
turnOrder: string[];
/** Current speaker accountId, or null if dormant */
currentSpeaker: string | null;
/** Set of accountIds that have NO_REPLY'd this cycle */
noRepliedThisCycle: Set<string>;
/** Timestamp of last state change */
lastChangedAt: number;
// ── Mention override state ──
/** Original turn order saved when override is active */
savedTurnOrder?: string[];
/** First agent in override cycle; used to detect cycle completion */
overrideFirstAgent?: string;
// ── Wait-for-human state ──
/** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */
waitingForHuman: boolean;
export type SpeakerEntry = {
agentId: string;
discordUserId: string;
};
const channelTurns = new Map<string, ChannelTurnState>();
/** Turn timeout: if the current speaker hasn't responded, auto-advance */
const TURN_TIMEOUT_MS = 60_000;
// --- helpers ---
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// --- public API ---
type ChannelTurnState = {
speakerList: SpeakerEntry[];
currentIndex: number;
/** Tracks which agents sent empty turns in the current cycle. */
emptyThisCycle: Set<string>;
/** Tracks which agents completed a turn at all this cycle. */
completedThisCycle: Set<string>;
dormant: boolean;
/** Discord message ID recorded at before_model_resolve, used as poll anchor. */
anchorMessageId: Map<string, string>; // agentId → messageId
};
/**
* Initialize or update the turn order for a channel.
* Called with the list of bot accountIds (already filtered, humans excluded).
* All mutable state is stored on globalThis so it persists across VM-context
* hot-reloads within the same gateway process. OpenClaw re-imports this module
* in a fresh isolated VM context on each reload, but all contexts share the real
* globalThis object because they run in the same Node.js process.
*/
export function initTurnOrder(channelId: string, botAccountIds: string[]): void {
const existing = channelTurns.get(channelId);
if (existing) {
// Compare membership against base order.
// If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison.
const baseOrder = existing.savedTurnOrder || existing.turnOrder;
const oldSet = new Set(baseOrder);
const newSet = new Set(botAccountIds);
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
if (same) return; // no change
const _G = globalThis as Record<string, unknown>;
console.log(
`[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` +
`oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` +
`oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`,
);
const nextOrder = shuffleArray(botAccountIds);
// Mention override active: update only the saved base order.
// Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered.
if (existing.savedTurnOrder) {
existing.savedTurnOrder = nextOrder;
existing.lastChangedAt = Date.now();
console.log(
`[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` +
`savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` +
`keptCurrent=${existing.currentSpeaker}`,
);
return;
function channelStates(): Map<string, ChannelTurnState> {
if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map<string, ChannelTurnState>();
return _G._tmChannelStates as Map<string, ChannelTurnState>;
}
// Non-mention flow: preserve previous behavior (re-init to dormant).
channelTurns.set(channelId, {
turnOrder: nextOrder,
currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
waitingForHuman: false,
});
console.log(
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
);
return;
function pendingTurns(): Set<string> {
if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set<string>();
return _G._tmPendingTurns as Set<string>;
}
console.log(
`[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`,
);
const nextOrder = shuffleArray(botAccountIds);
channelTurns.set(channelId, {
turnOrder: nextOrder,
currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
waitingForHuman: false,
});
console.log(
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
);
function blockedPendingCounts(): Map<string, number> {
if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map<string, number>();
return _G._tmBlockedPendingCounts as Map<string, number>;
}
/**
* Check if the given accountId is allowed to speak.
* Shared initialization lock: prevents multiple concurrent VM contexts from
* simultaneously initializing the same channel's speaker list.
* Used by both before_model_resolve and message_received hooks.
*/
export function checkTurn(channelId: string, accountId: string): {
allowed: boolean;
currentSpeaker: string | null;
reason: string;
} {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) {
return { allowed: true, currentSpeaker: null, reason: "no_turn_state" };
}
// Waiting for human → block all agents
if (state.waitingForHuman) {
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
}
// Not in turn order (human or unknown) → always allowed
if (!state.turnOrder.includes(accountId)) {
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
}
// Dormant → not allowed (will be activated by onNewMessage)
if (state.currentSpeaker === null) {
return { allowed: false, currentSpeaker: null, reason: "dormant" };
}
// Check timeout → auto-advance
if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) {
advanceTurn(channelId);
// Re-check after advance
const updated = channelTurns.get(channelId)!;
if (updated.currentSpeaker === accountId) {
return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" };
}
return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" };
}
if (accountId === state.currentSpeaker) {
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" };
}
return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" };
export function getInitializingChannels(): Set<string> {
if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
return _G._tmInitializingChannels as Set<string>;
}
/**
* Called when a new message arrives in the channel.
* Handles reactivation from dormant state and human-triggered resets.
*
* NOTE: For human messages with @mentions, call setMentionOverride() instead.
*
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
* @param isHuman - whether the sender is in the humanList
* Maximum blocked-pending entries tracked per agent per channel.
* Caps the drain time: in a busy channel many messages can arrive during a non-speaker
* turn, each incrementing the counter. Without a cap the counter grows unboundedly.
* Also applied retroactively in markTurnStarted to recover from accumulated debt.
*/
export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return;
const MAX_BLOCKED_PENDING = 3;
if (isHuman) {
// Human message: clear wait-for-human, restore original order if overridden, activate from first
state.waitingForHuman = false;
restoreOriginalOrder(state);
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return;
export function markTurnStarted(channelId: string, agentId: string): void {
pendingTurns().add(`${channelId}:${agentId}`);
// Cap existing blocked-pending at MAX to recover from accumulated debt
// (can occur when many messages arrive during a long non-speaker period).
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
const current = bpc.get(key) ?? 0;
if (current > MAX_BLOCKED_PENDING) bpc.set(key, MAX_BLOCKED_PENDING);
}
if (state.waitingForHuman) {
// Waiting for human — ignore non-human messages
return;
export function isTurnPending(channelId: string, agentId: string): boolean {
return pendingTurns().has(`${channelId}:${agentId}`);
}
if (state.currentSpeaker !== null) {
// Already active, no change needed from incoming message
return;
}
// Dormant state + non-human message → reactivate
if (senderAccountId && state.turnOrder.includes(senderAccountId)) {
// Sender is in turn order → next after sender
const idx = state.turnOrder.indexOf(senderAccountId);
const nextIdx = (idx + 1) % state.turnOrder.length;
state.currentSpeaker = state.turnOrder[nextIdx];
} else {
// Sender not in turn order → start from first
state.currentSpeaker = state.turnOrder[0];
}
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
export function clearTurnPending(channelId: string, agentId: string): void {
pendingTurns().delete(`${channelId}:${agentId}`);
}
/**
* Restore original turn order if an override is active.
* Counts NO_REPLY completions currently in-flight for an agent that was
* blocked (non-speaker or init-suppressed). These completions take ~10s to
* arrive (history-building overhead) and may arrive after markTurnStarted,
* causing false empty-turn detection. We count them and skip one per agent_end
* until the count reaches zero, at which point the next agent_end is real.
*/
function restoreOriginalOrder(state: ChannelTurnState): void {
if (state.savedTurnOrder) {
state.turnOrder = state.savedTurnOrder;
state.savedTurnOrder = undefined;
state.overrideFirstAgent = undefined;
}
export function incrementBlockedPending(channelId: string, agentId: string): void {
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
const current = bpc.get(key) ?? 0;
if (current < MAX_BLOCKED_PENDING) bpc.set(key, current + 1);
}
/**
* Set a temporary mention override for the turn order.
* When a human @mentions specific agents, only those agents speak (in their
* relative order from the current turn order). After the cycle returns to the
* first agent, the original order is restored.
*
* @param channelId - Discord channel ID
* @param mentionedAccountIds - accountIds of @mentioned agents, ordered by
* their position in the current turn order
* @returns true if override was set, false if no valid agents
*/
export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean {
const state = channelTurns.get(channelId);
if (!state || mentionedAccountIds.length === 0) return false;
console.log(
`[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` +
`mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` +
`order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`,
);
// Restore any existing override first
restoreOriginalOrder(state);
// Filter to agents actually in the turn order
const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id));
if (validIds.length === 0) {
console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`);
return false;
}
// Order by their position in the current turn order
validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b));
// Save original and apply override
state.savedTurnOrder = [...state.turnOrder];
state.turnOrder = validIds;
state.overrideFirstAgent = validIds[0];
state.currentSpeaker = validIds[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
console.log(
`[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` +
`overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` +
`savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`,
);
/** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */
export function consumeBlockedPending(channelId: string, agentId: string): boolean {
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
const count = bpc.get(key) ?? 0;
if (count <= 0) return false;
bpc.set(key, count - 1);
return true;
}
/**
* Check if a mention override is currently active.
*/
export function hasMentionOverride(channelId: string): boolean {
const state = channelTurns.get(channelId);
return !!state?.savedTurnOrder;
export function resetBlockedPending(channelId: string, agentId: string): void {
blockedPendingCounts().delete(`${channelId}:${agentId}`);
}
function getState(channelId: string): ChannelTurnState | undefined {
return channelStates().get(channelId);
}
function ensureState(channelId: string): ChannelTurnState {
const cs = channelStates();
let s = cs.get(channelId);
if (!s) {
s = {
speakerList: [],
currentIndex: 0,
emptyThisCycle: new Set(),
completedThisCycle: new Set(),
dormant: false,
anchorMessageId: new Map(),
};
cs.set(channelId, s);
}
return s;
}
/** Replace the speaker list (called at cycle boundaries and on init). */
export function setSpeakerList(channelId: string, speakers: SpeakerEntry[]): void {
const s = ensureState(channelId);
s.speakerList = speakers;
s.currentIndex = 0;
}
/** Get the currently active speaker, or null if dormant / list empty. */
export function getCurrentSpeaker(channelId: string): SpeakerEntry | null {
const s = getState(channelId);
if (!s || s.dormant || s.speakerList.length === 0) return null;
return s.speakerList[s.currentIndex] ?? null;
}
/** Check if a given agentId is the current speaker. */
export function isCurrentSpeaker(channelId: string, agentId: string): boolean {
const speaker = getCurrentSpeaker(channelId);
return speaker?.agentId === agentId;
}
/** Record the Discord anchor message ID for an agent's upcoming turn. */
export function setAnchor(channelId: string, agentId: string, messageId: string): void {
const s = ensureState(channelId);
s.anchorMessageId.set(agentId, messageId);
}
export function getAnchor(channelId: string, agentId: string): string | undefined {
return getState(channelId)?.anchorMessageId.get(agentId);
}
/**
* Set the channel to "waiting for human" state.
* All agents will be routed to no-reply until a human sends a message.
* Advance the speaker after a turn completes.
* Returns the new current speaker (or null if dormant).
*
* @param isEmpty - whether the completed turn was an empty turn
* @param rebuildFn - async function that fetches current Discord members and
* returns a new SpeakerEntry[]. Called at cycle boundaries.
* @param previousLastAgentId - for shuffle mode: the last speaker of the
* previous cycle (cannot become the new first speaker).
*/
export function setWaitingForHuman(channelId: string): void {
const state = channelTurns.get(channelId);
if (!state) return;
state.waitingForHuman = true;
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
export async function advanceSpeaker(
channelId: string,
agentId: string,
isEmpty: boolean,
rebuildFn: () => Promise<SpeakerEntry[]>,
previousLastAgentId?: string,
): Promise<{ next: SpeakerEntry | null; enteredDormant: boolean }> {
const s = ensureState(channelId);
// Record this turn
s.completedThisCycle.add(agentId);
if (isEmpty) s.emptyThisCycle.add(agentId);
const wasLastInCycle = s.currentIndex >= s.speakerList.length - 1;
if (!wasLastInCycle) {
// Middle of cycle — just advance pointer
s.currentIndex++;
s.dormant = false;
return { next: s.speakerList[s.currentIndex] ?? null, enteredDormant: false };
}
// === Cycle boundary ===
const newSpeakers = await rebuildFn();
const previousAgentIds = new Set(s.speakerList.map((sp) => sp.agentId));
const hasNewAgents = newSpeakers.some((sp) => !previousAgentIds.has(sp.agentId));
const allEmpty =
s.completedThisCycle.size > 0 &&
[...s.completedThisCycle].every((id) => s.emptyThisCycle.has(id));
// Reset cycle tracking
s.emptyThisCycle = new Set();
s.completedThisCycle = new Set();
if (allEmpty && !hasNewAgents) {
// Enter dormant
s.speakerList = newSpeakers;
s.currentIndex = 0;
s.dormant = true;
return { next: null, enteredDormant: true };
}
// Continue with updated list (apply shuffle if caller provides previousLastAgentId)
s.speakerList = previousLastAgentId != null
? shuffleList(newSpeakers, previousLastAgentId)
: newSpeakers;
s.currentIndex = 0;
s.dormant = false;
return { next: s.speakerList[0] ?? null, enteredDormant: false };
}
/**
* Check if the channel is waiting for a human reply.
* Wake the channel from dormant.
* Returns the new first speaker.
*/
export function isWaitingForHuman(channelId: string): boolean {
const state = channelTurns.get(channelId);
return !!state?.waitingForHuman;
export function wakeFromDormant(channelId: string): SpeakerEntry | null {
const s = getState(channelId);
if (!s) return null;
s.dormant = false;
s.currentIndex = 0;
s.emptyThisCycle = new Set();
s.completedThisCycle = new Set();
return s.speakerList[0] ?? null;
}
export function isDormant(channelId: string): boolean {
return getState(channelId)?.dormant ?? false;
}
export function hasSpeakers(channelId: string): boolean {
const s = getState(channelId);
return (s?.speakerList.length ?? 0) > 0;
}
/**
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
* @returns the new currentSpeaker (or null if dormant)
* Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
*/
export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null {
const state = channelTurns.get(channelId);
if (!state) return null;
if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore
if (wasNoReply) {
state.noRepliedThisCycle.add(accountId);
// Check if ALL agents have NO_REPLY'd this cycle
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
if (allNoReplied) {
// If override active, restore original order before going dormant
restoreOriginalOrder(state);
// Go dormant
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return null;
export function shuffleList(list: SpeakerEntry[], previousLastAgentId?: string): SpeakerEntry[] {
if (list.length <= 1) return list;
const arr = [...list];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
} else {
// Successful speech resets the cycle counter
state.noRepliedThisCycle = new Set();
if (previousLastAgentId && arr[0].agentId === previousLastAgentId && arr.length > 1) {
const swapIdx = 1 + Math.floor(Math.random() * (arr.length - 1));
[arr[0], arr[swapIdx]] = [arr[swapIdx], arr[0]];
}
return arr;
}
const next = advanceTurn(channelId);
// Check if override cycle completed (returned to first agent)
if (state.overrideFirstAgent && next === state.overrideFirstAgent) {
restoreOriginalOrder(state);
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return null; // go dormant after override cycle completes
}
return next;
}
/**
* Advance to next speaker in order.
*/
export function advanceTurn(channelId: string): string | null {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return null;
if (state.currentSpeaker === null) return null;
const idx = state.turnOrder.indexOf(state.currentSpeaker);
const nextIdx = (idx + 1) % state.turnOrder.length;
// Skip agents that already NO_REPLY'd this cycle
let attempts = 0;
let candidateIdx = nextIdx;
while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) {
candidateIdx = (candidateIdx + 1) % state.turnOrder.length;
attempts++;
}
if (attempts >= state.turnOrder.length) {
// All have NO_REPLY'd
state.currentSpeaker = null;
state.lastChangedAt = Date.now();
return null;
}
state.currentSpeaker = state.turnOrder[candidateIdx];
state.lastChangedAt = Date.now();
return state.currentSpeaker;
}
/**
* Force reset: go dormant.
*/
export function resetTurn(channelId: string): void {
const state = channelTurns.get(channelId);
if (state) {
restoreOriginalOrder(state);
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.waitingForHuman = false;
state.lastChangedAt = Date.now();
}
}
/**
* Get debug info.
*/
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
const state = channelTurns.get(channelId);
if (!state) return { channelId, hasTurnState: false };
export function getDebugInfo(channelId: string) {
const s = getState(channelId);
if (!s) return { exists: false };
return {
channelId,
hasTurnState: true,
turnOrder: state.turnOrder,
currentSpeaker: state.currentSpeaker,
noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null,
waitingForHuman: state.waitingForHuman,
hasOverride: !!state.savedTurnOrder,
overrideFirstAgent: state.overrideFirstAgent || null,
savedTurnOrder: state.savedTurnOrder || null,
exists: true,
speakerList: s.speakerList.map((sp) => sp.agentId),
currentIndex: s.currentIndex,
currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null,
dormant: s.dormant,
emptyThisCycle: [...s.emptyThisCycle],
completedThisCycle: [...s.completedThisCycle],
};
}
/** Remove a channel's turn state entirely (e.g. when archived). */
export function clearChannel(channelId: string): void {
channelStates().delete(channelId);
}

294
plugin/web/control-page.ts Normal file
View File

@@ -0,0 +1,294 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore, ChannelMode } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { fetchAdminGuilds, fetchGuildChannels } from "../core/moderator-discord.js";
import { scanPaddedCell } from "../core/padded-cell.js";
import path from "node:path";
import os from "node:os";
const SWITCHABLE_MODES: ChannelMode[] = ["none", "chat", "report"];
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
function html(strings: TemplateStringsArray, ...values: unknown[]): string {
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
}
function escapeHtml(s: unknown): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function modeBadge(mode: ChannelMode): string {
const colors: Record<ChannelMode, string> = {
none: "#888", chat: "#5865f2", report: "#57f287",
work: "#fee75c", discussion: "#eb459e",
};
return `<span style="background:${colors[mode]};color:#fff;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">${escapeHtml(mode)}</span>`;
}
function buildPage(content: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dirigent</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:24px}
h1{font-size:1.6rem;margin-bottom:4px;color:#fff}
.subtitle{color:#888;font-size:0.85rem;margin-bottom:24px}
h2{font-size:1.1rem;margin:24px 0 12px;color:#ccc;border-bottom:1px solid #333;padding-bottom:8px}
table{width:100%;border-collapse:collapse;margin-bottom:16px;font-size:0.9rem}
th{text-align:left;padding:8px 12px;background:#252540;color:#aaa;font-weight:600;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em}
td{padding:8px 12px;border-top:1px solid #2a2a4a}
tr:hover td{background:#1e1e3a}
input,select{background:#252540;border:1px solid #444;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:0.9rem}
input:focus,select:focus{outline:none;border-color:#5865f2}
button{background:#5865f2;color:#fff;border:none;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}
button:hover{background:#4752c4}
button.danger{background:#ed4245}
button.danger:hover{background:#c03537}
button.secondary{background:#36393f}
button.secondary:hover{background:#2f3136}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.guild-section{background:#16213e;border:1px solid #2a2a4a;border-radius:8px;margin-bottom:16px;overflow:hidden}
.guild-header{padding:12px 16px;background:#1f2d50;display:flex;align-items:center;gap:10px;font-weight:600}
.guild-name{font-size:1rem;color:#fff}
.guild-id{font-size:0.75rem;color:#888;font-family:monospace}
.msg{padding:8px 12px;border-radius:4px;margin:8px 0;font-size:0.85rem}
.msg.ok{background:#1a4a2a;border:1px solid #2d7a3a;color:#57f287}
.msg.err{background:#4a1a1a;border:1px solid #7a2d2d;color:#ed4245}
.spinner{display:none}
</style>
</head>
<body>
<h1>Dirigent</h1>
<p class="subtitle">OpenClaw multi-agent turn management</p>
${content}
<script>
async function apiCall(endpoint, method, body) {
const resp = await fetch(endpoint, {
method: method || 'GET',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
return resp.json();
}
function showMsg(el, text, isErr) {
el.className = 'msg ' + (isErr ? 'err' : 'ok');
el.textContent = text;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
</script>
</body>
</html>`;
}
export function registerControlPage(deps: {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
openclawDir: string;
hasPaddedCell: () => boolean;
}): void {
const { api, channelStore, identityRegistry, moderatorBotToken, openclawDir, hasPaddedCell } = deps;
// ── Main page ──────────────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent",
auth: "plugin",
match: "exact",
handler: async (_req, res) => {
const entries = identityRegistry.list();
const paddedCellBtn = hasPaddedCell()
? `<button class="secondary" onclick="rescanPaddedCell()">Re-scan padded-cell</button>`
: "";
// Build identity table rows
const identityRows = entries.map((e) => html`
<tr data-agent-id="${escapeHtml(e.agentId)}">
<td><code>${escapeHtml(e.discordUserId)}</code></td>
<td>${escapeHtml(e.agentId)}</td>
<td>${escapeHtml(e.agentName)}</td>
<td><button class="danger" onclick="removeIdentity('${escapeHtml(e.agentId)}')">Remove</button></td>
</tr>`).join("");
// Build guild sections
let guildHtml = "<p style='color:#888'>Loading guilds…</p>";
if (moderatorBotToken) {
try {
const guilds = await fetchAdminGuilds(moderatorBotToken);
if (guilds.length === 0) {
guildHtml = "<p style='color:#888'>No guilds with admin permissions found.</p>";
} else {
guildHtml = "";
for (const guild of guilds) {
const channels = await fetchGuildChannels(moderatorBotToken, guild.id);
const channelRows = channels.map((ch) => {
const mode = channelStore.getMode(ch.id);
const locked = LOCKED_MODES.has(mode);
const dropdown = locked
? modeBadge(mode)
: `<select onchange="setMode('${escapeHtml(ch.id)}', this.value)">
${SWITCHABLE_MODES.map((m) => `<option value="${m}"${m === mode ? " selected" : ""}>${m}</option>`).join("")}
</select>`;
return html`<tr>
<td><code style="font-size:0.8rem">${escapeHtml(ch.id)}</code></td>
<td>#${escapeHtml(ch.name)}</td>
<td>${dropdown}</td>
</tr>`;
}).join("");
guildHtml += html`
<div class="guild-section">
<div class="guild-header">
<span class="guild-name">${escapeHtml(guild.name)}</span>
<span class="guild-id">${escapeHtml(guild.id)}</span>
</div>
<table>
<thead><tr><th>Channel ID</th><th>Name</th><th>Mode</th></tr></thead>
<tbody>${channelRows}</tbody>
</table>
</div>`;
}
}
} catch (err) {
guildHtml = `<p style="color:#ed4245">Failed to load guilds: ${escapeHtml(String(err))}</p>`;
}
} else {
guildHtml = "<p style='color:#888'>moderatorBotToken not configured — cannot list guilds.</p>";
}
const content = html`
<h2>Identity Registry</h2>
<div id="identity-msg" class="msg" style="display:none"></div>
<table>
<thead><tr><th>Discord User ID</th><th>Agent ID</th><th>Agent Name</th><th></th></tr></thead>
<tbody id="identity-tbody">${identityRows}</tbody>
</table>
<div class="row">
<input id="new-discord-id" placeholder="Discord user ID" style="width:200px">
<input id="new-agent-id" placeholder="Agent ID" style="width:160px">
<input id="new-agent-name" placeholder="Agent name (optional)" style="width:180px">
<button onclick="addIdentity()">Add</button>
${paddedCellBtn}
</div>
<h2>Guild &amp; Channel Configuration</h2>
<div id="channel-msg" class="msg" style="display:none"></div>
${guildHtml}
<script>
async function addIdentity() {
const discordUserId = document.getElementById('new-discord-id').value.trim();
const agentId = document.getElementById('new-agent-id').value.trim();
const agentName = document.getElementById('new-agent-name').value.trim();
if (!discordUserId || !agentId) return alert('Discord user ID and Agent ID are required');
const r = await apiCall('/dirigent/api/identity', 'POST', { discordUserId, agentId, agentName: agentName || agentId });
showMsg(document.getElementById('identity-msg'), r.ok ? 'Added.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function removeIdentity(agentId) {
if (!confirm('Remove identity for ' + agentId + '?')) return;
const r = await apiCall('/dirigent/api/identity/' + encodeURIComponent(agentId), 'DELETE');
showMsg(document.getElementById('identity-msg'), r.ok ? 'Removed.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function setMode(channelId, mode) {
const r = await apiCall('/dirigent/api/channel-mode', 'POST', { channelId, mode });
showMsg(document.getElementById('channel-msg'), r.ok ? 'Mode updated.' : r.error, !r.ok);
}
async function rescanPaddedCell() {
const r = await apiCall('/dirigent/api/rescan-padded-cell', 'POST');
showMsg(document.getElementById('identity-msg'), r.ok ? ('Scanned: ' + r.count + ' entries.') : r.error, !r.ok);
if (r.ok) location.reload();
}
</script>`;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildPage(content));
},
});
// ── API: add identity ──────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/identity",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
try {
const { discordUserId, agentId, agentName } = JSON.parse(body);
if (!discordUserId || !agentId) throw new Error("discordUserId and agentId required");
identityRegistry.upsert({ discordUserId, agentId, agentName: agentName ?? agentId });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err) }));
}
});
},
});
// ── API: remove identity ───────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/identity/",
auth: "plugin",
match: "prefix",
handler: (req, res) => {
if (req.method !== "DELETE") { res.writeHead(405); res.end(); return; }
const agentId = decodeURIComponent((req.url ?? "").replace("/dirigent/api/identity/", ""));
const removed = identityRegistry.remove(agentId);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: removed, error: removed ? undefined : "Not found" }));
},
});
// ── API: set channel mode ──────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/channel-mode",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
try {
const { channelId, mode } = JSON.parse(body) as { channelId: string; mode: ChannelMode };
if (!channelId || !mode) throw new Error("channelId and mode required");
if (LOCKED_MODES.has(mode)) throw new Error(`Mode "${mode}" is locked to creation tools`);
channelStore.setMode(channelId, mode);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err) }));
}
});
},
});
// ── API: rescan padded-cell ────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/rescan-padded-cell",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
const count = scanPaddedCell(identityRegistry, openclawDir, api.logger);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: count >= 0, count, error: count < 0 ? "padded-cell not detected" : undefined }));
},
});
}

112
plugin/web/dirigent-api.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
type Deps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
moderatorBotUserId: string | undefined;
scheduleIdentifier: string;
moderatorServiceUrl: string | undefined;
moderatorServiceToken: string | undefined;
debugMode: boolean;
onNewMessage: (event: {
channelId: string;
messageId: string;
senderId: string;
guildId?: string;
}) => Promise<void>;
};
function sendJson(res: import("node:http").ServerResponse, status: number, payload: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function readBody(req: import("node:http").IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
if (body.length > 1_000_000) {
req.destroy();
reject(new Error("body too large"));
}
});
req.on("end", () => {
try {
resolve(body ? (JSON.parse(body) as Record<string, unknown>) : {});
} catch {
reject(new Error("invalid_json"));
}
});
req.on("error", reject);
});
}
/**
* Register Dirigent plugin HTTP routes that the moderator service calls back into.
*
* Routes:
* POST /dirigent/api/moderator/message — inbound message notification from moderator service
* GET /dirigent/api/moderator/status — health/status check
*/
export function registerDirigentApi(deps: Deps): void {
const { api, moderatorServiceUrl, onNewMessage } = deps;
// ── POST /dirigent/api/moderator/message ─────────────────────────────────────
// Called by the moderator service on every Discord MESSAGE_CREATE event.
api.registerHttpRoute({
path: "/dirigent/api/moderator/message",
auth: "plugin",
match: "exact",
handler: async (req, res) => {
if (req.method !== "POST") {
res.writeHead(405);
res.end();
return;
}
let body: Record<string, unknown>;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const channelId = typeof body.channelId === "string" ? body.channelId : undefined;
const messageId = typeof body.messageId === "string" ? body.messageId : undefined;
const senderId = typeof body.senderId === "string" ? body.senderId : undefined;
const guildId = typeof body.guildId === "string" ? body.guildId : undefined;
if (!channelId || !senderId) {
return sendJson(res, 400, { ok: false, error: "channelId and senderId required" });
}
try {
await onNewMessage({
channelId,
messageId: messageId ?? "",
senderId,
guildId,
});
return sendJson(res, 200, { ok: true });
} catch (err) {
api.logger.warn(`dirigent: moderator/message handler error: ${String(err)}`);
return sendJson(res, 500, { ok: false, error: String(err) });
}
},
});
// ── GET /dirigent/api/moderator/status ───────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/moderator/status",
auth: "plugin",
match: "exact",
handler: (_req, res) => {
return sendJson(res, 200, {
ok: true,
moderatorServiceUrl: moderatorServiceUrl ?? null,
});
},
});
}

View File

@@ -1,20 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(process.cwd(), '..');
const pluginDir = path.join(root, 'plugin');
const required = ['index.ts', 'rules.ts', 'openclaw.plugin.json', 'README.md', 'package.json'];
const root = path.resolve(import.meta.dirname, '..');
const checks = [
// Core plugin files
path.join(root, 'plugin', 'index.ts'),
path.join(root, 'plugin', 'turn-manager.ts'),
path.join(root, 'plugin', 'openclaw.plugin.json'),
path.join(root, 'plugin', 'package.json'),
// Sidecar
path.join(root, 'services', 'main.mjs'),
path.join(root, 'services', 'no-reply-api', 'server.mjs'),
path.join(root, 'services', 'moderator', 'index.mjs'),
];
let ok = true;
for (const f of required) {
const p = path.join(pluginDir, f);
for (const p of checks) {
if (!fs.existsSync(p)) {
ok = false;
console.error(`missing: ${p}`);
}
}
const manifestPath = path.join(pluginDir, 'openclaw.plugin.json');
const manifestPath = path.join(root, 'plugin', 'openclaw.plugin.json');
if (fs.existsSync(manifestPath)) {
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
for (const k of ['id', 'entry', 'configSchema']) {

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
docker compose down

View File

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

View File

@@ -2,7 +2,75 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync, spawnSync } from "node:child_process";
import { execFileSync } from "node:child_process";
// === Skill merge utilities ===
function extractGuildTable(skillMdContent) {
const tableMatch = skillMdContent.match(/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/);
if (!tableMatch) return null;
const lines = tableMatch[0].split("\n");
const dataRows = [];
for (const line of lines) {
if (line.includes("guild-id") && line.includes("description")) continue;
if (/^\|[-\s|]+\|$/.test(line)) continue;
const match = line.match(/^\| \s*(\d+) \s*\| \s*(.+?) \s*\|$/);
if (match) dataRows.push({ guildId: match[1].trim(), description: match[2].trim() });
}
return dataRows;
}
function buildGuildTable(rows) {
if (rows.length === 0) return "| guild-id | description |\n|----------|-------------|";
const header = "| guild-id | description |\n|----------|-------------|";
const dataLines = rows.map(r => `| ${r.guildId} | ${r.description} |`).join("\n");
return `${header}\n${dataLines}`;
}
function mergeGuildTables(existingRows, newRows) {
const seen = new Set();
const merged = [];
for (const row of existingRows) {
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
}
for (const row of newRows) {
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
}
return merged;
}
function installSkillWithMerge(skillName, pluginSkillDir, openClawSkillsDir) {
const targetSkillDir = path.join(openClawSkillsDir, skillName);
const sourceSkillMd = path.join(pluginSkillDir, "SKILL.md");
if (fs.existsSync(targetSkillDir)) {
const existingSkillMd = path.join(targetSkillDir, "SKILL.md");
if (fs.existsSync(existingSkillMd) && fs.existsSync(sourceSkillMd)) {
const existingContent = fs.readFileSync(existingSkillMd, "utf8");
const newContent = fs.readFileSync(sourceSkillMd, "utf8");
const existingRows = extractGuildTable(existingContent) || [];
const newRows = extractGuildTable(newContent) || [];
if (existingRows.length > 0 || newRows.length > 0) {
const mergedRows = mergeGuildTables(existingRows, newRows);
const mergedTable = buildGuildTable(mergedRows);
const finalContent = newContent.replace(
/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/,
mergedTable
);
fs.rmSync(targetSkillDir, { recursive: true, force: true });
fs.mkdirSync(targetSkillDir, { recursive: true });
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true });
fs.writeFileSync(path.join(targetSkillDir, "SKILL.md"), finalContent, "utf8");
return { merged: true, rowCount: mergedRows.length };
}
}
}
fs.mkdirSync(openClawSkillsDir, { recursive: true });
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true, force: true });
return { merged: false };
}
// === End skill merge utilities ===
const VALID_MODES = new Set(["--install", "--uninstall", "--update"]);
let modeArg = null;
@@ -11,46 +79,24 @@ let argNoReplyPort = 8787;
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (VALID_MODES.has(arg)) {
modeArg = arg;
} else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) {
argOpenClawDir = process.argv[++i];
} else if (arg.startsWith("--openclaw-profile-path=")) {
argOpenClawDir = arg.split("=").slice(1).join("=");
} else if (arg === "--no-reply-port" && i + 1 < process.argv.length) {
argNoReplyPort = Number(process.argv[++i]);
} else if (arg.startsWith("--no-reply-port=")) {
argNoReplyPort = Number(arg.split("=").slice(1).join("="));
}
if (VALID_MODES.has(arg)) modeArg = arg;
else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) argOpenClawDir = process.argv[++i];
else if (arg.startsWith("--openclaw-profile-path=")) argOpenClawDir = arg.split("=").slice(1).join("=");
else if (arg === "--no-reply-port" && i + 1 < process.argv.length) argNoReplyPort = Number(process.argv[++i]);
else if (arg.startsWith("--no-reply-port=")) argNoReplyPort = Number(arg.split("=").slice(1).join("="));
}
if (!modeArg) {
fail("Usage: node scripts/install.mjs --install|--uninstall|--update [--openclaw-profile-path <path>] [--no-reply-port <port>]");
process.exit(2);
}
if (!Number.isFinite(argNoReplyPort) || argNoReplyPort < 1 || argNoReplyPort > 65535) {
fail("invalid --no-reply-port (1-65535)");
console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path <path>] [--no-reply-port <port>]");
process.exit(2);
}
const mode = modeArg.slice(2);
const C = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",
};
const C = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m" };
function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; }
function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); }
function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); }
function ok(msg) { console.log(color(`\t${msg}`, "green")); }
function warn(msg) { console.log(color(`\t${msg}`, "yellow")); }
function fail(msg) { console.log(color(`\t${msg}`, "red")); }
function resolveOpenClawDir() {
if (argOpenClawDir) {
@@ -58,218 +104,116 @@ function resolveOpenClawDir() {
if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`);
return dir;
}
if (process.env.OPENCLAW_DIR) {
const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir());
if (fs.existsSync(dir)) return dir;
warn(`OPENCLAW_DIR not found: ${dir}, fallback to ~/.openclaw`);
}
const fallback = path.join(os.homedir(), ".openclaw");
if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir");
return fallback;
}
const OPENCLAW_DIR = resolveOpenClawDir();
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json");
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
fail(`config not found: ${OPENCLAW_CONFIG_PATH}`);
process.exit(1);
}
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const REPO_ROOT = path.resolve(__dirname, "..");
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
const NO_REPLY_INSTALL_DIR = path.join(PLUGIN_INSTALL_DIR, "no-reply-api");
const SKILLS_DIR = path.join(OPENCLAW_DIR, "skills");
const PLUGIN_SKILLS_DIR = path.join(REPO_ROOT, "skills");
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent";
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort);
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`;
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/no-reply/v1`;
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
const LIST_MODE = process.env.LIST_MODE || "human-list";
const HUMAN_LIST_JSON = process.env.HUMAN_LIST_JSON || "[]";
const AGENT_LIST_JSON = process.env.AGENT_LIST_JSON || "[]";
const CHANNEL_POLICIES_FILE = process.env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json");
const CHANNEL_POLICIES_JSON = process.env.CHANNEL_POLICIES_JSON || "{}";
const END_SYMBOLS_JSON = process.env.END_SYMBOLS_JSON || '["🔚"]';
const SCHEDULING_IDENTIFIER = process.env.SCHEDULING_IDENTIFIER || "➡️";
function runOpenclaw(args, allowFail = false) {
try {
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
} catch (e) {
if (allowFail) return null;
throw e;
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"], true);
if (!out || out === "undefined") return undefined;
try { return JSON.parse(out); } catch { return undefined; }
}
function setJson(pathKey, value) {
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
}
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function mergePreservingExisting(base, updates) {
if (!isPlainObject(updates)) return updates;
const out = isPlainObject(base) ? { ...base } : {};
for (const [key, nextValue] of Object.entries(updates)) {
const currentValue = out[key];
if (nextValue === undefined) continue;
if (isPlainObject(nextValue)) {
out[key] = mergePreservingExisting(currentValue, nextValue);
continue;
}
if (nextValue === null) {
if (currentValue === undefined) out[key] = null;
continue;
}
if (typeof nextValue === "string") {
if (nextValue === "" && currentValue !== undefined) continue;
out[key] = nextValue;
continue;
}
if (Array.isArray(nextValue)) {
if (nextValue.length === 0 && Array.isArray(currentValue) && currentValue.length > 0) continue;
out[key] = nextValue;
continue;
}
out[key] = nextValue;
}
return out;
}
function unsetPath(pathKey) {
runOpenclaw(["config", "unset", pathKey], true);
}
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); }
function syncDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true });
}
function isRegistered() {
const entry = getJson("plugins.entries.dirigent");
return !!(entry && typeof entry === "object");
}
if (mode === "update") {
title("Update");
const branch = process.env.DIRIGENT_GIT_BRANCH || "latest";
step(1, 2, `update source branch=${branch}`);
execFileSync("git", ["fetch", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
execFileSync("git", ["checkout", branch], { cwd: REPO_ROOT, stdio: "inherit" });
execFileSync("git", ["pull", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
ok("source updated");
step(2, 2, "run install after update");
const script = path.join(REPO_ROOT, "scripts", "install.mjs");
const args = [script, "--install", "--openclaw-profile-path", OPENCLAW_DIR, "--no-reply-port", String(NO_REPLY_PORT)];
const ret = spawnSync(process.execPath, args, { cwd: REPO_ROOT, stdio: "inherit", env: process.env });
process.exit(ret.status ?? 1);
}
if (mode === "install") {
title("Install");
step(1, 6, `environment: ${OPENCLAW_DIR}`);
title("Install Dirigent with Skills");
if (isRegistered()) {
warn("plugins.entries.dirigent exists; reinstalling in-place");
}
step(2, 6, "build dist assets");
step(1, 7, "build dist assets");
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api");
const sidecarSrc = path.resolve(REPO_ROOT, "services");
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
const distNoReply = path.resolve(REPO_ROOT, "dist", "dirigent", "no-reply-api");
fs.rmSync(distPlugin, { recursive: true, force: true });
syncDirRecursive(pluginSrc, distPlugin);
syncDirRecursive(noReplySrc, distNoReply);
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
ok("dist assets built");
step(3, 6, `install files -> ${PLUGIN_INSTALL_DIR}`);
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
syncDirRecursive(distNoReply, NO_REPLY_INSTALL_DIR);
ok("plugin files installed");
// cleanup old layout from previous versions
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
if (fs.existsSync(oldTopLevelNoReply)) {
fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
ok(`removed legacy path: ${oldTopLevelNoReply}`);
step(3, 7, "install skills with merge");
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => {
const full = path.join(PLUGIN_SKILLS_DIR, d);
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, "SKILL.md"));
});
for (const skillName of skills) {
const pluginSkillDir = path.join(PLUGIN_SKILLS_DIR, skillName);
const result = installSkillWithMerge(skillName, pluginSkillDir, SKILLS_DIR);
if (result.merged) {
ok(`skill ${skillName} installed (merged ${result.rowCount} guild entries)`);
} else {
ok(`skill ${skillName} installed`);
}
}
}
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
ok(`init channel policies file: ${CHANNEL_POLICIES_FILE}`);
step(4, 7, "configure plugin entry");
// Plugin load path — safe to read/write (not sensitive)
const loadPaths = getJson("plugins.load.paths") || [];
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) {
loadPaths.push(PLUGIN_INSTALL_DIR);
setJson("plugins.load.paths", loadPaths);
}
// For each config field: only write the default if the field has no value.
// Sensitive fields (e.g. moderatorBotToken) are never touched — user sets them manually.
// `getJson` returns undefined if the field is unset; __OPENCLAW_REDACTED__ counts as "set".
function setIfMissing(pathKey, defaultVal) {
const existing = getJson(pathKey);
if (existing === undefined || existing === null) setJson(pathKey, defaultVal);
}
setIfMissing("plugins.entries.dirigent.enabled", true);
const cp = "plugins.entries.dirigent.config";
setIfMissing(`${cp}.scheduleIdentifier`, "➡️");
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
setIfMissing(`${cp}.sideCarPort`, NO_REPLY_PORT);
// moderatorBotToken: intentionally not touched — set manually via:
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
ok("plugin configured");
step(4, 6, "configure plugin entry/path");
const plugins = getJson("plugins") || {};
const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR);
plugins.load = plugins.load || {};
plugins.load.paths = loadPaths;
plugins.entries = plugins.entries || {};
const existingDirigentEntry = isPlainObject(plugins.entries.dirigent) ? plugins.entries.dirigent : {};
const desiredDirigentEntry = {
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,
noReplyPort: NO_REPLY_PORT,
},
};
plugins.entries.dirigent = mergePreservingExisting(existingDirigentEntry, desiredDirigentEntry);
setJson("plugins", plugins);
step(5, 6, "configure no-reply provider");
step(5, 7, "configure no-reply provider");
const providers = getJson("models.providers") || {};
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,
},
],
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("models.providers", providers);
ok("provider configured");
// Add no-reply model to agents.defaults.models allowlist
step(6, 7, "add no-reply model to allowlist");
const agentsDefaultsModels = getJson("agents.defaults.models") || {};
agentsDefaultsModels[`${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`] = {};
setJson("agents.defaults.models", agentsDefaultsModels);
ok("model allowlisted");
step(6, 6, "enable plugin in allowlist");
step(7, 7, "enable plugin in allowlist");
const allow = getJson("plugins.allow") || [];
if (!allow.includes("dirigent")) {
allow.push("dirigent");
setJson("plugins.allow", allow);
}
if (!allow.includes("dirigent")) { allow.push("dirigent"); setJson("plugins.allow", allow); }
ok(`installed (no-reply port: ${NO_REPLY_PORT})`);
console.log("↻ restart gateway: openclaw gateway restart");
process.exit(0);
@@ -277,43 +221,38 @@ if (mode === "install") {
if (mode === "uninstall") {
title("Uninstall");
step(1, 5, `environment: ${OPENCLAW_DIR}`);
step(2, 5, "remove allowlist + plugin entry");
step(1, 4, "remove allowlist + plugin entry");
const allow = getJson("plugins.allow") || [];
const idx = allow.indexOf("dirigent");
if (idx >= 0) {
allow.splice(idx, 1);
setJson("plugins.allow", allow);
ok("removed from plugins.allow");
}
if (idx >= 0) { allow.splice(idx, 1); setJson("plugins.allow", allow); ok("removed from plugins.allow"); }
runOpenclaw(["config", "unset", "plugins.entries.dirigent"], true);
ok("removed plugin entry");
unsetPath("plugins.entries.dirigent");
ok("removed plugins.entries.dirigent");
step(3, 5, "remove plugin load path");
step(2, 4, "remove plugin load path");
const plugins = getJson("plugins") || {};
const paths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
plugins.load = plugins.load || {};
plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR);
plugins.load = plugins.load || {}; plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR);
setJson("plugins", plugins);
ok("removed plugin path from plugins.load.paths");
ok("removed load path");
step(4, 5, "remove no-reply provider");
const providers = getJson("models.providers") || {};
delete providers[NO_REPLY_PROVIDER_ID];
setJson("models.providers", providers);
ok(`removed provider ${NO_REPLY_PROVIDER_ID}`);
step(5, 5, "remove installed files");
step(3, 4, "remove installed files");
if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true });
if (fs.existsSync(NO_REPLY_INSTALL_DIR)) fs.rmSync(NO_REPLY_INSTALL_DIR, { recursive: true, force: true });
const legacyNoReply = path.join(PLUGINS_DIR, "dirigent-no-reply-api");
if (fs.existsSync(legacyNoReply)) fs.rmSync(legacyNoReply, { recursive: true, force: true });
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
if (fs.existsSync(oldTopLevelNoReply)) fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
ok("removed installed files");
ok("removed plugin files");
step(4, 4, "remove skills");
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => fs.statSync(path.join(PLUGIN_SKILLS_DIR, d)).isDirectory());
for (const skillName of skills) {
const targetSkillDir = path.join(SKILLS_DIR, skillName);
if (fs.existsSync(targetSkillDir)) {
fs.rmSync(targetSkillDir, { recursive: true, force: true });
ok(`removed skill ${skillName}`);
}
}
}
console.log("↻ restart gateway: openclaw gateway restart");
process.exit(0);
}
console.error("Unknown mode:", mode);
process.exit(2);

View File

@@ -1,15 +0,0 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const pluginDir = path.join(root, "plugin");
const outDir = path.join(root, "dist", "dirigent");
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "moderator-presence.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
}
console.log(`packaged plugin to ${outDir}`);

View File

@@ -1,32 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8787}"
AUTH_TOKEN="${AUTH_TOKEN:-}"
AUTH_HEADER=()
if [[ -n "$AUTH_TOKEN" ]]; then
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
fi
# Smoke-tests the no-reply API endpoint exposed by the sidecar service.
# The sidecar must already be running (it starts automatically with openclaw-gateway).
# Default base URL matches the sidecar's no-reply prefix.
BASE_URL="${BASE_URL:-http://127.0.0.1:8787/no-reply}"
echo "[1] health"
curl -sS "${BASE_URL}/health" | sed -n '1,3p'
curl -fsS "${BASE_URL}/health"
echo ""
echo "[2] models"
curl -sS "${BASE_URL}/v1/models" "${AUTH_HEADER[@]}" | sed -n '1,8p'
curl -fsS "${BASE_URL}/v1/models" | head -c 200
echo ""
echo "[3] chat/completions"
curl -sS -X POST "${BASE_URL}/v1/chat/completions" \
curl -fsS -X POST "${BASE_URL}/v1/chat/completions" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \
| sed -n '1,20p'
-d '{"model":"no-reply","messages":[{"role":"user","content":"hello"}]}' \
| head -c 300
echo ""
echo "[4] responses"
curl -sS -X POST "${BASE_URL}/v1/responses" \
curl -fsS -X POST "${BASE_URL}/v1/responses" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
-d '{"model":"dirigent-no-reply-v1","input":"hello"}' \
| sed -n '1,20p'
-d '{"model":"no-reply","input":"hello"}' \
| head -c 300
echo ""
echo "smoke ok"

211
scripts/test-features.mjs Normal file
View File

@@ -0,0 +1,211 @@
#!/usr/bin/env node
/**
* Dirigent feature test script
* Tests: no-reply gate, end-symbol enforcement, turn management
*
* Usage:
* node scripts/test-features.mjs [channelId]
*
* Env:
* PROXY_TOKEN - path to PROXY_BOT_TOKEN file (default: ./PROXY_BOT_TOKEN)
* GUILD_ID - guild id (default: 1480860737902743686)
*
* Reads token from PROXY_BOT_TOKEN file.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TOKEN_FILE = process.env.PROXY_TOKEN || path.resolve(__dirname, "../PROXY_BOT_TOKEN");
const GUILD_ID = process.env.GUILD_ID || "1480860737902743686";
const C = {
reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m",
yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m", bold: "\x1b[1m",
};
const c = (t, col) => `${C[col] || ""}${t}${C.reset}`;
const TOKEN = fs.readFileSync(TOKEN_FILE, "utf8").trim().split(/\s/)[0];
async function discord(method, path_, body) {
const r = await fetch(`https://discord.com/api/v10${path_}`, {
method,
headers: {
Authorization: `Bot ${TOKEN}`,
"Content-Type": "application/json",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let json = null;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
return { ok: r.ok, status: r.status, json };
}
async function sendMessage(channelId, content) {
const r = await discord("POST", `/channels/${channelId}/messages`, { content });
if (!r.ok) throw new Error(`send failed ${r.status}: ${JSON.stringify(r.json)}`);
return r.json;
}
async function getMessages(channelId, limit = 10) {
const r = await discord("GET", `/channels/${channelId}/messages?limit=${limit}`);
if (!r.ok) throw new Error(`fetch messages failed ${r.status}`);
return r.json; // newest first
}
async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// Wait for agent responses: poll until we see `expectedCount` new messages from bots
async function waitForBotMessages(channelId, afterMsgId, expectedCount = 1, timeoutMs = 15000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await sleep(1500);
const msgs = await getMessages(channelId, 20);
const newBotMsgs = msgs.filter(m =>
BigInt(m.id) > BigInt(afterMsgId) &&
m.author?.bot === true &&
m.author?.id !== "1481189346097758298" // exclude our proxy bot
);
if (newBotMsgs.length >= expectedCount) return newBotMsgs;
}
return [];
}
function printMsg(m) {
const who = `${m.author?.username}(${m.author?.id})`;
const preview = (m.content || "").slice(0, 120).replace(/\n/g, "\\n");
console.log(` ${c(who, "cyan")}: ${preview}`);
}
// ─────────────────────────────────────────────────────────
// Test helpers
// ─────────────────────────────────────────────────────────
let passed = 0, failed = 0;
function check(label, cond, detail = "") {
if (cond) {
console.log(` ${c("✓", "green")} ${label}`);
passed++;
} else {
console.log(` ${c("✗", "red")} ${label}${detail ? `${detail}` : ""}`);
failed++;
}
}
// ─────────────────────────────────────────────────────────
// Main tests
// ─────────────────────────────────────────────────────────
async function main() {
// Resolve channel
let channelId = process.argv[2];
if (!channelId) {
// Create a fresh test channel
console.log(c("\n[setup] Creating private test channel...", "blue"));
const meR = await discord("GET", "/users/@me");
if (!meR.ok) { console.error("Cannot auth:", meR.json); process.exit(1); }
console.log(` proxy bot: ${meR.json.username} (${meR.json.id})`);
// Get guild roles to find @everyone
const guildR = await discord("GET", `/guilds/${GUILD_ID}`);
const guildEveryoneId = guildR.json?.id || GUILD_ID;
// Get guild members to find agent bots
const membersR = await discord("GET", `/guilds/${GUILD_ID}/members?limit=50`);
const bots = (membersR.json || [])
.filter(m => m.user?.bot && m.user?.id !== meR.json.id)
.map(m => m.user);
console.log(` agent bots in guild: ${bots.map(b => `${b.username}(${b.id})`).join(", ")}`);
const allowedUserIds = [meR.json.id, ...bots.map(b => b.id)];
const overwrites = [
{ id: guildEveryoneId, type: 0, allow: "0", deny: "1024" },
...allowedUserIds.map(id => ({ id, type: 1, allow: "1024", deny: "0" })),
];
const chR = await discord("POST", `/guilds/${GUILD_ID}/channels`, {
name: `dirigent-test-${Date.now().toString(36)}`,
type: 0,
permission_overwrites: overwrites,
});
if (!chR.ok) { console.error("Cannot create channel:", chR.json); process.exit(1); }
channelId = chR.json.id;
console.log(` created channel: #${chR.json.name} (${channelId})`);
} else {
console.log(c(`\n[setup] Using channel ${channelId}`, "blue"));
}
await sleep(1000);
// ─────────────────────────────────────────────────────────
// Test 1: Human sends message → agents should respond with end-symbol
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 1] Human message → agent response must end with 🔚", "bold"));
const msg1 = await sendMessage(channelId, "Hello from human proxy! Please introduce yourself briefly. 🔚");
console.log(` sent: "${msg1.content}"`);
console.log(" waiting up to 20s for bot responses...");
const botMsgs1 = await waitForBotMessages(channelId, msg1.id, 1, 20000);
if (botMsgs1.length === 0) {
check("Agent responded", false, "no bot messages received within 20s");
} else {
for (const m of botMsgs1) printMsg(m);
check(
"Agent response ends with 🔚",
botMsgs1.some(m => m.content?.trim().endsWith("🔚")),
`got: ${botMsgs1.map(m => m.content?.slice(-10)).join(" | ")}`
);
}
// ─────────────────────────────────────────────────────────
// Test 2: Turn order — only one agent per round
// After first agent replies, second agent should be next
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 2] Turn order — check /dirigent turn-status", "bold"));
console.log(" (Observational — check Discord channel for /dirigent turn-status output)");
console.log(c(" → Manually run /dirigent turn-status in the test channel to verify", "yellow"));
// ─────────────────────────────────────────────────────────
// Test 3: Bot message without end-symbol → no-reply gate
// We send a message that looks like a bot (not in humanList) — observe logs
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 3] Second round — agent should reply after human follow-up", "bold"));
await sleep(3000);
const msg3 = await sendMessage(channelId, "What is 2+2? Answer briefly. 🔚");
console.log(` sent: "${msg3.content}"`);
const botMsgs3 = await waitForBotMessages(channelId, msg3.id, 1, 20000);
if (botMsgs3.length === 0) {
check("Agent responded to follow-up", false, "no response within 20s");
} else {
for (const m of botMsgs3) printMsg(m);
check(
"Follow-up response ends with 🔚",
botMsgs3.some(m => m.content?.trim().endsWith("🔚")),
);
}
// ─────────────────────────────────────────────────────────
// Test 4: NO_REPLY behavior — ask something irrelevant to trigger NO_REPLY
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 4] NO_REPLY — agents with nothing to say should be silent", "bold"));
console.log(" (This is hard to assert automatically — check gateway logs for NO_REPLY routing)");
console.log(c(" → Watch `openclaw logs` for 'dirigent: before_model_resolve blocking out-of-turn'", "yellow"));
// ─────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────
console.log(c(`\n─────────────────────────────────────────────`, "blue"));
console.log(`Results: ${c(String(passed), "green")} passed, ${c(String(failed), "red")} failed`);
console.log(`Channel: https://discord.com/channels/${GUILD_ID}/${channelId}`);
console.log(c("─────────────────────────────────────────────\n", "blue"));
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -1,82 +0,0 @@
import { spawn } from "node:child_process";
const BASE = "http://127.0.0.1:18787";
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function waitForHealth(retries = 30) {
for (let i = 0; i < retries; i++) {
try {
const r = await fetch(`${BASE}/health`);
if (r.ok) return true;
} catch {}
await sleep(200);
}
return false;
}
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
async function run() {
const token = "test-token";
const child = spawn("node", ["no-reply-api/server.mjs"], {
cwd: process.cwd(),
env: { ...process.env, PORT: "18787", AUTH_TOKEN: token, NO_REPLY_MODEL: "wg-test-model" },
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", () => {});
child.stderr.on("data", () => {});
try {
const ok = await waitForHealth();
assert(ok, "health check failed");
const unauth = await fetch(`${BASE}/v1/models`);
assert(unauth.status === 401, `expected 401, got ${unauth.status}`);
const models = await fetch(`${BASE}/v1/models`, {
headers: { Authorization: `Bearer ${token}` },
});
assert(models.ok, "authorized /v1/models failed");
const modelsJson = await models.json();
assert(modelsJson?.data?.[0]?.id === "wg-test-model", "model id mismatch");
const cc = await fetch(`${BASE}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model: "wg-test-model", messages: [{ role: "user", content: "hi" }] }),
});
assert(cc.ok, "chat completions failed");
const ccJson = await cc.json();
assert(ccJson?.choices?.[0]?.message?.content === "NO_REPLY", "chat completion not NO_REPLY");
const rsp = await fetch(`${BASE}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model: "wg-test-model", input: "hi" }),
});
assert(rsp.ok, "responses failed");
const rspJson = await rsp.json();
assert(rspJson?.output?.[0]?.content?.[0]?.text === "NO_REPLY", "responses not NO_REPLY");
console.log("test-no-reply-api: ok");
} finally {
child.kill("SIGTERM");
}
}
run().catch((err) => {
console.error(`test-no-reply-api: fail: ${err.message}`);
process.exit(1);
});

111
services/main.mjs Normal file
View File

@@ -0,0 +1,111 @@
/**
* Unified entry point for Dirigent services.
*
* Routes:
* /no-reply/* → no-reply API (strips /no-reply prefix)
* /moderator/* → moderator bot service (strips /moderator prefix)
* otherwise → 404
*
* Env vars:
* SERVICES_PORT (default 8787)
* MODERATOR_TOKEN Discord bot token (required for moderator)
* PLUGIN_API_URL (default http://127.0.0.1:18789)
* PLUGIN_API_TOKEN auth token for plugin API calls
* SCHEDULE_IDENTIFIER (default ➡️)
* DEBUG_MODE (default false)
*/
import http from "node:http";
import { createNoReplyHandler } from "./no-reply-api/server.mjs";
import { createModeratorService } from "./moderator/index.mjs";
const PORT = Number(process.env.SERVICES_PORT || 8787);
const MODERATOR_TOKEN = process.env.MODERATOR_TOKEN || "";
const PLUGIN_API_URL = process.env.PLUGIN_API_URL || "http://127.0.0.1:18789";
const PLUGIN_API_TOKEN = process.env.PLUGIN_API_TOKEN || "";
const SCHEDULE_IDENTIFIER = process.env.SCHEDULE_IDENTIFIER || "➡️";
const DEBUG_MODE = process.env.DEBUG_MODE === "true" || process.env.DEBUG_MODE === "1";
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
// ── Initialize services ────────────────────────────────────────────────────────
const noReplyHandler = createNoReplyHandler();
let moderatorService = null;
if (MODERATOR_TOKEN) {
console.log("[dirigent-services] moderator bot enabled");
moderatorService = createModeratorService({
token: MODERATOR_TOKEN,
pluginApiUrl: PLUGIN_API_URL,
pluginApiToken: PLUGIN_API_TOKEN,
scheduleIdentifier: SCHEDULE_IDENTIFIER,
debugMode: DEBUG_MODE,
});
} else {
console.log("[dirigent-services] MODERATOR_TOKEN not set — moderator disabled");
}
// ── HTTP server ────────────────────────────────────────────────────────────────
const server = http.createServer((req, res) => {
const url = req.url ?? "/";
if (url === "/health") {
return sendJson(res, 200, {
ok: true,
services: {
noReply: true,
moderator: !!moderatorService,
},
});
}
if (url.startsWith("/no-reply")) {
req.url = url.slice("/no-reply".length) || "/";
return noReplyHandler(req, res);
}
if (url.startsWith("/moderator")) {
if (!moderatorService) {
return sendJson(res, 503, { error: "moderator service not configured" });
}
req.url = url.slice("/moderator".length) || "/";
return moderatorService.httpHandler(req, res);
}
return sendJson(res, 404, { error: "not_found" });
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`[dirigent-services] listening on 127.0.0.1:${PORT}`);
console.log(`[dirigent-services] /no-reply → no-reply API`);
if (moderatorService) {
console.log(`[dirigent-services] /moderator → moderator bot`);
console.log(`[dirigent-services] plugin API: ${PLUGIN_API_URL}`);
}
if (DEBUG_MODE) {
console.log(`[dirigent-services] debug mode ON`);
}
});
// ── Graceful shutdown ──────────────────────────────────────────────────────────
function shutdown(signal) {
console.log(`[dirigent-services] received ${signal}, shutting down`);
if (moderatorService) {
moderatorService.stop();
}
server.close(() => {
console.log("[dirigent-services] server closed");
process.exit(0);
});
// Force-exit after 5s
setTimeout(() => process.exit(1), 5000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

View File

@@ -0,0 +1,514 @@
/**
* Moderator bot service.
*
* Exports createModeratorService(config) returning { httpHandler(req, res), stop() }.
*
* Responsibilities:
* - Discord Gateway WS with intents GUILD_MESSAGES (512) | MESSAGE_CONTENT (32768)
* - On MESSAGE_CREATE dispatch: notify plugin API
* - HTTP sub-handler for /health, /me, /send, /delete-message, /create-channel, /guilds, /channels/:guildId
*/
import { URL as NodeURL } from "node:url";
const DISCORD_API = "https://discord.com/api/v10";
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
const MAX_RECONNECT_DELAY_MS = 60_000;
const INTENTS = 512 | 32768; // GUILD_MESSAGES | MESSAGE_CONTENT
// ── Helpers ────────────────────────────────────────────────────────────────────
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
req.destroy();
reject(new Error("body too large"));
}
});
req.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("invalid_json"));
}
});
req.on("error", reject);
});
}
function getBotUserIdFromToken(token) {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}
// ── Discord REST helpers ───────────────────────────────────────────────────────
async function discordGet(token, path) {
const r = await fetch(`${DISCORD_API}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Discord GET ${path} failed (${r.status}): ${text}`);
}
return r.json();
}
async function discordPost(token, path, body) {
const r = await fetch(`${DISCORD_API}${path}`, {
method: "POST",
headers: {
Authorization: `Bot ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return { ok: r.ok, status: r.status, data: await r.json().catch(() => null) };
}
async function discordDelete(token, path) {
const r = await fetch(`${DISCORD_API}${path}`, {
method: "DELETE",
headers: { Authorization: `Bot ${token}` },
});
return { ok: r.ok, status: r.status };
}
// ── Gateway connection ─────────────────────────────────────────────────────────
function createGatewayConnection(token, onMessage, log) {
let ws = null;
let heartbeatTimer = null;
let heartbeatAcked = true;
let lastSequence = null;
let sessionId = null;
let resumeUrl = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
let destroyed = false;
function sendPayload(data) {
if (ws?.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify(data));
}
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
clearTimeout(heartbeatTimer);
heartbeatTimer = null;
}
}
function startHeartbeat(intervalMs) {
stopHeartbeat();
heartbeatAcked = true;
const jitter = Math.floor(Math.random() * intervalMs);
const firstTimer = setTimeout(() => {
if (destroyed) return;
if (!heartbeatAcked) {
ws?.close(4000, "missed heartbeat ack");
return;
}
heartbeatAcked = false;
sendPayload({ op: 1, d: lastSequence });
heartbeatTimer = setInterval(() => {
if (destroyed) return;
if (!heartbeatAcked) {
ws?.close(4000, "missed heartbeat ack");
return;
}
heartbeatAcked = false;
sendPayload({ op: 1, d: lastSequence });
}, intervalMs);
}, jitter);
heartbeatTimer = firstTimer;
}
function cleanup() {
stopHeartbeat();
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
try { ws.close(1000); } catch { /* ignore */ }
ws = null;
}
}
function scheduleReconnect(resume) {
if (destroyed) return;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectAttempts++;
const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS);
const delay = baseDelay + Math.random() * 1000;
log.info(`dirigent-moderator: reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect(resume);
}, delay);
}
function connect(isResume = false) {
if (destroyed) return;
cleanup();
const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL;
try {
ws = new WebSocket(url);
} catch (err) {
log.warn(`dirigent-moderator: ws constructor failed: ${String(err)}`);
scheduleReconnect(false);
return;
}
const currentWs = ws;
ws.onopen = () => {
if (currentWs !== ws || destroyed) return;
reconnectAttempts = 0;
if (isResume && sessionId) {
sendPayload({
op: 6,
d: { token, session_id: sessionId, seq: lastSequence },
});
} else {
sendPayload({
op: 2,
d: {
token,
intents: INTENTS,
properties: {
os: "linux",
browser: "dirigent",
device: "dirigent",
},
},
});
}
};
ws.onmessage = (evt) => {
if (currentWs !== ws || destroyed) return;
try {
const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
const { op, t, s, d } = msg;
if (s != null) lastSequence = s;
switch (op) {
case 10: // Hello
startHeartbeat(d.heartbeat_interval);
break;
case 11: // Heartbeat ACK
heartbeatAcked = true;
break;
case 1: // Heartbeat request
sendPayload({ op: 1, d: lastSequence });
break;
case 0: // Dispatch
if (t === "READY") {
sessionId = d.session_id;
resumeUrl = d.resume_gateway_url;
log.info("dirigent-moderator: connected and ready");
} else if (t === "RESUMED") {
log.info("dirigent-moderator: session resumed");
} else if (t === "MESSAGE_CREATE") {
onMessage(d);
}
break;
case 7: // Reconnect
log.info("dirigent-moderator: reconnect requested by Discord");
cleanup();
scheduleReconnect(true);
break;
case 9: // Invalid Session
log.warn(`dirigent-moderator: invalid session, resumable=${d}`);
cleanup();
sessionId = d ? sessionId : null;
setTimeout(() => {
if (!destroyed) connect(!!d && !!sessionId);
}, 3000 + Math.random() * 2000);
break;
}
} catch {
// ignore parse errors
}
};
ws.onclose = (evt) => {
if (currentWs !== ws) return;
stopHeartbeat();
if (destroyed) return;
const code = evt.code;
if (code === 4004) {
log.warn("dirigent-moderator: token invalid (4004), stopping");
return;
}
if (code === 4010 || code === 4011 || code === 4013 || code === 4014) {
log.warn(`dirigent-moderator: fatal close (${code}), re-identifying`);
sessionId = null;
scheduleReconnect(false);
return;
}
log.info(`dirigent-moderator: disconnected (code=${code}), will reconnect`);
const canResume = !!sessionId && code !== 4012;
scheduleReconnect(canResume);
};
ws.onerror = () => {
// onclose will fire after this
};
}
// Start initial connection
connect(false);
return {
stop() {
destroyed = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
cleanup();
},
};
}
// ── HTTP route handler ─────────────────────────────────────────────────────────
function createHttpHandler(token, botUserId, log) {
return async function httpHandler(req, res) {
const url = req.url ?? "/";
// GET /health
if (req.method === "GET" && url === "/health") {
return sendJson(res, 200, { ok: true, botId: botUserId });
}
// GET /me
if (req.method === "GET" && url === "/me") {
try {
const data = await discordGet(token, "/users/@me");
return sendJson(res, 200, { id: data.id, username: data.username });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// GET /guilds
if (req.method === "GET" && url === "/guilds") {
try {
const guilds = await discordGet(token, "/users/@me/guilds");
const ADMIN = 8n;
const adminGuilds = guilds
.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN)
.map((g) => ({ id: g.id, name: g.name }));
return sendJson(res, 200, { guilds: adminGuilds });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// GET /channels/:guildId
const channelsMatch = url.match(/^\/channels\/(\d+)$/);
if (req.method === "GET" && channelsMatch) {
const guildId = channelsMatch[1];
try {
const channels = await discordGet(token, `/guilds/${guildId}/channels`);
return sendJson(res, 200, {
channels: channels
.filter((c) => c.type === 0)
.map((c) => ({ id: c.id, name: c.name, type: c.type })),
});
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// POST /send
if (req.method === "POST" && url === "/send") {
let body;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const { channelId, content } = body;
if (!channelId || !content) {
return sendJson(res, 400, { ok: false, error: "channelId and content required" });
}
try {
const result = await discordPost(token, `/channels/${channelId}/messages`, { content });
if (!result.ok) {
return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` });
}
return sendJson(res, 200, { ok: true, messageId: result.data?.id });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// POST /delete-message
if (req.method === "POST" && url === "/delete-message") {
let body;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const { channelId, messageId } = body;
if (!channelId || !messageId) {
return sendJson(res, 400, { ok: false, error: "channelId and messageId required" });
}
try {
const result = await discordDelete(token, `/channels/${channelId}/messages/${messageId}`);
return sendJson(res, 200, { ok: result.ok });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// POST /create-channel
if (req.method === "POST" && url === "/create-channel") {
let body;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const { guildId, name, permissionOverwrites = [] } = body;
if (!guildId || !name) {
return sendJson(res, 400, { ok: false, error: "guildId and name required" });
}
try {
const result = await discordPost(token, `/guilds/${guildId}/channels`, {
name,
type: 0,
permission_overwrites: permissionOverwrites,
});
if (!result.ok) {
return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` });
}
return sendJson(res, 200, { ok: true, channelId: result.data?.id });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
return sendJson(res, 404, { error: "not_found" });
};
}
// ── Plugin notification ────────────────────────────────────────────────────────
function createNotifyPlugin(pluginApiUrl, pluginApiToken, log) {
return function notifyPlugin(message) {
const body = JSON.stringify({
channelId: message.channel_id,
messageId: message.id,
senderId: message.author?.id,
guildId: message.guild_id,
content: message.content,
});
const headers = {
"Content-Type": "application/json",
};
if (pluginApiToken) {
headers["Authorization"] = `Bearer ${pluginApiToken}`;
}
fetch(`${pluginApiUrl}/dirigent/api/moderator/message`, {
method: "POST",
headers,
body,
}).catch((err) => {
log.warn(`dirigent-moderator: notify plugin failed: ${String(err)}`);
});
};
}
// ── Public API ─────────────────────────────────────────────────────────────────
/**
* Create the moderator service.
*
* @param {object} config
* @param {string} config.token - Discord bot token
* @param {string} config.pluginApiUrl - e.g. "http://127.0.0.1:18789"
* @param {string} [config.pluginApiToken] - bearer token for plugin API
* @param {string} [config.scheduleIdentifier] - e.g. "➡️"
* @param {boolean} [config.debugMode]
* @returns {{ httpHandler: Function, stop: Function }}
*/
export function createModeratorService(config) {
const { token, pluginApiUrl, pluginApiToken = "", scheduleIdentifier = "➡️", debugMode = false } = config;
const log = {
info: (msg) => console.log(`[dirigent-moderator] ${msg}`),
warn: (msg) => console.warn(`[dirigent-moderator] WARN ${msg}`),
};
if (debugMode) {
log.info(`debug mode enabled, scheduleIdentifier=${scheduleIdentifier}`);
}
// Decode bot user ID from token
const botUserId = getBotUserIdFromToken(token);
log.info(`bot user id decoded: ${botUserId ?? "(unknown)"}`);
// Plugin notify callback (fire-and-forget)
const notifyPlugin = createNotifyPlugin(pluginApiUrl, pluginApiToken, log);
// Gateway connection
const gateway = createGatewayConnection(
token,
(message) => {
// Skip bot's own messages
if (message.author?.id === botUserId) return;
notifyPlugin(message);
},
log,
);
// HTTP handler (caller strips /moderator prefix)
const httpHandler = createHttpHandler(token, botUserId, log);
return {
httpHandler,
stop() {
log.info("stopping moderator service");
gateway.stop();
},
};
}

View File

@@ -0,0 +1,131 @@
import http from "node:http";
const modelName = process.env.NO_REPLY_MODEL || "no-reply";
const authToken = process.env.AUTH_TOKEN || "";
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function isAuthorized(req) {
if (!authToken) return true;
const header = req.headers.authorization || "";
return header === `Bearer ${authToken}`;
}
function noReplyChatCompletion(reqBody) {
return {
id: `chatcmpl_dirigent_${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
choices: [
{
index: 0,
message: { role: "assistant", content: "NO_REPLY" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 },
};
}
function noReplyResponses(reqBody) {
return {
id: `resp_dirigent_${Date.now()}`,
object: "response",
created_at: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "NO_REPLY" }],
},
],
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 },
};
}
function listModels() {
return {
object: "list",
data: [
{
id: modelName,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "dirigent",
},
],
};
}
/**
* Returns a Node.js HTTP request handler for the no-reply API.
* When used as a sub-service inside main.mjs, the caller strips
* the "/no-reply" prefix from req.url before calling this handler.
*/
export function createNoReplyHandler() {
return function noReplyHandler(req, res) {
const url = req.url ?? "/";
if (req.method === "GET" && url === "/health") {
return sendJson(res, 200, {
ok: true,
service: "dirigent-no-reply-api",
model: modelName,
});
}
if (req.method === "GET" && url === "/v1/models") {
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
return sendJson(res, 200, listModels());
}
if (req.method !== "POST") {
return sendJson(res, 404, { error: "not_found" });
}
if (!isAuthorized(req)) {
return sendJson(res, 401, { error: "unauthorized" });
}
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) req.destroy();
});
req.on("end", () => {
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch {
return sendJson(res, 400, { error: "invalid_json" });
}
if (url === "/v1/chat/completions") {
return sendJson(res, 200, noReplyChatCompletion(parsed));
}
if (url === "/v1/responses") {
return sendJson(res, 200, noReplyResponses(parsed));
}
return sendJson(res, 404, { error: "not_found" });
});
};
}
// Standalone mode: run HTTP server if this file is the entry point
const isMain = process.argv[1] && process.argv[1].endsWith("server.mjs");
if (isMain) {
const port = Number(process.env.PORT || 8787);
const handler = createNoReplyHandler();
const server = http.createServer(handler);
server.listen(port, () => {
console.log(`[dirigent-no-reply-api] listening on :${port}`);
});
}

View File

@@ -0,0 +1,29 @@
---
name: discord-guilds
description: When calling tools that require Discord guild ID/server ID, refer to this skill for available guild IDs and their descriptions.
---
# Discord Guilds
Use this skill when a tool or command requires a Discord `guildId` (also called server ID).
## Available Guilds
| guild-id | description |
|----------|-------------|
| 1480860737902743686 | Main test guild for HarborForge/Dirigent development |
## Usage
When calling tools like `dirigent_discord_control` that require a `guildId` parameter, look up the appropriate guild ID from the table above based on the context.
To add a new guild to this list, use the `add-guild` script:
```bash
{baseDir}/scripts/add-guild <guild-id> <description>
```
Example:
```bash
~/.openclaw/skills/discord-guilds/scripts/add-guild "123456789012345678" "Production server"
```

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILL_PATH = path.resolve(__dirname, "../SKILL.md");
const args = process.argv.slice(2);
if (args.length < 2) {
console.error("Usage: add-guild <guild-id> <description>");
process.exit(1);
}
const guildId = args[0];
const description = args.slice(1).join(" ");
if (!/^\d+$/.test(guildId)) {
console.error("Error: guild-id must be numeric (Discord snowflake)");
process.exit(1);
}
let content = fs.readFileSync(SKILL_PATH, "utf8");
const newRow = `| ${guildId} | ${description} |`;
// Find separator line and insert after it
const lines = content.split("\n");
let insertIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\|[-\s|]+\|$/.test(lines[i]) && i > 0 && lines[i-1].includes("guild-id")) {
insertIndex = i;
break;
}
}
if (insertIndex === -1) {
console.error("Error: Could not find guild table in SKILL.md");
process.exit(1);
}
lines.splice(insertIndex + 1, 0, newRow);
fs.writeFileSync(SKILL_PATH, lines.join("\n"), "utf8");
console.log(`✓ Added guild: ${guildId} - ${description}`);

View File

@@ -0,0 +1,175 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerBeforeMessageWriteHook } from '../plugin/hooks/before-message-write.ts';
import { registerMessageReceivedHook } from '../plugin/hooks/message-received.ts';
import { registerMessageSentHook } from '../plugin/hooks/message-sent.ts';
import { buildDiscussionOriginCallbackMessage } from '../plugin/core/discussion-messages.ts';
import { initTurnOrder, onNewMessage, getTurnDebugInfo, resetTurn } from '../plugin/turn-manager.ts';
type Handler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => unknown;
function makeApi() {
const handlers = new Map<string, Handler>();
return {
handlers,
logger: {
info: (_msg: string) => {},
warn: (_msg: string) => {},
},
on(name: string, handler: Handler) {
handlers.set(name, handler);
},
};
}
test('before_message_write leaves ordinary channels dormant without sending a discussion idle reminder', async () => {
const channelId = 'normal-channel';
resetTurn(channelId);
initTurnOrder(channelId, ['agent-a', 'agent-b']);
onNewMessage(channelId, 'human-user', true);
const state = getTurnDebugInfo(channelId);
const [firstSpeaker, secondSpeaker] = state.turnOrder as string[];
assert.ok(firstSpeaker);
assert.ok(secondSpeaker);
const sessionChannelId = new Map<string, string>([
['sess-a', channelId],
['sess-b', channelId],
]);
const sessionAccountId = new Map<string, string>([
['sess-a', firstSpeaker],
['sess-b', secondSpeaker],
]);
const sessionAllowed = new Map<string, boolean>([
['sess-a', true],
['sess-b', true],
]);
const sessionTurnHandled = new Set<string>();
const idleReminderCalls: string[] = [];
const moderatorMessages: string[] = [];
const api = makeApi();
registerBeforeMessageWriteHook({
api: api as any,
baseConfig: { endSymbols: ['🔚'], moderatorBotToken: 'bot-token' } as any,
policyState: { channelPolicies: {} },
sessionAllowed,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded: () => {},
shouldDebugLog: () => false,
ensureTurnOrder: () => {},
resolveDiscordUserId: () => undefined,
isMultiMessageMode: () => false,
sendModeratorMessage: async (_token, _channelId, content) => {
moderatorMessages.push(content);
return { ok: true };
},
discussionService: {
maybeSendIdleReminder: async (id) => {
idleReminderCalls.push(id);
},
getDiscussion: () => undefined,
},
});
const beforeMessageWrite = api.handlers.get('before_message_write');
assert.ok(beforeMessageWrite);
await beforeMessageWrite?.({ message: { role: 'assistant', content: 'NO_REPLY' } }, { sessionKey: 'sess-a' });
await beforeMessageWrite?.({ message: { role: 'assistant', content: 'NO_REPLY' } }, { sessionKey: 'sess-b' });
assert.deepEqual(idleReminderCalls, []);
assert.deepEqual(moderatorMessages, []);
assert.equal(getTurnDebugInfo(channelId).dormant, true);
});
test('message_received lets moderator discussion callback notifications wake the origin channel workflow', async () => {
const channelId = '1474327736242798612';
resetTurn(channelId);
initTurnOrder(channelId, ['agent-a', 'agent-b']);
assert.equal(getTurnDebugInfo(channelId).currentSpeaker, null);
const api = makeApi();
registerMessageReceivedHook({
api: api as any,
baseConfig: {
moderatorUserId: 'moderator-user',
humanList: ['human-user'],
} as any,
shouldDebugLog: () => false,
debugCtxSummary: () => ({}),
ensureTurnOrder: () => {},
getModeratorUserId: (cfg) => (cfg as any).moderatorUserId,
recordChannelAccount: () => false,
extractMentionedUserIds: () => [],
buildUserIdToAccountIdMap: () => new Map(),
enterMultiMessageMode: () => {},
exitMultiMessageMode: () => {},
discussionService: {
maybeReplyClosedChannel: async () => false,
},
});
const messageReceived = api.handlers.get('message_received');
assert.ok(messageReceived);
await messageReceived?.({
content: buildDiscussionOriginCallbackMessage('/workspace/plans/discussion-summary.md', 'discussion-42'),
from: 'moderator-user',
}, {
conversationId: channelId,
});
const state = getTurnDebugInfo(channelId);
assert.equal(state.currentSpeaker, state.turnOrder[0]);
assert.equal(state.dormant, false);
});
test('message_sent skips handoff after discuss-callback has closed the discussion channel', async () => {
const channelId = 'discussion-closed-channel';
resetTurn(channelId);
initTurnOrder(channelId, ['agent-a', 'agent-b']);
onNewMessage(channelId, 'human-user', true);
const state = getTurnDebugInfo(channelId);
const currentSpeaker = state.currentSpeaker as string;
assert.ok(currentSpeaker);
const moderatorMessages: string[] = [];
const api = makeApi();
registerMessageSentHook({
api: api as any,
baseConfig: {
endSymbols: ['🔚'],
moderatorBotToken: 'bot-token',
schedulingIdentifier: '➡️',
} as any,
policyState: { channelPolicies: {} },
sessionChannelId: new Map([['sess-closed', channelId]]),
sessionAccountId: new Map([['sess-closed', currentSpeaker]]),
sessionTurnHandled: new Set(),
ensurePolicyStateLoaded: () => {},
resolveDiscordUserId: () => 'discord-user-next',
sendModeratorMessage: async (_token, _channelId, content) => {
moderatorMessages.push(content);
return { ok: true };
},
discussionService: {
isClosedDiscussion: (id) => id === channelId,
},
});
const messageSent = api.handlers.get('message_sent');
assert.ok(messageSent);
await messageSent?.({ content: 'NO_REPLY' }, { sessionKey: 'sess-closed', accountId: currentSpeaker, channelId });
assert.deepEqual(moderatorMessages, []);
assert.equal(getTurnDebugInfo(channelId).currentSpeaker, currentSpeaker);
});

View File

@@ -0,0 +1,420 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createDiscussionService } from '../plugin/core/discussion-service.ts';
import { buildDiscussionClosedMessage, buildDiscussionIdleReminderMessage, buildDiscussionOriginCallbackMessage } from '../plugin/core/discussion-messages.ts';
function makeLogger() {
return {
info: (_msg: string) => {},
warn: (_msg: string) => {},
};
}
function makeApi() {
return {
logger: makeLogger(),
};
}
function makeWorkspace(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dirigent-discussion-test-'));
}
test('initDiscussion stores metadata and getDiscussion retrieves it by channel id', async () => {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: makeWorkspace(),
forceNoReplyForSession: () => {},
});
const metadata = await service.initDiscussion({
discussionChannelId: 'discussion-init-1',
originChannelId: 'origin-1',
initiatorAgentId: 'agent-alpha',
initiatorSessionId: 'session-alpha',
discussGuide: 'Settle the callback contract.',
});
assert.equal(metadata.mode, 'discussion');
assert.equal(metadata.status, 'active');
assert.equal(metadata.originChannelId, 'origin-1');
const stored = service.getDiscussion('discussion-init-1');
assert.ok(stored);
assert.equal(stored?.discussionChannelId, 'discussion-init-1');
assert.equal(stored?.initiatorAgentId, 'agent-alpha');
assert.equal(stored?.initiatorSessionId, 'session-alpha');
});
test('handleCallback closes an active discussion and records the resolved summary path', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('plans', 'summary.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, '# summary\n');
const forcedSessions: string[] = [];
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: (sessionKey) => forcedSessions.push(sessionKey),
getDiscussionSessionKeys: () => ['session-beta-helper'],
});
await service.initDiscussion({
discussionChannelId: 'discussion-close-1',
originChannelId: 'origin-2',
initiatorAgentId: 'agent-beta',
initiatorSessionId: 'session-beta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Write the wrap-up.',
});
const result = await service.handleCallback({
channelId: 'discussion-close-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-beta',
callerSessionKey: 'session-beta',
});
assert.equal(result.ok, true);
assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath));
assert.equal(result.discussion.status, 'closed');
assert.equal(result.discussion.summaryPath, fs.realpathSync.native(summaryAbsPath));
assert.ok(result.discussion.completedAt);
assert.deepEqual(forcedSessions.sort(), ['session-beta', 'session-beta-helper']);
});
test('handleCallback rejects duplicate callback after the discussion is already closed', async () => {
const workspace = makeWorkspace();
const summaryRelPath = 'summary.md';
fs.writeFileSync(path.join(workspace, summaryRelPath), 'done\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-duplicate-1',
originChannelId: 'origin-3',
initiatorAgentId: 'agent-gamma',
initiatorSessionId: 'session-gamma',
discussGuide: 'One close only.',
});
await service.handleCallback({
channelId: 'discussion-duplicate-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-gamma',
callerSessionKey: 'session-gamma',
});
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-duplicate-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-gamma',
callerSessionKey: 'session-gamma',
}),
/discussion is already closed/,
);
});
test('handleCallback accepts a valid summaryPath inside the initiator workspace', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('notes', 'nested', 'summary.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, 'nested summary\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-path-ok-1',
originChannelId: 'origin-4',
initiatorAgentId: 'agent-delta',
initiatorSessionId: 'session-delta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Path validation.',
});
const result = await service.handleCallback({
channelId: 'discussion-path-ok-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-delta',
callerSessionKey: 'session-delta',
});
assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath));
});
test('handleCallback rejects a missing summary file', async () => {
const workspace = makeWorkspace();
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-missing-1',
originChannelId: 'origin-5',
initiatorAgentId: 'agent-epsilon',
initiatorSessionId: 'session-epsilon',
discussGuide: 'Expect missing file failure.',
});
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-missing-1',
summaryPath: 'missing.md',
callerAgentId: 'agent-epsilon',
callerSessionKey: 'session-epsilon',
}),
);
});
test('handleCallback uses the initiator workspace root instead of the process cwd', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('notes', 'initiator-only.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, 'initiator workspace file\n');
const differentDefaultWorkspace = makeWorkspace();
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: differentDefaultWorkspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-workspace-root-1',
originChannelId: 'origin-6a',
initiatorAgentId: 'agent-zeta-root',
initiatorSessionId: 'session-zeta-root',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Use initiator workspace root.',
});
const result = await service.handleCallback({
channelId: 'discussion-workspace-root-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-zeta-root',
callerSessionKey: 'session-zeta-root',
});
assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath));
});
test('handleCallback rejects .. path traversal outside the initiator workspace', async () => {
const workspace = makeWorkspace();
const outsideDir = makeWorkspace();
const outsideFile = path.join(outsideDir, 'outside.md');
fs.writeFileSync(outsideFile, 'outside\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-traversal-1',
originChannelId: 'origin-6',
initiatorAgentId: 'agent-zeta',
initiatorSessionId: 'session-zeta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Reject traversal.',
});
const traversalPath = path.relative(workspace, outsideFile);
assert.match(traversalPath, /^\.\./);
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-traversal-1',
summaryPath: traversalPath,
callerAgentId: 'agent-zeta',
callerSessionKey: 'session-zeta',
}),
/summaryPath must stay inside the initiator workspace/,
);
});
test('handleCallback rejects an absolute path outside the initiator workspace', async () => {
const workspace = makeWorkspace();
const outsideDir = makeWorkspace();
const outsideFile = path.join(outsideDir, 'absolute-outside.md');
fs.writeFileSync(outsideFile, 'absolute outside\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-absolute-1',
originChannelId: 'origin-7',
initiatorAgentId: 'agent-eta',
initiatorSessionId: 'session-eta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Reject absolute outside path.',
});
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-absolute-1',
summaryPath: outsideFile,
callerAgentId: 'agent-eta',
callerSessionKey: 'session-eta',
}),
/summaryPath must stay inside the initiator workspace/,
);
});
test('maybeSendIdleReminder sends exactly one idle reminder for an active discussion', async () => {
const workspace = makeWorkspace();
const fetchCalls: Array<{ url: string; body: any }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
fetchCalls.push({ url: String(url), body });
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
}) as typeof fetch;
try {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
moderatorBotToken: 'bot-token',
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-idle-1',
originChannelId: 'origin-idle-1',
initiatorAgentId: 'agent-idle',
initiatorSessionId: 'session-idle',
discussGuide: 'Only send one reminder.',
});
await service.maybeSendIdleReminder('discussion-idle-1');
await service.maybeSendIdleReminder('discussion-idle-1');
assert.equal(fetchCalls.length, 2);
assert.equal(fetchCalls[1]?.url, 'https://discord.com/api/v10/channels/discussion-idle-1/messages');
assert.equal(fetchCalls[1]?.body?.content, buildDiscussionIdleReminderMessage());
} finally {
globalThis.fetch = originalFetch;
}
});
test('handleCallback notifies the origin channel with the resolved summary path', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('plans', 'discussion-summary.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, '# done\n');
const fetchCalls: Array<{ url: string; body: any }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
fetchCalls.push({ url: String(url), body });
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
}) as typeof fetch;
try {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
moderatorBotToken: 'bot-token',
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-origin-1',
originChannelId: 'origin-8',
initiatorAgentId: 'agent-theta',
initiatorSessionId: 'session-theta',
discussGuide: 'Notify the origin channel.',
});
await service.handleCallback({
channelId: 'discussion-origin-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-theta',
callerSessionKey: 'session-theta',
});
assert.equal(fetchCalls.length, 2);
assert.equal(fetchCalls[1]?.url, 'https://discord.com/api/v10/channels/origin-8/messages');
assert.equal(
fetchCalls[1]?.body?.content,
buildDiscussionOriginCallbackMessage(fs.realpathSync.native(summaryAbsPath), 'discussion-origin-1'),
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('maybeReplyClosedChannel sends the archive-only closed message for later channel activity', async () => {
const workspace = makeWorkspace();
const summaryRelPath = 'summary.md';
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.writeFileSync(summaryAbsPath, 'closed\n');
const fetchCalls: Array<{ url: string; body: any }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
fetchCalls.push({ url: String(url), body });
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
}) as typeof fetch;
try {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
moderatorBotToken: 'bot-token',
moderatorUserId: 'moderator-user',
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-closed-1',
originChannelId: 'origin-9',
initiatorAgentId: 'agent-iota',
initiatorSessionId: 'session-iota',
discussGuide: 'Close and archive.',
});
await service.handleCallback({
channelId: 'discussion-closed-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-iota',
callerSessionKey: 'session-iota',
});
const handled = await service.maybeReplyClosedChannel('discussion-closed-1', 'human-user');
assert.equal(handled, true);
assert.equal(fetchCalls.length, 3);
assert.equal(fetchCalls[2]?.url, 'https://discord.com/api/v10/channels/discussion-closed-1/messages');
assert.equal(fetchCalls[2]?.body?.content, buildDiscussionClosedMessage());
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,141 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode, setChannelShuffling, getChannelShuffling } from "../plugin/core/channel-modes.ts";
import { initTurnOrder, checkTurn, getTurnDebugInfo, onNewMessage, resetTurn, setWaitingForHuman, isWaitingForHuman } from "../plugin/turn-manager.ts";
describe("Mode Compatibility Tests", () => {
const channelId = "test-channel";
beforeEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId); // Ensure clean state
});
afterEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId);
});
describe("multi-message mode with waiting-for-human", () => {
it("should prioritize multi-message mode over waiting-for-human", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Set up waiting for human state
setWaitingForHuman(channelId);
assert.strictEqual(isWaitingForHuman(channelId), true);
// Enter multi-message mode (should take precedence in before-model-resolve)
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
assert.strictEqual(isWaitingForHuman(channelId), true); // Both states exist but multi-message mode takes priority in hook
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
assert.strictEqual(isWaitingForHuman(channelId), true); // Waiting for human state still exists
});
});
describe("shuffle mode with dormant state", () => {
it("should maintain shuffle setting when dormant", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Reset to dormant
resetTurn(channelId);
const dormantState = getTurnDebugInfo(channelId);
assert.strictEqual(dormantState.dormant, true);
assert.strictEqual(getChannelShuffling(channelId), true); // Shuffling setting should persist
// Reactivate
onNewMessage(channelId, "human-user", true);
const activeState = getTurnDebugInfo(channelId);
assert.strictEqual(activeState.dormant, false);
assert.strictEqual(getChannelShuffling(channelId), true); // Setting should still be there
});
});
describe("shuffle mode with mention override", () => {
it("should handle shuffle mode during mention override", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
// In real implementation, mention override would be set via setMentionOverride function
// This test ensures the settings coexist properly
const state = getTurnDebugInfo(channelId);
assert.ok(state.hasTurnState);
assert.strictEqual(getChannelShuffling(channelId), true);
});
});
describe("multi-message mode with dormant state", () => {
it("should exit multi-message mode properly from dormant state", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Reset to dormant
resetTurn(channelId);
const dormantState = getTurnDebugInfo(channelId);
assert.strictEqual(dormantState.dormant, true);
// Enter multi-message mode while dormant
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
// Should still be dormant
const stateAfterExit = getTurnDebugInfo(channelId);
assert.strictEqual(stateAfterExit.dormant, true);
});
});
describe("complete workflow with all modes", () => {
it("should handle transitions between all modes", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Start with shuffling enabled
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Enter multi-message mode
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
assert.strictEqual(getChannelShuffling(channelId), true);
// Set waiting for human
setWaitingForHuman(channelId);
assert.strictEqual(isWaitingForHuman(channelId), true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Reactivate with human message
onNewMessage(channelId, "human-user", true);
const activeState = getTurnDebugInfo(channelId);
assert.strictEqual(activeState.dormant, false);
assert.strictEqual(isWaitingForHuman(channelId), false);
assert.strictEqual(getChannelShuffling(channelId), true);
// Test that agents can speak in normal mode with shuffling enabled
const turnResult = checkTurn(channelId, "agent-a");
// This would depend on current turn state, but the important thing is no errors occurred
assert.ok(typeof turnResult === "object");
});
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode } from "../plugin/core/channel-modes.ts";
import { initTurnOrder, checkTurn, getTurnDebugInfo, onNewMessage, resetTurn } from "../plugin/turn-manager.ts";
describe("Multi-Message Mode Tests", () => {
const channelId = "test-channel";
beforeEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId); // Ensure clean state
});
afterEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId);
});
describe("multi-message mode state management", () => {
it("should enter multi-message mode", () => {
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
});
it("should exit multi-message mode", () => {
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
});
it("should start in normal mode by default", () => {
assert.strictEqual(isMultiMessageMode(channelId), false);
});
});
describe("compatibility with waiting-for-human", () => {
it("should properly handle multi-message mode with human messages", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enter multi-message mode
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
// Simulate human message in multi-message mode
onNewMessage(channelId, "human-user", true);
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
// Should be able to proceed normally
onNewMessage(channelId, "human-user", true);
const turnResult = checkTurn(channelId, "agent-a");
assert.ok(turnResult);
});
});
describe("compatibility with mention override", () => {
it("should handle multi-message mode with mention override", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Enter multi-message mode
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
// Even with mention override conceptually, multi-message mode should take precedence
// In real usage, mention overrides happen in message-received hook before multi-message mode logic
const turnResult = checkTurn(channelId, "agent-a");
assert.ok(typeof turnResult === "object");
// The actual behavior depends on the before-model-resolve hook which forces no-reply in multi-message mode
// Exit multi-message mode to resume normal operation
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
});
});
describe("multi-message mode interaction with turn management", () => {
it("should pause turn management in multi-message mode", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Initially, turn should work normally
const normalTurnResult = checkTurn(channelId, "agent-a");
assert.ok(normalTurnResult);
// Enter multi-message mode
enterMultiMessageMode(channelId);
// In multi-message mode, agents should be blocked (this is handled in before-model-resolve hook)
// But the turn state itself continues to exist
const stateInMultiMessage = getTurnDebugInfo(channelId);
assert.ok(stateInMultiMessage.hasTurnState);
// Exit multi-message mode
exitMultiMessageMode(channelId);
const stateAfterExit = getTurnDebugInfo(channelId);
assert.ok(stateAfterExit.hasTurnState);
});
});
});

215
test/register-tools.test.ts Normal file
View File

@@ -0,0 +1,215 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerDirigentTools } from '../plugin/tools/register-tools.ts';
type RegisteredTool = {
name: string;
parameters?: Record<string, unknown>;
handler: (params: Record<string, unknown>, ctx?: Record<string, unknown>) => Promise<any>;
};
function makeApi() {
const tools = new Map<string, RegisteredTool>();
return {
config: {
channels: {
discord: {
accounts: {
bot: { token: 'discord-bot-token' },
},
},
},
},
logger: {
info: (_msg: string) => {},
warn: (_msg: string) => {},
},
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
tools,
};
}
function pickDefined(obj: Record<string, unknown>) {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
}
test('plain private channel create works unchanged without discussion params', async () => {
const api = makeApi();
let initDiscussionCalls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit) => {
return new Response(JSON.stringify({ id: 'created-channel-1', name: 'plain-room' }), { status: 200 });
}) as typeof fetch;
try {
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion() {
initDiscussionCalls += 1;
return {};
},
async handleCallback() {
return { ok: true };
},
},
});
const tool = api.tools.get('dirigent_discord_control');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({
action: 'channel-private-create',
guildId: 'guild-1',
name: 'plain-room',
allowedUserIds: ['user-1'],
}, {
agentId: 'agent-a',
sessionKey: 'session-a',
});
assert.equal(result.isError, undefined);
assert.match(result.content[0].text, /"discussionMode": false/);
assert.equal(initDiscussionCalls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test('private channel create rejects callbackChannelId without discussGuide', async () => {
const api = makeApi();
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion() {
return {};
},
async handleCallback() {
return { ok: true };
},
},
});
const tool = api.tools.get('dirigent_discord_control');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({
action: 'channel-private-create',
guildId: 'guild-1',
name: 'discussion-room',
callbackChannelId: 'origin-1',
}, {
agentId: 'agent-a',
sessionKey: 'session-a',
});
assert.equal(result.isError, true);
assert.equal(result.content[0].text, 'discussGuide is required when callbackChannelId is provided');
});
test('discussion-mode channel create initializes discussion metadata', async () => {
const api = makeApi();
const initCalls: Array<Record<string, unknown>> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit) => {
return new Response(JSON.stringify({ id: 'discussion-channel-1', name: 'discussion-room' }), { status: 200 });
}) as typeof fetch;
try {
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion(params) {
initCalls.push(params as Record<string, unknown>);
return {};
},
async handleCallback() {
return { ok: true };
},
},
});
const tool = api.tools.get('dirigent_discord_control');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({
action: 'channel-private-create',
guildId: 'guild-1',
name: 'discussion-room',
callbackChannelId: 'origin-1',
discussGuide: 'Decide the callback contract.',
}, {
agentId: 'agent-a',
sessionKey: 'session-a',
workspaceRoot: '/workspace/agent-a',
});
assert.equal(result.isError, undefined);
assert.match(result.content[0].text, /"discussionMode": true/);
assert.equal(initCalls.length, 1);
assert.deepEqual(initCalls[0], {
discussionChannelId: 'discussion-channel-1',
originChannelId: 'origin-1',
initiatorAgentId: 'agent-a',
initiatorSessionId: 'session-a',
initiatorWorkspaceRoot: '/workspace/agent-a',
discussGuide: 'Decide the callback contract.',
});
} finally {
globalThis.fetch = originalFetch;
}
});
test('discuss-callback registers and forwards channel/session/agent context', async () => {
const api = makeApi();
const callbackCalls: Array<Record<string, unknown>> = [];
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion() {
return {};
},
async handleCallback(params) {
callbackCalls.push(params as Record<string, unknown>);
return { ok: true, summaryPath: '/workspace/summary.md' };
},
},
});
const tool = api.tools.get('discuss-callback');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({ summaryPath: 'plans/summary.md' }, {
channelId: 'discussion-1',
agentId: 'agent-a',
sessionKey: 'session-a',
});
assert.equal(result.isError, undefined);
assert.deepEqual(callbackCalls, [{
channelId: 'discussion-1',
summaryPath: 'plans/summary.md',
callerAgentId: 'agent-a',
callerSessionKey: 'session-a',
}]);
assert.match(result.content[0].text, /"summaryPath": "\/workspace\/summary.md"/);
});

183
test/shuffle-mode.test.ts Normal file
View File

@@ -0,0 +1,183 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { initTurnOrder, checkTurn, onSpeakerDone, advanceTurn, resetTurn, getTurnDebugInfo, onNewMessage } from "../plugin/turn-manager.ts";
import { setChannelShuffling, getChannelShuffling } from "../plugin/core/channel-modes.ts";
describe("Shuffle Mode Tests", () => {
const channelId = "test-channel";
beforeEach(() => {
resetTurn(channelId);
});
afterEach(() => {
resetTurn(channelId);
});
describe("/turn-shuffling command functionality", () => {
it("should enable shuffle mode", () => {
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
});
it("should disable shuffle mode", () => {
setChannelShuffling(channelId, false);
assert.strictEqual(getChannelShuffling(channelId), false);
});
it("should start with shuffle mode disabled by default", () => {
assert.strictEqual(getChannelShuffling(channelId), false);
});
});
describe("shuffle mode behavior", () => {
it("should not reshuffle when shuffling is disabled", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Disable shuffling (should be default anyway)
setChannelShuffling(channelId, false);
// Simulate a full cycle without reshuffling
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
const firstSpeaker = initialOrder[0];
// Have first speaker finish their turn
onSpeakerDone(channelId, firstSpeaker, false);
// Check that the order didn't change (since shuffling is disabled)
const orderAfterOneTurn = getTurnDebugInfo(channelId).turnOrder as string[];
// The order should remain the same when shuffling is disabled
assert.deepStrictEqual(initialOrder, orderAfterOneTurn);
});
it("should reshuffle when shuffling is enabled after a full cycle", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Get initial order
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
const firstSpeaker = initialOrder[0];
// Complete a full cycle by having each agent speak once
for (const agent of initialOrder) {
const turnResult = checkTurn(channelId, agent);
if (turnResult.allowed) {
onSpeakerDone(channelId, agent, false);
}
}
// After a full cycle, the order should have potentially changed if shuffling is enabled
const orderAfterCycle = getTurnDebugInfo(channelId).turnOrder as string[];
// The order might be different due to shuffling, or it might be the same by chance
// But the important thing is that the shuffling mechanism was called
assert(Array.isArray(orderAfterCycle));
assert.strictEqual(orderAfterCycle.length, 3);
});
it("should ensure last speaker doesn't become first in next round when shuffling", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Get initial order
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
assert.strictEqual(initialOrder.length, 2);
// Have first agent speak
const firstSpeaker = initialOrder[0];
const secondSpeaker = initialOrder[1];
// Have first speaker finish
onSpeakerDone(channelId, firstSpeaker, false);
// Have second speaker finish (completing a full cycle)
onSpeakerDone(channelId, secondSpeaker, false);
// The turn order should be reshuffled but with constraints
const orderAfterReshuffle = getTurnDebugInfo(channelId).turnOrder as string[];
// Verify the order is still valid
assert.strictEqual(orderAfterReshuffle.length, 2);
assert.ok(orderAfterReshuffle.includes("agent-a"));
assert.ok(orderAfterReshuffle.includes("agent-b"));
});
it("should handle single agent scenario gracefully", () => {
const botIds = ["agent-a"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Dormant channels need a new message to activate the first speaker.
onNewMessage(channelId, "human-user", true);
const turnResult = checkTurn(channelId, "agent-a");
assert.strictEqual(turnResult.allowed, true);
onSpeakerDone(channelId, "agent-a", false);
const stateAfter = getTurnDebugInfo(channelId);
assert.deepStrictEqual(stateAfter.turnOrder, ["agent-a"]);
assert.strictEqual(stateAfter.currentSpeaker, "agent-a");
});
it("should handle double agent scenario properly", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Activate the channel before exercising the round transition.
onNewMessage(channelId, "human-user", true);
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
const firstSpeaker = initialOrder[0];
const secondSpeaker = initialOrder[1];
// Have first speaker finish
onSpeakerDone(channelId, firstSpeaker, false);
// Have second speaker finish (this completes a cycle)
onSpeakerDone(channelId, secondSpeaker, false);
// The order might be reshuffled, but it should be valid
const newState = getTurnDebugInfo(channelId);
const newOrder = newState.turnOrder as string[];
assert.strictEqual(newOrder.length, 2);
assert.ok(newOrder.includes("agent-a"));
assert.ok(newOrder.includes("agent-b"));
// After a full round, the next current speaker should already be set.
assert.ok(["agent-a", "agent-b"].includes(newState.currentSpeaker as string));
});
});
describe("compatibility with other modes", () => {
it("should work with dormant state", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
setChannelShuffling(channelId, true);
// Start with dormant state
resetTurn(channelId);
const dormantState = getTurnDebugInfo(channelId);
assert.strictEqual(dormantState.dormant, true);
// Activate with new message
onNewMessage(channelId, "agent-a", false);
const activeState = getTurnDebugInfo(channelId);
assert.strictEqual(activeState.dormant, false);
assert.ok(activeState.currentSpeaker);
});
});
});