Compare commits
134 Commits
b1e546ada6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca7b47e683 | |||
| 4cc787ad90 | |||
| 41a49e10b3 | |||
| 32dc9a4233 | |||
| d8ac9ee0f9 | |||
| 9e61af4a16 | |||
| 27c968fa69 | |||
| c40b756bec | |||
| 74e6d61d4d | |||
| b9cbb7e895 | |||
| b5196e972c | |||
| dea345698b | |||
| d8dcd59715 | |||
| fd33290266 | |||
| 9aa85fdbc5 | |||
| c9bed19689 | |||
| 895cfe3bab | |||
| 7bccb660df | |||
| 29f1f01219 | |||
| 15f7d211d7 | |||
| 0f38e34bec | |||
| b11c15d8c8 | |||
| 4e0a24333e | |||
| b40838f259 | |||
| 16daab666b | |||
| b7b405f416 | |||
| 7670d41785 | |||
| d44204fabf | |||
| 8073c33f2c | |||
| bfbe40b3c6 | |||
| 684f8f9ee7 | |||
| d9bb5c2e21 | |||
| 2c870ea2c5 | |||
| b9933d899a | |||
| 62cd2f20cf | |||
| 9fa71f37bf | |||
| 81687e548a | |||
| 0a99abc7e3 | |||
| 0a76dae376 | |||
| dbd0fc68c0 | |||
| 4dde4f6efe | |||
| 3b5ca21f40 | |||
| c2fe859ea5 | |||
| 4905f37c1a | |||
| e6b20e9d52 | |||
| 13d5b95081 | |||
| 08f42bfd92 | |||
| bddc7798d8 | |||
| 1f846fa7aa | |||
| 68c13d9eef | |||
| e5158cf039 | |||
| 7e0f187f34 | |||
| a995b7d6bf | |||
| 05702941c1 | |||
| ffaf7a89e5 | |||
| 3cde4a7795 | |||
| d497631b99 | |||
| bb10d6913e | |||
| a2781139b0 | |||
| 4d50826f2a | |||
| 7640e80373 | |||
| 6124ceec7a | |||
| e6b445fb97 | |||
| 0806edb7d9 | |||
| 12b0f4c6cc | |||
| 307235e207 | |||
| 121786e6e3 | |||
| 3e35da83ca | |||
| 63009f3925 | |||
| ea501ccef8 | |||
| 0c06aeb36c | |||
| f9ac401877 | |||
| 05b902c0b2 | |||
| e258e99ed2 | |||
| d021b0c06c | |||
| 3a8b85eb7b | |||
| b63c1dfe94 | |||
| 5c4340d5a9 | |||
| c15ea0d471 | |||
| 96a1f18d1b | |||
| b7381395fe | |||
| e8de773ee4 | |||
| fc2cdf0eee | |||
| 211ad9246f | |||
| e4454bfc1a | |||
| 7b93db3ed9 | |||
| 0729e83b38 | |||
| bbd18cd90c | |||
| d55eac8dc1 | |||
| ac65d7e036 | |||
| 5c9c979f26 | |||
| 3d91d7ed95 | |||
| 92799176bf | |||
| cd0ce6a910 | |||
| a177150554 | |||
| b79cc1eb84 | |||
| ed5ffd6c53 | |||
| be8fe835d0 | |||
| 91cd402627 | |||
| 84b0363d59 | |||
| 83c03b6934 | |||
| 6423dbcdfa | |||
| d378e27be9 | |||
| 2ea4f59a1e | |||
| 8e26744162 | |||
| cb58e3d951 | |||
| fd1bf449a4 | |||
| af33d747d9 | |||
| 2afb982c04 | |||
| a18168749b | |||
| 329f6ed490 | |||
| cf9be60145 | |||
| 6d17e6a911 | |||
| aeecb534b7 | |||
| d90083317b | |||
| a4bc9990db | |||
| 435a7712b8 | |||
| 9e754d32cf | |||
| 692111eeda | |||
| 80439b0912 | |||
| 7fdc7952b7 | |||
| f03d32e15f | |||
| 6a81f75fd0 | |||
| 81758b9a54 | |||
| 86fdc63802 | |||
| 385990ab90 | |||
| a6f2be44b7 | |||
| fb50b62509 | |||
|
|
8c8757e9a7 | ||
|
|
54ff78cffe | ||
|
|
476308d0df | ||
|
|
1d8881577d | ||
| 52a613fdcc | |||
| 882f62eae7 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,20 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-mvp
|
||||
|
||||
- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
|
||||
- Added optional bearer auth (`AUTH_TOKEN`)
|
||||
- Added WhisperGate plugin with deterministic rule gate
|
||||
- Added discord-specific 🔚 prompt injection for bypass/end-symbol paths
|
||||
- Added containerization (`Dockerfile`, `docker-compose.yml`)
|
||||
- Added helper scripts for smoke/dev lifecycle and rule validation
|
||||
- Added no-touch config rendering and integration docs
|
||||
- Added installer script with rollback (`scripts/install-whispergate-openclaw.sh`)
|
||||
- supports `--install` / `--uninstall`
|
||||
- uninstall restores all recorded changes
|
||||
- writes install/uninstall records under `~/.openclaw/whispergate-install-records/`
|
||||
- Added discord-control-api with:
|
||||
- `channel-private-create` (create private channel for allowlist)
|
||||
- `channel-private-update` (update allowlist/overwrites for existing channel)
|
||||
- `member-list` (guild members list with pagination + optional field projection)
|
||||
- guardrails: action mode validation, id-list limits, response-size limit
|
||||
393
DESIGN.md
Normal file
393
DESIGN.md
Normal 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.
|
||||
25
Makefile
25
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up smoke-discord-control
|
||||
.PHONY: check check-rules check-files smoke install
|
||||
|
||||
check:
|
||||
cd plugin && npm run check
|
||||
@@ -6,26 +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
|
||||
|
||||
discord-control-up:
|
||||
cd discord-control-api && node server.mjs
|
||||
|
||||
smoke-discord-control:
|
||||
./scripts/smoke-discord-control.sh
|
||||
install:
|
||||
node scripts/install.mjs --install
|
||||
|
||||
143
README.md
143
README.md
@@ -1,60 +1,117 @@
|
||||
# WhisperGate
|
||||
# Dirigent
|
||||
|
||||
Rule-based no-reply gate for OpenClaw.
|
||||
Turn-management and moderation plugin for OpenClaw (Discord).
|
||||
|
||||
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
|
||||
|
||||
## What it does
|
||||
|
||||
WhisperGate adds a deterministic gate **before model selection**:
|
||||
Dirigent adds deterministic routing and **turn-based speaking** for multi-agent Discord channels:
|
||||
|
||||
1. If message is not from Discord → skip gate
|
||||
2. If sender is in bypass user list → skip gate
|
||||
3. If message ends with configured end-symbol → skip gate
|
||||
4. Otherwise switch this turn to a 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`
|
||||
|
||||
The no-reply provider returns `NO_REPLY` for any input.
|
||||
- **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
|
||||
|
||||
- **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)
|
||||
|
||||
- **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
|
||||
|
||||
- **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 (before_model_resolve hook)
|
||||
- `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY`
|
||||
- `discord-control-api/` — Discord 管理扩展 API(私密频道 + 成员列表)
|
||||
- `docs/` — rollout, integration, run-mode notes
|
||||
- `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
|
||||
|
||||
## Quick start (no Docker)
|
||||
|
||||
```bash
|
||||
cd no-reply-api
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
Then render config snippet:
|
||||
|
||||
```bash
|
||||
node scripts/render-openclaw-config.mjs
|
||||
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
|
||||
```
|
||||
|
||||
See `docs/RUN_MODES.md` for Docker mode.
|
||||
|
||||
Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。
|
||||
|
||||
---
|
||||
|
||||
## Development plan (incremental commits)
|
||||
## Installation
|
||||
|
||||
- [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
|
||||
node scripts/install.mjs --install
|
||||
```
|
||||
|
||||
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
|
||||
make smoke
|
||||
# or:
|
||||
./scripts/smoke-no-reply-api.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin config
|
||||
|
||||
Key options (in `openclaw.json` under `plugins.entries.dirigent.config`):
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Dev commands
|
||||
|
||||
```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
281
TEST-PLAN.md
Normal 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.9,gateway 正常启动 | 日志无报错 |
|
||||
| 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 A(Chat)并通过 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=discussion,concluded=false,initiatorAgentId=main,callbackChannelId=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=work(locked)
|
||||
- 无轮次管理,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 dedup(BMR 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 模型特性)。*
|
||||
51
achieve/CHANGELOG.md
Normal file
51
achieve/CHANGELOG.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- **Split `dirigent_tools` into 6 individual tools**:
|
||||
- Discord: `dirigent_discord_channel_create`, `dirigent_discord_channel_update`, `dirigent_discord_member_list`
|
||||
- Policy: `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete`
|
||||
- Turn management tools removed (internal plugin logic only; use `/dirigent` slash commands)
|
||||
- **Human @mention override**: When a `humanList` user @mentions specific agents:
|
||||
- Temporarily overrides the speaking order to only mentioned agents
|
||||
- Agents cycle in their original turn-order position
|
||||
- After all mentioned agents have spoken, original order restores and state goes dormant
|
||||
- Handles edge cases: all NO_REPLY, reset, non-agent mentions
|
||||
- **Wait for human reply**: New `waitIdentifier` (default: `👤`):
|
||||
- Agent ends with `👤` to signal it needs a human response
|
||||
- All agents go silent (routed to no-reply model) until a human speaks
|
||||
- Prompt injection warns agents to use sparingly (only when human is actively participating)
|
||||
- **Installer improvements**:
|
||||
- Dynamic OpenClaw dir resolution (`$OPENCLAW_DIR` → `openclaw` CLI → `~/.openclaw`)
|
||||
- Plugin installed to `$(openclaw_dir)/plugins/dirigent`
|
||||
- New `--update` mode: pulls latest from git `latest` branch and reinstalls
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- **Project renamed from WhisperGate to Dirigent**
|
||||
- All plugin ids, tool names, config keys, file paths, docs updated
|
||||
- Legacy `whispergate` config key still supported as fallback
|
||||
- **Identity prompt enhancements**: Discord userId now included in agent identity injection
|
||||
- **Scheduling identifier**: Added configurable `schedulingIdentifier` (default: `➡️`)
|
||||
- Moderator handoff now sends `<@USER_ID>➡️` instead of semantic messages
|
||||
- Agent prompt explains the identifier is meaningless — check chat history and decide
|
||||
- **All prompts in English**: End-marker instructions, group chat rules, slash command help text
|
||||
|
||||
## 0.1.0-mvp
|
||||
|
||||
- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
|
||||
- Added optional bearer auth (`AUTH_TOKEN`)
|
||||
- Added plugin with deterministic rule gate
|
||||
- Added discord-specific 🔚 prompt injection for bypass/end-symbol paths
|
||||
- Added containerization (`Dockerfile`, `docker-compose.yml`)
|
||||
- Added helper scripts for smoke/dev lifecycle and rule validation
|
||||
- Added no-touch config rendering and integration docs
|
||||
- Added installer script with rollback (`scripts/install-dirigent-openclaw.sh`)
|
||||
- supports `--install` / `--uninstall`
|
||||
- uninstall restores all recorded changes
|
||||
- writes install/uninstall records under `~/.openclaw/dirigent-install-records/`
|
||||
- Added discord-control-api with: (historical; later migrated into plugin internal Discord REST control)
|
||||
- `channel-private-create` (create private channel for allowlist)
|
||||
- `channel-private-update` (update allowlist/overwrites for existing channel)
|
||||
- `member-list` (guild members list with pagination + optional field projection)
|
||||
- guardrails: action mode validation, id-list limits, response-size limit
|
||||
158
achieve/FEAT.md
Normal file
158
achieve/FEAT.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Dirigent — Feature List
|
||||
|
||||
All implemented features across all versions.
|
||||
|
||||
---
|
||||
|
||||
## Core: Rule-Based No-Reply Gate
|
||||
- Deterministic logic in `before_model_resolve` hook decides whether to route to no-reply model
|
||||
- **human-list** mode: humanList senders bypass gate; others need end symbol to pass
|
||||
- **agent-list** mode: agentList senders need end symbol; others bypass
|
||||
- Non-Discord messages skip entirely
|
||||
- DM sessions (no metadata) always bypass
|
||||
- Per-channel policy overrides via JSON file (runtime-updateable)
|
||||
|
||||
## Core: End-Symbol Enforcement
|
||||
- Injects prompt instruction: "Your response MUST end with 🔚"
|
||||
- Gateway keywords (NO_REPLY, HEARTBEAT_OK) exempt from end symbol
|
||||
- Group chats get additional rule: "If not relevant, reply NO_REPLY"
|
||||
- End symbols configurable per-channel via policy
|
||||
|
||||
## Core: No-Reply API
|
||||
- OpenAI-compatible API (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
|
||||
- Always returns `NO_REPLY` — used as the override model target
|
||||
- Optional bearer auth (`AUTH_TOKEN`)
|
||||
- Auto-started/stopped with gateway lifecycle
|
||||
|
||||
## Turn-Based Speaking (Multi-Bot)
|
||||
- Only the current speaker is allowed to respond; others forced to no-reply model
|
||||
- Turn order auto-populated from bot accounts seen in each channel
|
||||
- Turn advances on end-symbol (successful speech) or NO_REPLY
|
||||
- Successful speech resets NO_REPLY cycle counter
|
||||
- If all bots NO_REPLY in a cycle → channel goes **dormant**
|
||||
- Dormant reactivation: human message → first in order; bot message → next after sender
|
||||
- Turn timeout: auto-advance after 60s of inactivity
|
||||
- Manual control: `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset`
|
||||
|
||||
## Human @Mention Override *(v0.3.0)*
|
||||
- When a `humanList` user @mentions specific agents (`<@USER_ID>`):
|
||||
- Extracts mentioned Discord user IDs from message content
|
||||
- Maps userIds → accountIds via reverse bot token lookup
|
||||
- Filters to agents in the current turn order
|
||||
- Orders by their position in the current turn order
|
||||
- Temporarily replaces speaking order with only those agents
|
||||
- Cycle: a → b → c → (back to a) → restore original order, go dormant
|
||||
- Edge cases:
|
||||
- All override agents NO_REPLY → restore + dormant
|
||||
- `resetTurn` clears any active override
|
||||
- Human message without mentions → restores override, normal flow
|
||||
- Mentioned users not in turn order → ignored
|
||||
|
||||
## Agent Identity Injection
|
||||
- Group chat prompts include: agent name, Discord accountId, Discord userId
|
||||
- userId resolved from bot token (base64 first segment)
|
||||
|
||||
## Wait for Human Reply *(v0.3.0)*
|
||||
- Configurable wait identifier (default: `👤`)
|
||||
- Agent ends message with `👤` instead of `🔚` when it needs a human to reply
|
||||
- Triggers "waiting for human" state:
|
||||
- All agents routed to no-reply model
|
||||
- Turn manager goes dormant
|
||||
- State clears automatically when a human sends a message
|
||||
- Prompt injection tells agents:
|
||||
- Use wait identifier only when confident the human is actively participating
|
||||
- Do NOT use it speculatively
|
||||
- Works with mention override: wait identifier during override also triggers waiting state
|
||||
|
||||
## Scheduling Identifier
|
||||
- Configurable identifier (default: `➡️`) used for moderator handoff
|
||||
- Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic scheduling signal)
|
||||
- Agent prompt explains: identifier is meaningless — check chat history, decide whether to reply
|
||||
- If nothing to say → NO_REPLY
|
||||
|
||||
## Moderator Bot Presence
|
||||
- Maintains Discord Gateway WebSocket connection for moderator bot
|
||||
- Shows "online" status with "Moderating" activity
|
||||
- Handles reconnect, resume, heartbeat, invalid session recovery
|
||||
- Singleton guard prevents duplicate connections
|
||||
- Sends handoff messages to trigger next speaker's turn
|
||||
|
||||
## Individual Tools *(v0.3.0)*
|
||||
Six standalone tools (split from former monolithic `dirigent_tools`):
|
||||
|
||||
### Discord Control
|
||||
- **`dirigent_discord_channel_create`** — Create private Discord channel with user/role permissions
|
||||
- **`dirigent_discord_channel_update`** — Update permissions on existing private channel
|
||||
- **`dirigent_discord_member_list`** — List guild members with pagination and field projection
|
||||
|
||||
### Policy Management
|
||||
- **`dirigent_policy_get`** — Get all channel policies
|
||||
- **`dirigent_policy_set`** — Set/update a channel policy (listMode, humanList, agentList, endSymbols)
|
||||
- **`dirigent_policy_delete`** — Delete a channel policy
|
||||
|
||||
### Turn Management (internal only — not exposed as tools)
|
||||
Turn management is handled entirely by the plugin. Manual control via slash commands:
|
||||
- `/dirigent turn-status` — Show turn state
|
||||
- `/dirigent turn-advance` — Manually advance to next speaker
|
||||
- `/dirigent turn-reset` — Reset turn order (go dormant, clear overrides)
|
||||
|
||||
## Slash Command: `/dirigent`
|
||||
- `status` — Show all channel policies
|
||||
- `turn-status` — Show turn state for current channel
|
||||
- `turn-advance` — Manually advance turn
|
||||
- `turn-reset` — Reset turn order
|
||||
|
||||
## Project Rename (WhisperGate → Dirigent) *(v0.2.0)*
|
||||
- All plugin ids, tool names, config keys, file paths, docs updated
|
||||
- Legacy `whispergate` config key still supported as fallback
|
||||
|
||||
## Installer Script *(updated v0.3.0)*
|
||||
- `scripts/install-dirigent-openclaw.mjs`
|
||||
- `--install` / `--uninstall` / `--update` modes
|
||||
- **Dynamic OpenClaw dir resolution**: `$OPENCLAW_DIR` → `openclaw config get dataDir` → `~/.openclaw`
|
||||
- Builds dist and copies to `$(openclaw_dir)/plugins/dirigent`
|
||||
- `--update`: pulls latest from git `latest` branch, then reinstalls
|
||||
- Auto-reinstall (uninstall + install) if already installed
|
||||
- Backup before changes, rollback on failure
|
||||
- Records stored in `$(openclaw_dir)/dirigent-install-records/`
|
||||
|
||||
## Discord Control API (Sidecar)
|
||||
- Private channel create/update with permission overwrites
|
||||
- Member list with pagination + field projection
|
||||
- Guardrails: action validation, id-list limits, response-size limit
|
||||
- (Migrated) Discord control now runs in-plugin via direct Discord REST (no companion service)
|
||||
|
||||
---
|
||||
|
||||
## NEW_FEAT 合并记录(原 NEW_FEAT.md)
|
||||
|
||||
### 背景与目标
|
||||
- 解决 turn 初始化依赖被动观察(`recordChannelAccount`)导致 `currentSpeaker` 空值的问题。
|
||||
- 将 Discord control 从 sidecar 迁移到插件内模块。
|
||||
- 采用 channel 成员缓存(内存 + 本地持久化),避免轮询。
|
||||
|
||||
### 关键实现方向
|
||||
- 统一 channelId 解析链路,避免 `channel=discord` 错位。
|
||||
- `before_model_resolve / before_prompt_build` 与消息 hook 使用一致解析策略。
|
||||
- 清理未使用函数,降低排障噪音。
|
||||
- 模块化重构:`index.ts` 作为 wiring,逻辑拆入 `hooks/core/tools/policy/commands`。
|
||||
|
||||
### Channel 成员缓存
|
||||
- 缓存文件:`~/.openclaw/dirigent-channel-members.json`
|
||||
- 启动加载、运行时原子写盘。
|
||||
- 记录字段包含 `botAccountIds/updatedAt/source/guildId`。
|
||||
- 首次无缓存时允许 bootstrap 拉取,随后走本地缓存。
|
||||
|
||||
### Turn 初始化改造
|
||||
- `ensureTurnOrder(channelId)` 基于缓存中的 botAccountIds 初始化。
|
||||
- 不再仅依赖“已见账号”被动记录。
|
||||
- 提升新频道首条消息场景的稳定性。
|
||||
|
||||
### 权限计算(频道可见成员)
|
||||
- 通过 guild 成员 + roles + channel overwrites 计算 `VIEW_CHANNEL` 可见性。
|
||||
- 用于内部 turn bootstrap,不对外暴露为公共工具。
|
||||
|
||||
### 风险与注意
|
||||
- 权限位计算必须严格按 Discord 规则。
|
||||
- 缓存读写需原子化,防并发损坏。
|
||||
- 通过 `updatedAt/source/guildId` 提高可观测性与排障效率。
|
||||
86
achieve/TASKLIST.md
Normal file
86
achieve/TASKLIST.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Dirigent – Fixes & Improvements
|
||||
|
||||
> Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs).
|
||||
|
||||
## 1) Identity Prompt Enhancements ✅
|
||||
- Current prompt only includes agent-id + discord name.
|
||||
- **Add Discord userId** to identity injection.
|
||||
- **Done**: `buildAgentIdentity()` now resolves and includes Discord userId via `resolveDiscordUserId()`.
|
||||
|
||||
## 2) Scheduling Identifier (Default: ➡️) ✅
|
||||
- Add a **configurable scheduling identifier** (default: `➡️`).
|
||||
- Update agent prompt to explain:
|
||||
- The scheduling identifier itself is meaningless.
|
||||
- When receiving `<@USER_ID>` + scheduling identifier, the agent should check chat history and decide whether to reply.
|
||||
- If no reply needed, return `NO_REPLY`.
|
||||
- **Done**: Added `schedulingIdentifier` config field; `buildSchedulingIdentifierInstruction()` injected for group chats.
|
||||
|
||||
## 3) Moderator Handoff Message Format ✅
|
||||
- Moderator should **no longer send semantic messages** to activate agents.
|
||||
- Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`).
|
||||
- **Done**: Both `before_message_write` and `message_sent` handoff messages now use `<@userId>` + scheduling identifier format.
|
||||
|
||||
## 4) Prompt Language ✅
|
||||
- **All prompts must be in English** (including end-marker instructions and group-chat rules).
|
||||
- **Done**: `buildEndMarkerInstruction()` and `buildSchedulingIdentifierInstruction()` output English. Slash command help text in English.
|
||||
|
||||
## 5) Full Project Rename ✅
|
||||
- Project name changed to **Dirigent**.
|
||||
- Update **all strings** across repo:
|
||||
- plugin name/id → `dirigent`
|
||||
- tool name → `dirigent_tools`
|
||||
- slash command → `/dirigent`
|
||||
- docs, config, scripts, examples
|
||||
- any text mentions
|
||||
- dist output dir → `dist/dirigent`
|
||||
- docker service → `dirigent-no-reply-api`
|
||||
- config key fallback: still reads legacy `whispergate` entry if `dirigent` not found
|
||||
- **Done**: All files updated.
|
||||
|
||||
---
|
||||
|
||||
## 6) Split dirigent_tools into Individual Tools ✅
|
||||
- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions.
|
||||
- **After**: 6 individual tools (Discord tools prefixed `dirigent_discord_*`):
|
||||
- `dirigent_discord_channel_create` — Create private Discord channel
|
||||
- `dirigent_discord_channel_update` — Update channel permissions
|
||||
- `dirigent_discord_member_list` — List guild members
|
||||
- `dirigent_policy_get` — Get all channel policies
|
||||
- `dirigent_policy_set` — Set/update a channel policy
|
||||
- `dirigent_policy_delete` — Delete a channel policy
|
||||
- Turn management (status/advance/reset) NOT exposed as tools — purely internal plugin logic, accessible via `/dirigent` slash commands.
|
||||
- Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication.
|
||||
- **Done**: All tools registered individually with specific parameter schemas.
|
||||
|
||||
## 7) Human @Mention Override ✅
|
||||
- When a message from a `humanList` user contains `<@USER_ID>` mentions:
|
||||
- Extract mentioned Discord user IDs from the message content.
|
||||
- Map user IDs → accountIds via bot token decoding (reverse of `resolveDiscordUserId`).
|
||||
- Filter to agents in the current turn order.
|
||||
- Order by their position in the current turn order.
|
||||
- Temporarily replace the speaking order with only those agents.
|
||||
- After the cycle returns to the first agent, restore the original order and go dormant.
|
||||
- Edge cases handled:
|
||||
- All override agents NO_REPLY → restore original order, go dormant.
|
||||
- `resetTurn` clears any active override.
|
||||
- New human message without mentions → restores override before normal handling.
|
||||
- Mentioned users not in turn order → ignored, normal flow.
|
||||
- New functions in `turn-manager.ts`: `setMentionOverride()`, `hasMentionOverride()`.
|
||||
- New helpers in `index.ts`: `buildUserIdToAccountIdMap()`, `extractMentionedUserIds()`.
|
||||
- **Done**: Override logic integrated in `message_received` handler.
|
||||
|
||||
## 8) Wait for Human Reply ✅
|
||||
- Added configurable `waitIdentifier` (default: `👤`) to config and config schema.
|
||||
- Prompt injection in group chats: tells agents to end with `👤` instead of end symbol when they need a human response. Warns to use sparingly — only when human is actively participating.
|
||||
- Detection in `before_message_write` and `message_sent`: if last char matches wait identifier → `setWaitingForHuman(channelId)`.
|
||||
- Turn manager `waitingForHuman` state:
|
||||
- `checkTurn()` blocks all agents (`reason: "waiting_for_human"`) → routed to no-reply model.
|
||||
- `onNewMessage()` clears `waitingForHuman` on human message → normal flow resumes.
|
||||
- Non-human messages ignored while waiting.
|
||||
- `resetTurn()` also clears waiting state.
|
||||
- **Done**: Full lifecycle implemented across turn-manager, rules, and index.
|
||||
|
||||
---
|
||||
|
||||
## Open Items / Notes
|
||||
- User requested the previous README commit should have been pushed to `main` directly (was pushed to a branch). Address separately if needed.
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "whispergate-discord-control-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number(process.env.PORT || 8790);
|
||||
const authToken = process.env.AUTH_TOKEN || "";
|
||||
const requireAuthToken = String(process.env.REQUIRE_AUTH_TOKEN || "false").toLowerCase() === "true";
|
||||
const discordToken = process.env.DISCORD_BOT_TOKEN || "";
|
||||
const discordBase = "https://discord.com/api/v10";
|
||||
|
||||
const enabledActions = {
|
||||
channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false",
|
||||
channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false",
|
||||
memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false",
|
||||
};
|
||||
|
||||
const allowedGuildIds = new Set(
|
||||
String(process.env.ALLOWED_GUILD_IDS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const allowedCallerIds = new Set(
|
||||
String(process.env.ALLOWED_CALLER_IDS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const BIT_VIEW_CHANNEL = 1024n;
|
||||
const BIT_SEND_MESSAGES = 2048n;
|
||||
const BIT_READ_MESSAGE_HISTORY = 65536n;
|
||||
|
||||
const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20));
|
||||
const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000));
|
||||
const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200));
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function fail(status, code, message, details) {
|
||||
return { status, code, message, details };
|
||||
}
|
||||
|
||||
function readCallerId(req) {
|
||||
const v = req.headers["x-openclaw-caller-id"] || req.headers["x-caller-id"];
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
}
|
||||
|
||||
function ensureControlAuth(req) {
|
||||
if (requireAuthToken && !authToken) {
|
||||
throw fail(500, "auth_misconfigured", "REQUIRE_AUTH_TOKEN=true but AUTH_TOKEN is empty");
|
||||
}
|
||||
if (!authToken) return;
|
||||
const header = req.headers.authorization || "";
|
||||
if (header !== `Bearer ${authToken}`) {
|
||||
throw fail(401, "unauthorized", "invalid or missing bearer token");
|
||||
}
|
||||
|
||||
if (allowedCallerIds.size > 0) {
|
||||
const callerId = readCallerId(req);
|
||||
if (!callerId || !allowedCallerIds.has(callerId)) {
|
||||
throw fail(403, "caller_forbidden", "caller is not in ALLOWED_CALLER_IDS", { callerId: callerId || null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDiscordToken() {
|
||||
if (!discordToken) {
|
||||
throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN");
|
||||
}
|
||||
}
|
||||
|
||||
function ensureGuildAllowed(guildId) {
|
||||
if (allowedGuildIds.size === 0) return;
|
||||
if (!allowedGuildIds.has(guildId)) {
|
||||
throw fail(403, "guild_forbidden", "guild is not in ALLOWED_GUILD_IDS", { guildId });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureActionEnabled(action) {
|
||||
if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) {
|
||||
throw fail(403, "action_disabled", "channel-private-create is disabled");
|
||||
}
|
||||
if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) {
|
||||
throw fail(403, "action_disabled", "channel-private-update is disabled");
|
||||
}
|
||||
if (action === "member-list" && !enabledActions.memberList) {
|
||||
throw fail(403, "action_disabled", "member-list is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
async function discordRequest(path, init = {}) {
|
||||
ensureDiscordToken();
|
||||
const headers = {
|
||||
Authorization: `Bot ${discordToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers || {}),
|
||||
};
|
||||
|
||||
const r = await fetch(`${discordBase}${path}`, { ...init, headers });
|
||||
const text = await r.text();
|
||||
let data = text;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {}
|
||||
|
||||
if (!r.ok) {
|
||||
throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function toStringMask(v, fallback) {
|
||||
if (v === undefined || v === null || v === "") return String(fallback);
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number") return String(Math.floor(v));
|
||||
if (typeof v === "bigint") return String(v);
|
||||
throw fail(400, "invalid_mask", "invalid permission bit mask");
|
||||
}
|
||||
|
||||
function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) {
|
||||
const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY;
|
||||
const denyDefault = BIT_VIEW_CHANNEL;
|
||||
|
||||
const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault);
|
||||
const targetAllow = toStringMask(allowMask, allowDefault);
|
||||
|
||||
const overwrites = [
|
||||
{
|
||||
id: guildId,
|
||||
type: 0,
|
||||
allow: "0",
|
||||
deny: everyoneDeny,
|
||||
},
|
||||
];
|
||||
|
||||
for (const roleId of allowedRoleIds) {
|
||||
overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" });
|
||||
}
|
||||
for (const userId of allowedUserIds) {
|
||||
overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" });
|
||||
}
|
||||
|
||||
return overwrites;
|
||||
}
|
||||
|
||||
function parseFieldList(input) {
|
||||
if (Array.isArray(input)) return input.map((x) => String(x).trim()).filter(Boolean);
|
||||
if (typeof input === "string") return input.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeIdList(value, label) {
|
||||
const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : [];
|
||||
if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) {
|
||||
throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, {
|
||||
label,
|
||||
limit: MAX_PRIVATE_MUTATION_TARGETS,
|
||||
size: arr.length,
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function pick(obj, keys) {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
if (k in obj) out[k] = obj[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectMember(member, fields) {
|
||||
if (!fields.length) return member;
|
||||
const base = pick(member, fields);
|
||||
if (fields.some((f) => f.startsWith("user.")) && member?.user) {
|
||||
const userFields = fields
|
||||
.filter((f) => f.startsWith("user."))
|
||||
.map((f) => f.slice(5))
|
||||
.filter(Boolean);
|
||||
base.user = pick(member.user, userFields);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function actionChannelPrivateCreate(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
const name = String(body.name || "").trim();
|
||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
||||
if (!name) throw fail(400, "bad_request", "name is required");
|
||||
ensureGuildAllowed(guildId);
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
type: Number.isInteger(body.type) ? body.type : 0,
|
||||
parent_id: body.parentId || undefined,
|
||||
topic: body.topic || undefined,
|
||||
position: Number.isInteger(body.position) ? body.position : undefined,
|
||||
nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined,
|
||||
permission_overwrites: buildPrivateOverwrites({
|
||||
guildId,
|
||||
allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"),
|
||||
allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"),
|
||||
allowMask: body.allowMask,
|
||||
denyEveryoneMask: body.denyEveryoneMask,
|
||||
}),
|
||||
};
|
||||
|
||||
if (body.dryRun === true) {
|
||||
return { ok: true, action: "channel-private-create", dryRun: true, payload };
|
||||
}
|
||||
|
||||
const channel = await discordRequest(`/guilds/${guildId}/channels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return { ok: true, action: "channel-private-create", channel };
|
||||
}
|
||||
|
||||
async function actionChannelPrivateUpdate(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
const channelId = String(body.channelId || "").trim();
|
||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
||||
if (!channelId) throw fail(400, "bad_request", "channelId is required");
|
||||
ensureGuildAllowed(guildId);
|
||||
|
||||
const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY);
|
||||
const denyMask = toStringMask(body.denyMask, 0n);
|
||||
const mode = String(body.mode || "merge").trim();
|
||||
if (mode !== "merge" && mode !== "replace") {
|
||||
throw fail(400, "bad_request", "mode must be merge or replace", { mode });
|
||||
}
|
||||
|
||||
const addUserIds = normalizeIdList(body.addUserIds, "addUserIds");
|
||||
const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds");
|
||||
const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds");
|
||||
|
||||
const existing = await discordRequest(`/channels/${channelId}`);
|
||||
const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : [];
|
||||
|
||||
let next = [];
|
||||
if (mode === "replace") {
|
||||
// keep @everyone deny if present, otherwise set one
|
||||
const everyone = existingOverwrites.find((o) => String(o.id) === guildId && Number(o.type) === 0) || {
|
||||
id: guildId,
|
||||
type: 0,
|
||||
allow: "0",
|
||||
deny: String(BIT_VIEW_CHANNEL),
|
||||
};
|
||||
next.push({ id: String(everyone.id), type: 0, allow: String(everyone.allow || "0"), deny: String(everyone.deny || BIT_VIEW_CHANNEL) });
|
||||
} else {
|
||||
next = existingOverwrites.map((o) => ({ id: String(o.id), type: Number(o.type) === 1 ? 1 : 0, allow: String(o.allow || "0"), deny: String(o.deny || "0") }));
|
||||
}
|
||||
|
||||
const removeSet = new Set(removeTargetIds);
|
||||
if (removeSet.size > 0) {
|
||||
next = next.filter((o) => !removeSet.has(String(o.id)));
|
||||
}
|
||||
|
||||
const upsert = (id, type) => {
|
||||
const idx = next.findIndex((o) => String(o.id) === String(id));
|
||||
const row = { id: String(id), type, allow: allowMask, deny: denyMask };
|
||||
if (idx >= 0) next[idx] = row;
|
||||
else next.push(row);
|
||||
};
|
||||
|
||||
for (const userId of addUserIds) upsert(userId, 1);
|
||||
for (const roleId of addRoleIds) upsert(roleId, 0);
|
||||
|
||||
const payload = { permission_overwrites: next };
|
||||
|
||||
if (body.dryRun === true) {
|
||||
return { ok: true, action: "channel-private-update", dryRun: true, payload, mode };
|
||||
}
|
||||
|
||||
const channel = await discordRequest(`/channels/${channelId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return { ok: true, action: "channel-private-update", mode, channel };
|
||||
}
|
||||
|
||||
async function actionMemberList(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
||||
ensureGuildAllowed(guildId);
|
||||
|
||||
const limitRaw = Number(body.limit ?? 100);
|
||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
||||
const after = body.after ? String(body.after) : undefined;
|
||||
const fields = parseFieldList(body.fields);
|
||||
if (fields.length > MAX_MEMBER_FIELDS) {
|
||||
throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", {
|
||||
limit: MAX_MEMBER_FIELDS,
|
||||
size: fields.length,
|
||||
});
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("limit", String(limit));
|
||||
if (after) qs.set("after", after);
|
||||
|
||||
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
|
||||
const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members;
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
action: "member-list",
|
||||
guildId,
|
||||
count: Array.isArray(projected) ? projected.length : 0,
|
||||
fields: fields.length ? fields : undefined,
|
||||
members: projected,
|
||||
};
|
||||
|
||||
const bytes = Buffer.byteLength(JSON.stringify(response), "utf8");
|
||||
if (bytes > MAX_MEMBER_RESPONSE_BYTES) {
|
||||
throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", {
|
||||
bytes,
|
||||
limit: MAX_MEMBER_RESPONSE_BYTES,
|
||||
hint: "reduce limit or set fields projection",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function handleAction(body) {
|
||||
const action = String(body.action || "").trim();
|
||||
if (!action) throw fail(400, "bad_request", "action is required");
|
||||
ensureActionEnabled(action);
|
||||
|
||||
if (action === "channel-private-create") return await actionChannelPrivateCreate(body);
|
||||
if (action === "channel-private-update") return await actionChannelPrivateUpdate(body);
|
||||
if (action === "member-list") return await actionMemberList(body);
|
||||
|
||||
throw fail(400, "unsupported_action", `unsupported action: ${action}`);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
service: "discord-control-api",
|
||||
authRequired: !!authToken || requireAuthToken,
|
||||
actionGates: enabledActions,
|
||||
guildAllowlistEnabled: allowedGuildIds.size > 0,
|
||||
limits: {
|
||||
maxMemberFields: MAX_MEMBER_FIELDS,
|
||||
maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES,
|
||||
maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method !== "POST" || req.url !== "/v1/discord/action") {
|
||||
return sendJson(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 2_000_000) req.destroy();
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
ensureControlAuth(req);
|
||||
const parsed = body ? JSON.parse(body) : {};
|
||||
const result = await handleAction(parsed);
|
||||
return sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
return sendJson(res, err?.status || 500, {
|
||||
error: err?.code || "request_failed",
|
||||
message: String(err?.message || err),
|
||||
details: err?.details,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[discord-control-api] listening on :${port}`);
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
services:
|
||||
whispergate-no-reply-api:
|
||||
build:
|
||||
context: ./no-reply-api
|
||||
container_name: whispergate-no-reply-api
|
||||
ports:
|
||||
- "8787:8787"
|
||||
environment:
|
||||
- PORT=8787
|
||||
- NO_REPLY_MODEL=whispergate-no-reply-v1
|
||||
restart: unless-stopped
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"plugins": {
|
||||
"load": {
|
||||
"paths": ["/path/to/WhisperGate/dist/whispergate"]
|
||||
"paths": ["/path/to/Dirigent/dist/dirigent"]
|
||||
},
|
||||
"entries": {
|
||||
"whispergate": {
|
||||
"dirigent": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"enabled": true,
|
||||
@@ -13,14 +13,17 @@
|
||||
"humanList": ["561921120408698910"],
|
||||
"agentList": [],
|
||||
"endSymbols": ["🔚"],
|
||||
"channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json",
|
||||
"noReplyProvider": "whisper-gateway",
|
||||
"schedulingIdentifier": "➡️",
|
||||
"multiMessageStartMarker": "↗️",
|
||||
"multiMessageEndMarker": "↙️",
|
||||
"multiMessagePromptMarker": "⤵️",
|
||||
"channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json",
|
||||
"noReplyProvider": "dirigentway",
|
||||
"noReplyModel": "no-reply",
|
||||
"enableDiscordControlTool": true,
|
||||
"enableWhispergatePolicyTool": true,
|
||||
"enableDirigentPolicyTool": true,
|
||||
"enableDebugLogs": false,
|
||||
"debugLogChannelIds": [],
|
||||
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
|
||||
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
||||
"discordControlCallerId": "agent-main"
|
||||
}
|
||||
@@ -29,7 +32,7 @@
|
||||
},
|
||||
"models": {
|
||||
"providers": {
|
||||
"whisper-gateway": {
|
||||
"dirigentway": {
|
||||
"apiKey": "<NO_REPLY_API_TOKEN_OR_PLACEHOLDER>",
|
||||
"baseUrl": "http://127.0.0.1:8787/v1",
|
||||
"api": "openai-completions",
|
||||
@@ -52,7 +55,7 @@
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": ["whispergate"]
|
||||
"allow": ["dirigent"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# Discord Control API
|
||||
|
||||
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
|
||||
|
||||
> 现在可以通过 WhisperGate 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl)。
|
||||
> 注意:该工具是 optional,需要在 agent tools allowlist 中显式允许(例如允许 `whispergate` 或 `discord_control`)。
|
||||
|
||||
1. 创建指定名单可见的私人频道
|
||||
2. 查看 server 成员列表(分页)
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cd discord-control-api
|
||||
export DISCORD_BOT_TOKEN='xxx'
|
||||
# 建议启用
|
||||
export AUTH_TOKEN='strong-token'
|
||||
# optional hard requirement
|
||||
# export REQUIRE_AUTH_TOKEN=true
|
||||
# optional action gates
|
||||
# export ENABLE_CHANNEL_PRIVATE_CREATE=true
|
||||
# export ENABLE_CHANNEL_PRIVATE_UPDATE=true
|
||||
# export ENABLE_MEMBER_LIST=true
|
||||
# optional allowlist
|
||||
# export ALLOWED_GUILD_IDS='123,456'
|
||||
# export ALLOWED_CALLER_IDS='agent-main,agent-admin'
|
||||
# optional limits
|
||||
# export MAX_MEMBER_FIELDS=20
|
||||
# export MAX_MEMBER_RESPONSE_BYTES=500000
|
||||
# export MAX_PRIVATE_MUTATION_TARGETS=200
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
Health:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8790/health
|
||||
```
|
||||
|
||||
## Unified action endpoint
|
||||
|
||||
`POST /v1/discord/action`
|
||||
|
||||
- Header: `Authorization: Bearer <AUTH_TOKEN>`(若配置)
|
||||
- Header: `X-OpenClaw-Caller-Id: <id>`(若配置了 `ALLOWED_CALLER_IDS`)
|
||||
- Body: `{ "action": "...", ... }`
|
||||
|
||||
---
|
||||
|
||||
## Action: channel-private-create
|
||||
|
||||
与 OpenClaw `channel-create` 参数保持风格一致,并增加私密覆盖参数。
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channel-private-create",
|
||||
"guildId": "123",
|
||||
"name": "private-room",
|
||||
"type": 0,
|
||||
"parentId": "456",
|
||||
"topic": "secret",
|
||||
"position": 3,
|
||||
"nsfw": false,
|
||||
"allowedUserIds": ["111", "222"],
|
||||
"allowedRoleIds": ["333"],
|
||||
"allowMask": "67648",
|
||||
"denyEveryoneMask": "1024",
|
||||
"dryRun": false
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- 默认 deny `@everyone` 的 `VIEW_CHANNEL`。
|
||||
- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`。
|
||||
- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。
|
||||
|
||||
---
|
||||
|
||||
## Action: channel-private-update
|
||||
|
||||
对现有频道的白名单/覆盖权限做增删改。
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channel-private-update",
|
||||
"guildId": "123",
|
||||
"channelId": "789",
|
||||
"mode": "merge",
|
||||
"addUserIds": ["111"],
|
||||
"addRoleIds": ["333"],
|
||||
"removeTargetIds": ["222"],
|
||||
"allowMask": "67648",
|
||||
"denyMask": "0",
|
||||
"dryRun": false
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `mode=merge`:在现有覆盖基础上增删
|
||||
- `mode=replace`:重建覆盖(保留/补上 @everyone deny)
|
||||
|
||||
---
|
||||
|
||||
## Action: member-list
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "member-list",
|
||||
"guildId": "123",
|
||||
"limit": 100,
|
||||
"after": "0",
|
||||
"fields": ["user.id", "user.username", "nick", "roles", "joined_at"]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `limit` 1~1000
|
||||
- `after` 用于分页(Discord snowflake)
|
||||
- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段
|
||||
|
||||
---
|
||||
|
||||
## Curl examples
|
||||
|
||||
示例文件:`docs/EXAMPLES.discord-control.json`
|
||||
|
||||
快速检查脚本:
|
||||
|
||||
```bash
|
||||
./scripts/smoke-discord-control.sh
|
||||
# with env
|
||||
# AUTH_TOKEN=xxx CALLER_ID=agent-main GUILD_ID=123 CHANNEL_ID=456 ./scripts/smoke-discord-control.sh
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
鉴权与内置风格对齐(简化版):
|
||||
- 控制面 token:`AUTH_TOKEN` / `REQUIRE_AUTH_TOKEN`
|
||||
- 调用者 allowlist:`ALLOWED_CALLER_IDS`(配合 `X-OpenClaw-Caller-Id`)
|
||||
- action gate:`ENABLE_CHANNEL_PRIVATE_CREATE` / `ENABLE_MEMBER_LIST`
|
||||
- guild allowlist:`ALLOWED_GUILD_IDS`
|
||||
|
||||
- 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。
|
||||
- 若无权限,Discord API 会返回 403 并透传错误细节。
|
||||
@@ -1,8 +1,8 @@
|
||||
# WhisperGate Implementation Notes
|
||||
# Dirigent Implementation Notes
|
||||
|
||||
## Decision path
|
||||
|
||||
WhisperGate evaluates in strict order:
|
||||
Dirigent evaluates in strict order:
|
||||
|
||||
1. channel check (discord-only)
|
||||
2. bypass sender check
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# WhisperGate Integration (No-touch Template)
|
||||
# Dirigent Integration (No-touch Template)
|
||||
|
||||
This guide **does not** change your current OpenClaw config automatically.
|
||||
It only generates a JSON snippet you can review.
|
||||
@@ -7,9 +7,9 @@ It only generates a JSON snippet you can review.
|
||||
|
||||
```bash
|
||||
node scripts/render-openclaw-config.mjs \
|
||||
/absolute/path/to/WhisperGate/plugin \
|
||||
/absolute/path/to/Dirigent/plugin \
|
||||
openai \
|
||||
whispergate-no-reply-v1 \
|
||||
dirigent-no-reply-v1 \
|
||||
561921120408698910
|
||||
```
|
||||
|
||||
@@ -23,29 +23,26 @@ Arguments:
|
||||
|
||||
The script prints JSON for:
|
||||
- `plugins.load.paths`
|
||||
- `plugins.entries.whispergate.config`
|
||||
- `plugins.entries.dirigent.config`
|
||||
|
||||
You can merge this snippet manually into your `openclaw.json`.
|
||||
|
||||
## Installer script (with rollback)
|
||||
|
||||
For production-like install with automatic rollback on error (Node-only installer):
|
||||
## Installer script
|
||||
|
||||
```bash
|
||||
node ./scripts/install-whispergate-openclaw.mjs --install
|
||||
node ./scripts/install.mjs --install
|
||||
# optional port override
|
||||
node ./scripts/install.mjs --install --no-reply-port 8787
|
||||
# or wrapper
|
||||
./scripts/install-whispergate-openclaw.sh --install
|
||||
./scripts/install-dirigent-openclaw.sh --install
|
||||
```
|
||||
|
||||
Uninstall (revert all recorded config changes):
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
node ./scripts/install-whispergate-openclaw.mjs --uninstall
|
||||
node ./scripts/install.mjs --uninstall
|
||||
# or wrapper
|
||||
./scripts/install-whispergate-openclaw.sh --uninstall
|
||||
# or specify a record explicitly
|
||||
# RECORD_FILE=~/.openclaw/whispergate-install-records/whispergate-YYYYmmddHHMMSS.json \
|
||||
# node ./scripts/install-whispergate-openclaw.mjs --uninstall
|
||||
./scripts/install-dirigent-openclaw.sh --uninstall
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
@@ -61,23 +58,26 @@ 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`
|
||||
- creates config backup first
|
||||
- restores backup automatically if any install step fails
|
||||
- restarts gateway during install, then validates `whisper-gateway/no-reply` is visible via `openclaw models list/status`
|
||||
- writes a change record for every install/uninstall:
|
||||
- directory: `~/.openclaw/whispergate-install-records/`
|
||||
- latest pointer: `~/.openclaw/whispergate-install-record-latest.json`
|
||||
- installs plugin + no-reply-api into `~/.openclaw/plugins`
|
||||
- updates `plugins.entries.dirigent` and `models.providers.<no-reply-provider>`
|
||||
- supports `--no-reply-port` (also written into `plugins.entries.dirigent.config.noReplyPort`)
|
||||
- does not maintain install/uninstall record files
|
||||
|
||||
Policy state semantics:
|
||||
- channel policy file is loaded once into memory on startup
|
||||
- runtime decisions use in-memory state
|
||||
- use `whispergate_policy` tool to update state (memory first, then file persist)
|
||||
- use `dirigent_policy` tool to update state (memory first, then file persist)
|
||||
- manual file edits do not auto-apply until next restart
|
||||
|
||||
## Notes
|
||||
|
||||
- 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`.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# PR Summary (WhisperGate + Discord Control)
|
||||
# PR Summary (Dirigent + Discord Control)
|
||||
|
||||
## Scope
|
||||
|
||||
This PR delivers two tracks:
|
||||
|
||||
1. WhisperGate deterministic no-reply gate for Discord sessions
|
||||
1. Dirigent deterministic no-reply gate for Discord sessions
|
||||
2. Discord control extension API for private-channel/member-list gaps
|
||||
|
||||
## Delivered Features
|
||||
|
||||
### WhisperGate
|
||||
### Dirigent
|
||||
|
||||
- Deterministic rule chain:
|
||||
1) non-discord => skip
|
||||
@@ -51,5 +51,5 @@ This PR delivers two tracks:
|
||||
## Rollback
|
||||
|
||||
- Disable plugin entry or remove plugin path from OpenClaw config
|
||||
- Stop `discord-control-api` process
|
||||
- (Legacy note) `discord-control-api` sidecar has been removed; Discord control is in-plugin now
|
||||
- Keep no-reply API stopped if not needed
|
||||
|
||||
@@ -8,17 +8,17 @@ node scripts/package-plugin.mjs
|
||||
|
||||
Output:
|
||||
|
||||
- `dist/whispergate/index.ts`
|
||||
- `dist/whispergate/rules.ts`
|
||||
- `dist/whispergate/openclaw.plugin.json`
|
||||
- `dist/whispergate/README.md`
|
||||
- `dist/whispergate/package.json`
|
||||
- `dist/dirigent/index.ts`
|
||||
- `dist/dirigent/rules.ts`
|
||||
- `dist/dirigent/openclaw.plugin.json`
|
||||
- `dist/dirigent/README.md`
|
||||
- `dist/dirigent/package.json`
|
||||
|
||||
## Use packaged plugin path
|
||||
|
||||
Point OpenClaw `plugins.load.paths` to:
|
||||
|
||||
`/absolute/path/to/WhisperGate/dist/whispergate`
|
||||
`/absolute/path/to/Dirigent/dist/dirigent`
|
||||
|
||||
## Verify package completeness
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# WhisperGate Rollout Checklist
|
||||
# Dirigent Rollout Checklist
|
||||
|
||||
## Stage 0: Local sanity
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
|
||||
## Rollback
|
||||
|
||||
- Disable plugin entry `whispergate.enabled=false` OR remove plugin path
|
||||
- Disable plugin entry `dirigent.enabled=false` OR remove plugin path
|
||||
- Keep API service running; it is inert when plugin disabled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Run Modes
|
||||
|
||||
WhisperGate has two runtime components:
|
||||
Dirigent has two runtime components:
|
||||
|
||||
1. `plugin/` (OpenClaw plugin)
|
||||
2. `no-reply-api/` (deterministic NO_REPLY service)
|
||||
@@ -20,7 +20,7 @@ Then configure OpenClaw provider `baseURL` to `http://127.0.0.1:8787/v1`.
|
||||
|
||||
```bash
|
||||
./scripts/dev-up.sh
|
||||
# or: docker compose up -d --build whispergate-no-reply-api
|
||||
# or: docker compose up -d --build dirigent-no-reply-api
|
||||
```
|
||||
|
||||
Stop:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# WhisperGate 测试记录报告(阶段性)
|
||||
# Dirigent 测试记录报告(阶段性)
|
||||
|
||||
日期:2026-02-25
|
||||
|
||||
@@ -6,19 +6,19 @@
|
||||
|
||||
本轮覆盖:
|
||||
|
||||
1. WhisperGate 基础静态与脚本测试
|
||||
1. Dirigent 基础静态与脚本测试
|
||||
2. no-reply-api 隔离集成测试
|
||||
3. discord-control-api 功能测试(dryRun + 实操)
|
||||
3. (历史)discord-control-api 功能测试(dryRun + 实操,当前版本已迁移为 in-plugin)
|
||||
|
||||
未覆盖:
|
||||
|
||||
- WhisperGate 插件真实挂载 OpenClaw 后的端到端(E2E)
|
||||
- Dirigent 插件真实挂载 OpenClaw 后的端到端(E2E)
|
||||
|
||||
---
|
||||
|
||||
## 二、测试环境
|
||||
|
||||
- 代码仓库:`/root/.openclaw/workspace-operator/WhisperGate`
|
||||
- 代码仓库:`/root/.openclaw/workspace-operator/Dirigent`
|
||||
- OpenClaw 配置来源:本机已有配置(读取 Discord token)
|
||||
- Discord guild(server)ID:`1368531017534537779`
|
||||
- allowlist user IDs:
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
## 三、已执行测试与结果
|
||||
|
||||
### A. WhisperGate 基础测试
|
||||
### A. Dirigent 基础测试
|
||||
|
||||
命令:
|
||||
|
||||
@@ -53,7 +53,7 @@ make check check-rules test-api
|
||||
|
||||
---
|
||||
|
||||
### B. discord-control-api dryRun + 实操测试
|
||||
### B. (历史)discord-control-api dryRun + 实操测试(当前版本已迁移)
|
||||
|
||||
执行内容与结果:
|
||||
|
||||
@@ -95,7 +95,7 @@ make check check-rules test-api
|
||||
|
||||
## 五、待测项(下一阶段)
|
||||
|
||||
### 1) WhisperGate 插件 E2E(需临时接入 OpenClaw 配置)
|
||||
### 1) Dirigent 插件 E2E(需临时接入 OpenClaw 配置)
|
||||
|
||||
目标:验证插件真实挂载后的完整链路。
|
||||
|
||||
@@ -113,7 +113,7 @@ make check check-rules test-api
|
||||
|
||||
### 2) 回归测试
|
||||
|
||||
- discord-control-api 引入后,不影响 WhisperGate 原有流程
|
||||
- (历史结论)discord-control-api 引入后,不影响 Dirigent 原有流程;现已迁移为 in-plugin 实现
|
||||
- 规则校验脚本在最新代码继续稳定通过
|
||||
|
||||
### 3) 运行与安全校验
|
||||
@@ -127,4 +127,4 @@ make check check-rules test-api
|
||||
## 六、当前结论
|
||||
|
||||
- 新增 Discord 管控能力(私密频道 + 成员列表)已完成核心测试,可用。
|
||||
- 项目剩余主要测试工作集中在 WhisperGate 插件与 OpenClaw 的真实 E2E 联调。
|
||||
- 项目剩余主要测试工作集中在 Dirigent 插件与 OpenClaw 的真实 E2E 联调。
|
||||
|
||||
108
docs/TURN-WAKEUP-PROBLEM.md
Normal file
108
docs/TURN-WAKEUP-PROBLEM.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Turn-Based Speaking: Wakeup Problem
|
||||
|
||||
## Context
|
||||
|
||||
Dirigent implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override.
|
||||
|
||||
## The Problem
|
||||
|
||||
When the current speaker responds with **NO_REPLY** (decides the message is not relevant to them), the turn advances to the next agent. However, **the next agent has no trigger to start speaking**.
|
||||
|
||||
### Why This Happens
|
||||
|
||||
1. A message arrives in the Discord channel
|
||||
2. OpenClaw routes it to **all** agent sessions in that channel simultaneously
|
||||
3. The Dirigent plugin intercepts at `before_model_resolve`:
|
||||
- Current speaker → allowed to process
|
||||
- Everyone else → forced to no-reply model (message is "consumed" silently)
|
||||
4. Current speaker processes the message and returns NO_REPLY
|
||||
5. `message_sent` hook detects NO_REPLY → turn advances to next agent
|
||||
6. **But the next agent already "consumed" the message in step 3** — their session processed it (as no-reply) and moved on
|
||||
7. No new message exists to trigger the next agent
|
||||
|
||||
### The Result
|
||||
|
||||
After a NO_REPLY, the next speaker sits idle until a **new** message arrives in the channel (from a human or another source). The original message that should have been passed to the next speaker is lost.
|
||||
|
||||
## When This Matters
|
||||
|
||||
- **Single-round conversation**: Human asks a question → Agent A says NO_REPLY → Agent B should answer but can't
|
||||
- **Chain conversations**: Agent A defers → Agent B defers → Agent C should speak but never gets triggered
|
||||
|
||||
## When This Doesn't Matter
|
||||
|
||||
- **End-symbol responses**: When an agent actually speaks (ends with 🔚), the turn advances and the next agent will respond to the **next** message. This is fine.
|
||||
- **Human-driven channels**: If humans keep sending messages, the dormant state resolves quickly.
|
||||
|
||||
## Possible Solutions
|
||||
|
||||
### 1. Synthetic Trigger Message (Plugin-Side)
|
||||
|
||||
After detecting NO_REPLY and advancing the turn, the plugin sends a **synthetic message** to the channel that triggers the next agent.
|
||||
|
||||
**Challenges:**
|
||||
- The plugin SDK (`message_sent` hook) doesn't have an API to inject messages into agent sessions
|
||||
- Sending a real Discord message (even invisible like zero-width space) creates noise and may confuse other agents
|
||||
- The synthetic message wouldn't contain the original user's context
|
||||
|
||||
### 2. Deferred Evaluation (Don't Block in before_model_resolve)
|
||||
|
||||
Instead of blocking non-speakers at `before_model_resolve`, let all agents receive the message but inject a "you are not the current speaker, reply NO_REPLY" instruction. The current speaker gets a normal prompt.
|
||||
|
||||
After the current speaker responds with NO_REPLY, the plugin would need to **re-trigger** the next agent's session with the same message.
|
||||
|
||||
**Challenges:**
|
||||
- All agents still consume tokens for the NO_REPLY evaluation
|
||||
- Re-triggering a session with an already-processed message requires OpenClaw internal APIs
|
||||
|
||||
### 3. Queue + Replay (Plugin-Side State)
|
||||
|
||||
The plugin stores the original message when it arrives. After NO_REPLY, it replays the message by injecting it into the next speaker's session.
|
||||
|
||||
**Challenges:**
|
||||
- Requires access to session injection API (not available in current plugin SDK)
|
||||
- Managing the message queue adds complexity
|
||||
|
||||
### 4. Gateway-Level Support (OpenClaw Core Change)
|
||||
|
||||
Add a plugin hook return value like `{ defer: true }` in `before_model_resolve` that tells OpenClaw: "don't process this message yet, but keep it pending." When the turn advances, the plugin could call `api.retrigger(sessionKey)` to replay the pending message.
|
||||
|
||||
**Challenges:**
|
||||
- Requires changes to OpenClaw core, not just the plugin
|
||||
- Needs design discussion with the OpenClaw team
|
||||
|
||||
### 5. Bot-to-Bot Handoff via Discord Message
|
||||
|
||||
When current speaker NO_REPLYs, have **that bot** send a brief handoff message in the channel: e.g., "(轮到下一位)" or a reaction. This real Discord message triggers all agents, and the turn manager ensures only the next speaker responds.
|
||||
|
||||
**Challenges:**
|
||||
- Adds visible noise to the channel (could use a convention like a specific emoji reaction)
|
||||
- The no-reply'd bot can't send messages (it was silenced)
|
||||
- Could use in-plugin Discord REST control to send as a different bot (sidecar removed)
|
||||
|
||||
### 6. Timer-Based Retry (Pragmatic)
|
||||
|
||||
After advancing the turn, set a short timer (e.g., 2-3 seconds). If no new message has arrived, send a minimal trigger. This could be an internal "nudge" if the SDK supports it.
|
||||
|
||||
**Challenges:**
|
||||
- Timing is fragile
|
||||
- Still needs a mechanism to trigger the next agent
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Solution 5 (Bot-to-Bot Handoff)** is the most pragmatic with current constraints. The implementation would be:
|
||||
|
||||
1. In the `message_sent` hook, after detecting NO_REPLY and advancing the turn:
|
||||
2. Use in-plugin Discord REST control to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
|
||||
3. This real Discord message triggers OpenClaw to route it to all agents
|
||||
4. The turn manager allows only the (now-current) next speaker to respond
|
||||
5. The next speaker sees the original conversation context in their session history and responds appropriately
|
||||
|
||||
**Downside:** Adds a visible "[轮转]" message. Could be mitigated by immediately deleting it after delivery, or using a reaction instead of a message.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Does the OpenClaw plugin SDK support injecting messages into sessions?
|
||||
2. Can plugins access the Discord client to send messages directly?
|
||||
3. Would an OpenClaw core `defer`/`retrigger` mechanism be feasible?
|
||||
4. Is visible channel noise acceptable for the handoff message?
|
||||
@@ -1,4 +1,4 @@
|
||||
# WhisperGate Quick Verification
|
||||
# Dirigent Quick Verification
|
||||
|
||||
## 1) Start no-reply API
|
||||
|
||||
@@ -15,7 +15,7 @@ npm start
|
||||
curl -sS http://127.0.0.1:8787/health
|
||||
curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hi"}]}'
|
||||
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hi"}]}'
|
||||
```
|
||||
|
||||
Or run bundled smoke check:
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
@@ -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"]
|
||||
12
no-reply-api/package-lock.json
generated
12
no-reply-api/package-lock.json
generated
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "whispergate-no-reply-api",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "whispergate-no-reply-api",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "whispergate-no-reply-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number(process.env.PORT || 8787);
|
||||
const modelName = process.env.NO_REPLY_MODEL || "whispergate-no-reply-v1";
|
||||
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_whispergate_${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_whispergate_${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: "whispergate"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
return sendJson(res, 200, { ok: true, service: "whispergate-no-reply-api", model: modelName });
|
||||
}
|
||||
|
||||
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(`[whispergate-no-reply-api] listening on :${port}`);
|
||||
});
|
||||
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@hangman-lab/dirigent",
|
||||
"version": "0.3.0",
|
||||
"description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/",
|
||||
"plugin/",
|
||||
"services/",
|
||||
|
||||
"docs/",
|
||||
"scripts/install.mjs",
|
||||
"docker-compose.yml",
|
||||
"Makefile",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"TASKLIST.md"
|
||||
],
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"plugin",
|
||||
"discord",
|
||||
"moderation",
|
||||
"turn-management"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.hangman-lab.top/nav/Dirigent.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
249
plans/CHANNEL_MODES_AND_SHUFFLE.md
Normal file
249
plans/CHANNEL_MODES_AND_SHUFFLE.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Channel Modes & Turn Shuffle 规划
|
||||
|
||||
## 背景
|
||||
|
||||
`feat/new-feat-notes` 分支中的 `NEW_FEAT.md` 提出了两项新的行为增强需求,需要整合进入 `main` 分支下的 `plans/`:
|
||||
|
||||
1. **Multi-Message Mode(人类连续多消息模式)**
|
||||
2. **Shuffle Mode(轮转顺序重洗牌)**
|
||||
|
||||
这两项能力都与 Dirigent 当前的 turn-manager、moderator handoff、no-reply override 机制直接相关,因此应作为 turn orchestration 的功能扩展纳入规划,而不是孤立实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. Multi-Message Mode
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
允许人类在一个 channel 中连续发送多条消息而不被 Agent 打断。
|
||||
|
||||
当用户显式进入 multi-message mode 后:
|
||||
- 当前 channel 的 turn manager 暂停
|
||||
- 所有 Agent 在该 channel 中进入 no-reply 覆盖
|
||||
- moderator bot 对每条人类追加消息发送 prompt marker,提示继续输入
|
||||
- 直到人类发送 end marker 才恢复 turn manager
|
||||
- 恢复后由 moderator 按现有调度机制唤醒下一位 Agent
|
||||
|
||||
---
|
||||
|
||||
### 1.2 配置项
|
||||
|
||||
新增并已实现以下可配置项:
|
||||
|
||||
- `multiMessageStartMarker`:默认 `↗️`
|
||||
- `multiMessageEndMarker`:默认 `↙️`
|
||||
- `multiMessagePromptMarker`:默认 `⤵️`
|
||||
|
||||
这些配置已加入插件 config schema,并在运行时被 `message-received` / `before-message-write` / `before-model-resolve` 使用。
|
||||
|
||||
---
|
||||
|
||||
### 1.3 行为规则
|
||||
|
||||
#### 进入 multi-message mode
|
||||
当 **human** 消息中包含 start marker:
|
||||
- 当前 channel 进入 `multi-message` 状态
|
||||
- turn manager 暂停
|
||||
- 当前 channel 下所有 Agent 在该 channel/session 中走 no-reply override
|
||||
|
||||
#### multi-message mode 中
|
||||
在该模式下:
|
||||
- 人类每发一条消息
|
||||
- moderator bot 自动回复 prompt marker(例如 `⤵️`)
|
||||
- Agent 不应参与正常轮转回复
|
||||
|
||||
#### 退出 multi-message mode
|
||||
当 **human** 消息中包含 end marker:
|
||||
- 当前 channel 退出 `multi-message` 状态
|
||||
- turn manager 恢复
|
||||
- moderator bot 发送当前调度标识(scheduling identifier)唤醒下一位 Agent
|
||||
|
||||
---
|
||||
|
||||
### 1.4 状态建议
|
||||
|
||||
每个 Discord channel 维护一个 channel mode:
|
||||
|
||||
- `normal`
|
||||
- `multi-message`
|
||||
- (后续可扩展其他模式)
|
||||
|
||||
multi-message mode 应与 discussion channel / wait-for-human / no-reply 决策互相兼容,优先级需要明确。
|
||||
|
||||
建议优先级初稿:
|
||||
|
||||
1. closed discussion channel
|
||||
2. multi-message mode
|
||||
3. waiting-for-human
|
||||
4. normal turn-manager
|
||||
|
||||
---
|
||||
|
||||
## 2. Shuffle Mode
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
在 turn-based speaking 中,为每个 Discord channel 增加可选的 turn order reshuffle 能力。
|
||||
|
||||
当 shuffle mode 开启时:
|
||||
- 在一轮 turn list 的最后一位 speaker 发言完成后
|
||||
- 对 turn order 重新洗牌
|
||||
- 但必须保证:**上一轮最后一位发言者不能在新一轮中成为第一位**
|
||||
|
||||
---
|
||||
|
||||
### 2.2 配置 / 控制方式
|
||||
|
||||
为每个 Discord channel 维护:
|
||||
|
||||
- `shuffling: boolean`
|
||||
|
||||
并新增 slash command:
|
||||
|
||||
- `/dirigent turn-shuffling on`
|
||||
- `/dirigent turn-shuffling off`
|
||||
- `/dirigent turn-shuffling`(查看当前状态)
|
||||
|
||||
当前实现结论:
|
||||
- `shuffling` 是 **channel 级 runtime state**,存放在 `plugin/core/channel-modes.ts`
|
||||
- 默认值为 `false`
|
||||
- 当前版本**不新增**全局 `shuffle default` 配置项
|
||||
- 重启后会恢复为默认关闭,如需开启需要再次执行命令
|
||||
|
||||
这样与现有实现保持一致,也避免把一次性的实验性调度偏好混入全局静态配置。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 行为规则
|
||||
|
||||
当 `shuffling = true` 时:
|
||||
- 正常按 turn order 轮转
|
||||
- 当“当前 speaker 是该轮最后一位”并完成发言后
|
||||
- 进入下一轮前重洗 turn order
|
||||
- 新 turn order 必须满足:
|
||||
- 上一轮最后 speaker != 新 turnOrder[0]
|
||||
|
||||
若无法满足(极端小集合场景),需要定义 fallback:
|
||||
- 两个 Agent 时可退化为简单交换/固定顺序
|
||||
- 单 Agent 时不需要 shuffle
|
||||
|
||||
---
|
||||
|
||||
## 3. 与当前系统的关系
|
||||
|
||||
这两个能力都不是独立子系统,而是 Dirigent turn orchestration 的扩展。
|
||||
|
||||
### 涉及模块
|
||||
|
||||
- `plugin/turn-manager.ts`
|
||||
- `plugin/hooks/message-received.ts`
|
||||
- `plugin/hooks/before-model-resolve.ts`
|
||||
- `plugin/hooks/before-message-write.ts`
|
||||
- `plugin/hooks/message-sent.ts`
|
||||
- `plugin/commands/dirigent-command.ts`
|
||||
- `plugin/index.ts`
|
||||
- `plugin/openclaw.plugin.json`
|
||||
- 如有必要新增 `plugin/core/channel-modes.ts` 或类似 runtime state 模块
|
||||
|
||||
---
|
||||
|
||||
## 4. 建议实现方向
|
||||
|
||||
### 4.1 Multi-Message Mode
|
||||
|
||||
建议不要把 multi-message mode 直接塞进规则判断字符串里,而是做成显式 channel runtime state。
|
||||
|
||||
建议新增:
|
||||
- channel mode state store
|
||||
- helper:
|
||||
- `enterMultiMessageMode(channelId)`
|
||||
- `exitMultiMessageMode(channelId)`
|
||||
- `isMultiMessageMode(channelId)`
|
||||
|
||||
然后:
|
||||
- `message-received` 检测 human 消息中的 start/end marker
|
||||
- `before-model-resolve` 检测当前 channel 是否处于 multi-message mode,若是则直接走 no-reply override
|
||||
- `message-received` 或合适的消息链路中触发 moderator prompt marker
|
||||
- 退出时触发一次 scheduling handoff
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Shuffle Mode
|
||||
|
||||
建议将 shuffle 状态与 turn order 状态放在一起,或由 turn-manager 引用单独的 channel config/state。
|
||||
|
||||
建议新增 helper:
|
||||
- `setChannelShuffling(channelId, enabled)`
|
||||
- `getChannelShuffling(channelId)`
|
||||
- `reshuffleTurnOrder(channelId, { avoidFirstAccountId })`
|
||||
|
||||
并在 turn cycle 的边界点调用重洗逻辑,而不是在任意发言后随机改顺序。
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与边界
|
||||
|
||||
### Multi-Message Mode
|
||||
|
||||
- 需要防止 moderator prompt marker 自己再次触发 turn logic
|
||||
- 需要明确 start/end marker 的匹配规则(全文包含、末尾匹配、独立一条指令等)
|
||||
- 需要明确 multi-message mode 下 human @mention 是否还有特殊行为
|
||||
- 需要避免与 discussion closed / waiting-for-human 冲突
|
||||
|
||||
### Shuffle Mode
|
||||
|
||||
- 两个 Agent 的洗牌约束需要单独处理
|
||||
- turn order 若正处于 mention override / waiting-for-human / dormant,需要明确何时允许 reshuffle
|
||||
- 若 turn order 成员变化,shuffle 与 bootstrap 的先后顺序要明确
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现状态
|
||||
|
||||
Multi-Message Mode 与 Shuffle Mode 已经在代码中实现,包括:
|
||||
|
||||
- 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
|
||||
|
||||
- Shuffle Mode 实现:
|
||||
- `plugin/core/channel-modes.ts` - 管理 shuffle 状态
|
||||
- `plugin/turn-manager.ts` - 在每轮结束后根据 shuffle 设置决定是否重洗牌
|
||||
- `/dirigent turn-shuffling` slash command 实现,支持 `on`/`off`/`status` 操作
|
||||
- 确保上一轮最后发言者不会在下一轮中成为第一位
|
||||
- 当前行为是运行时开关,默认关闭,不落盘
|
||||
|
||||
## 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 协作控制能力。
|
||||
588
plans/CSM.md
Normal file
588
plans/CSM.md
Normal file
@@ -0,0 +1,588 @@
|
||||
# CSM 设计稿:基于 Discord 私密频道的 Agent 协作讨论与回调机制
|
||||
|
||||
## 1. 背景
|
||||
|
||||
在 Dirigent 的多 Agent 工作流中,某个正在执行任务的 Agent 可能在处理中途需要与其他 Agent 讨论某个问题,例如:
|
||||
|
||||
- 方案评审
|
||||
- 权限模型确认
|
||||
- 实现细节对齐
|
||||
- 风险/边界条件讨论
|
||||
|
||||
当前缺少一种受控、可回收、可回灌原工作流的讨论机制。目标是让 Agent 能在需要时主动拉起一个临时私密讨论频道,与指定 Agent 交流,并在结束后把讨论成果注入回原始工作 Session 所在的 Discord channel。
|
||||
|
||||
本方案采用:
|
||||
|
||||
- 由发起讨论的 Agent 直接调用 `discord_channel_create`
|
||||
- 为该工具新增两个可选参数,以标记“讨论模式”
|
||||
- 由插件侧 moderator bot 用纯字符串拼接方式驱动讨论流程
|
||||
- 由发起讨论的 Agent 在讨论结束时显式调用新增工具 `discuss-callback`
|
||||
- 回调后关闭讨论 channel,并唤醒原工作 channel 继续处理
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标
|
||||
|
||||
实现一种可控的 Agent 间讨论机制,满足以下要求:
|
||||
|
||||
1. Agent 能在工作过程中主动创建一个私密讨论 channel
|
||||
2. 讨论 channel 能通过固定规则唤醒参与 Agent 开始讨论
|
||||
3. 讨论结束时,结果必须以文件形式落地
|
||||
4. 讨论结果能回传到原工作 channel
|
||||
5. 讨论结束后,讨论 channel 不再继续处理任何 Agent 发言
|
||||
6. moderator bot 不使用任何模型,所有发言均为固定模板/字符串拼接
|
||||
7. 整个机制尽量复用现有插件中的 turn-manager 轮转发言能力
|
||||
|
||||
---
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
以下内容不在本次 MVP 范围内:
|
||||
|
||||
- 不做基于模型的 moderator/coordinator 智能决策
|
||||
- 不做自动总结(总结必须由发起讨论的 Agent 负责)
|
||||
- 不做复杂投票/仲裁机制
|
||||
- 不做多级讨论嵌套
|
||||
- 不做跨 workspace 的结果存储
|
||||
- 不做开放式 transcript 自动压缩和长期知识抽取
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心思路
|
||||
|
||||
将“讨论”视为一种特殊模式的私密频道:
|
||||
|
||||
- 由工作中的 Agent 主动创建
|
||||
- 创建时提供原工作 channel id(callback channel id)和讨论指引(discuss guide)
|
||||
- 插件识别该频道为特殊讨论频道
|
||||
- moderator bot 通过插件内部 `sendModeratorMessage(...)` 在频道中发布一条固定 kickoff message,说明:
|
||||
- 讨论目的
|
||||
- 应如何进行讨论
|
||||
- 结束条件
|
||||
- 发起讨论的 Agent 在结束时必须写总结文档并调用 `discuss-callback`
|
||||
- 讨论期间复用现有 turn-manager 轮转发言机制驱动 Agent 发言
|
||||
- 如果轮转一圈无人发言,则 moderator 提醒发起者执行 callback 收尾
|
||||
- callback 成功后:
|
||||
- 检查总结文档是否存在,且必须位于发起讨论 Agent 的 workspace 下
|
||||
- 将讨论 channel 中对应 session 的 provider/model override 到 `noReplyProvider` / `noReplyModel`
|
||||
- 讨论 channel 后续任何消息都不再参与讨论,仅由 moderator 回复“channel 已关闭,仅留档”
|
||||
- moderator 在原工作 channel 发消息,给出讨论结果文档路径,唤醒原工作流
|
||||
|
||||
---
|
||||
|
||||
## 5. 工具设计
|
||||
|
||||
### 5.1 扩展 `discord_channel_create`
|
||||
|
||||
在现有 `discord_channel_create` 工具上新增两个可选参数:
|
||||
|
||||
- `callbackChannelId?: string`
|
||||
- `discussGuide?: string`
|
||||
|
||||
#### 兼容性要求
|
||||
|
||||
如果未填写 `callbackChannelId`:
|
||||
- 工具行为与当前完全一致
|
||||
|
||||
如果填写了 `callbackChannelId`:
|
||||
- 则必须同时填写 `discussGuide`
|
||||
- 插件将该 channel 标记为“讨论模式 channel”
|
||||
|
||||
#### 参数语义
|
||||
|
||||
##### `callbackChannelId`
|
||||
表示原工作 Session 所在的 Discord channel id。
|
||||
|
||||
该参数用于在讨论结束时,将结果回传给原工作 channel。
|
||||
|
||||
##### `discussGuide`
|
||||
字符串,描述:
|
||||
|
||||
- 需要讨论什么
|
||||
- 讨论希望得到什么结论
|
||||
- 讨论的边界与目标
|
||||
- 可选的产出要求
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
讨论私密协作 channel 的回调机制,需要明确:
|
||||
1. `discord_channel_create` 扩展参数设计
|
||||
2. discuss-callback 工具的行为
|
||||
3. 讨论结束后的 session 收口方式
|
||||
4. MVP 范围内的异常处理策略
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 新工具 `discuss-callback`
|
||||
|
||||
新增工具:`discuss-callback`
|
||||
|
||||
建议工具参数最小化为:
|
||||
|
||||
- `summaryPath: string`
|
||||
|
||||
#### 参数说明
|
||||
|
||||
##### `summaryPath`
|
||||
讨论总结文档的路径。
|
||||
|
||||
要求:
|
||||
- 文件必须存在
|
||||
- 文件必须位于发起讨论 Agent 的 workspace 下
|
||||
- 不允许引用 workspace 外路径
|
||||
|
||||
#### 不建议暴露的参数
|
||||
|
||||
以下参数不应由 Agent 自己传入:
|
||||
|
||||
- 原工作 channel id
|
||||
- discussion channel id
|
||||
- 发起者身份
|
||||
|
||||
这些信息应由插件根据当前 channel 上下文和已记录 metadata 自动推导,避免伪造和串错。
|
||||
|
||||
---
|
||||
|
||||
## 6. 讨论模式 Channel 的元数据
|
||||
|
||||
当 `discord_channel_create` 以讨论模式创建 channel 时,插件需要记录对应 metadata。
|
||||
|
||||
建议结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "discussion",
|
||||
"discussionChannelId": "C_DISCUSS_001",
|
||||
"originChannelId": "C_WORK_001",
|
||||
"initiatorAgentId": "agent_planner",
|
||||
"initiatorSessionId": "sess_123",
|
||||
"discussGuide": "讨论如何设计 callback 机制并确定 MVP 行为",
|
||||
"status": "active",
|
||||
"createdAt": "2026-03-30T18:00:00Z",
|
||||
"summaryPath": null
|
||||
}
|
||||
```
|
||||
|
||||
### 必需字段说明
|
||||
|
||||
- `mode`: 固定为 `discussion`
|
||||
- `discussionChannelId`: 当前讨论 channel id
|
||||
- `originChannelId`: 原工作 channel id
|
||||
- `initiatorAgentId`: 发起讨论的 Agent 标识
|
||||
- `initiatorSessionId`: 发起讨论对应的 session 标识
|
||||
- `discussGuide`: 讨论说明
|
||||
- `status`: 生命周期状态
|
||||
- `createdAt`: 创建时间
|
||||
- `summaryPath`: 结束后回写的总结文档路径
|
||||
|
||||
### 生命周期状态建议
|
||||
|
||||
- `active`:讨论进行中
|
||||
- `completed`:已成功 callback,已完成
|
||||
- `closed`:已关闭,仅作留档
|
||||
|
||||
对于 MVP,也可以将 `completed` 直接视为 `closed` 前的瞬时态,只要实现上清晰即可。
|
||||
|
||||
---
|
||||
|
||||
## 7. moderator bot 行为设计
|
||||
|
||||
moderator bot 的工作流完全不使用模型,所有输出均由模板字符串拼接生成。
|
||||
|
||||
### 7.1 讨论启动时发言
|
||||
|
||||
当检测到某 channel 为讨论模式 channel 时,moderator bot 自动发 kickoff message。
|
||||
|
||||
建议内容结构如下(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`):
|
||||
|
||||
```text
|
||||
[Discussion Started]
|
||||
|
||||
This channel was created for a temporary agent discussion.
|
||||
|
||||
Goal:
|
||||
{discussGuide}
|
||||
|
||||
Instructions:
|
||||
1. Discuss only the topic above.
|
||||
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(summaryPath)
|
||||
- provide the summary document path
|
||||
|
||||
Completion rule:
|
||||
Only the discussion initiator may finish this discussion.
|
||||
|
||||
After callback:
|
||||
- this channel will be closed
|
||||
- further discussion messages will be ignored
|
||||
- this channel will remain only for archive/reference
|
||||
- the original work channel will be notified with the summary file path
|
||||
```
|
||||
|
||||
结尾必须明确表达以下语义:
|
||||
|
||||
- 讨论结束由发起讨论的 Agent 判定
|
||||
- 发起者结束时必须写入总结文档
|
||||
- 发起者必须调用 `discuss-callback`
|
||||
- callback 成功后,原工作 channel 会被通知结果文档路径
|
||||
|
||||
---
|
||||
|
||||
### 7.2 空转提醒机制
|
||||
|
||||
插件当前已有基于 turn-manager 的轮转发言机制。
|
||||
|
||||
现有逻辑:
|
||||
- 发言列表轮转一圈,如果没有人发言且所有人均回复 `NO_REPLY`,则认为 channel 休眠
|
||||
|
||||
本方案对“特殊讨论 channel”修改为:
|
||||
|
||||
#### 新逻辑
|
||||
在讨论模式 channel 中:
|
||||
- 若发言列表轮转一圈,无人发言
|
||||
- 则 moderator 不直接判定流程结束
|
||||
- 改为提醒发起讨论的 Agent:
|
||||
- 若讨论目标已经达成,请写总结文档
|
||||
- 然后调用 `discuss-callback` 结束讨论
|
||||
|
||||
建议提醒消息模板:
|
||||
|
||||
```text
|
||||
[Discussion Idle]
|
||||
|
||||
No agent responded in the latest discussion round.
|
||||
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.
|
||||
```
|
||||
|
||||
这样可以避免讨论悄无声息悬空,同时不引入自动总结逻辑。
|
||||
|
||||
---
|
||||
|
||||
### 7.3 讨论结束后的行为
|
||||
|
||||
在 `discuss-callback` 成功后:
|
||||
|
||||
1. 讨论 channel 标记为关闭/完成
|
||||
2. 讨论 channel 中所有相关 session 的 provider/model override 到 `noReplyProvider` / `noReplyModel`
|
||||
3. 后续任何在该 channel 中的发言均不再触发实际讨论流程
|
||||
4. 如果讨论结束后仍有人发言,由 moderator 统一回复:
|
||||
- `channel 已关闭,channel 仅做留档使用`
|
||||
|
||||
建议模板:
|
||||
|
||||
```text
|
||||
[Channel Closed]
|
||||
|
||||
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 的全局默认模型。
|
||||
|
||||
---
|
||||
|
||||
## 8. `discuss-callback` 工具行为细节
|
||||
|
||||
### 8.1 调用前提
|
||||
|
||||
仅允许在讨论模式 channel 中调用。
|
||||
|
||||
### 8.2 调用权限
|
||||
|
||||
只有发起讨论的 Agent 对应 session 可以成功调用。
|
||||
|
||||
其他参与 Agent 调用时应被拒绝。
|
||||
|
||||
### 8.3 调用流程
|
||||
|
||||
`discuss-callback(summaryPath)` 执行时,插件执行如下步骤:
|
||||
|
||||
1. 从当前调用上下文获取 discussion channel id
|
||||
2. 查询该 channel 的 discussion metadata
|
||||
3. 校验该 channel 是否为讨论模式 channel
|
||||
4. 校验调用者是否为 initiator session / initiator agent
|
||||
5. 校验 `summaryPath` 对应文件是否存在
|
||||
6. 校验文件路径是否位于发起讨论 Agent 的 workspace 下
|
||||
7. 更新 metadata:
|
||||
- `summaryPath = ...`
|
||||
- `status = completed` 或 `closed`
|
||||
- `completedAt = now`
|
||||
8. 将该 discussion channel 中相关 session 的模型覆盖为 `NO_REPLY`
|
||||
9. 在原工作 channel 中由 moderator bot 发消息,给出结果文档路径
|
||||
10. 讨论 channel 后续进入只留档、不再参与讨论的状态
|
||||
|
||||
---
|
||||
|
||||
## 9. 原工作 Channel 的唤醒机制
|
||||
|
||||
`discuss-callback` 成功后,moderator bot 需要在原工作 channel 发一条固定格式消息。
|
||||
|
||||
目的:
|
||||
- 向原工作 Session 明确传达“讨论结束”
|
||||
- 提供结果文档路径
|
||||
- 用新消息唤醒原工作 channel 上的 Agent 继续执行
|
||||
|
||||
建议模板(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`):
|
||||
|
||||
```text
|
||||
[Discussion Result Ready]
|
||||
|
||||
A temporary discussion has completed.
|
||||
|
||||
Summary file:
|
||||
{summaryPath}
|
||||
|
||||
Source discussion channel:
|
||||
<#discussionChannelId>
|
||||
|
||||
Status:
|
||||
completed
|
||||
|
||||
Continue the original task using the summary file above.
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
这里的“唤醒”不依赖额外模型调度逻辑,而是依赖:
|
||||
- moderator bot 在原 channel 发布一条新消息
|
||||
- 原工作 channel 正常接收到新消息事件
|
||||
- 原工作 Session 被自然触发继续处理
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全与边界条件
|
||||
|
||||
### 10.1 `summaryPath` 伪造
|
||||
|
||||
必须防止 Agent 传入伪造路径。
|
||||
|
||||
#### 强制校验
|
||||
插件必须检查:
|
||||
|
||||
1. 文件是否存在
|
||||
2. 文件路径是否位于发起讨论 Agent 的 workspace 下
|
||||
3. 不允许使用 workspace 之外的路径
|
||||
|
||||
这是本方案的硬约束。
|
||||
|
||||
---
|
||||
|
||||
### 10.2 discussion channel 未正常结束
|
||||
|
||||
由于讨论结束依赖发起者显式 callback,可能出现讨论完成但发起者未执行 callback 的情况。
|
||||
|
||||
#### MVP 策略
|
||||
- 不做自动总结
|
||||
- 复用顺序讨论机制检测“轮转一圈无人发言”
|
||||
- 由 moderator bot 提醒发起者收尾
|
||||
|
||||
这已经足够形成最低可用闭环。
|
||||
|
||||
后续增强可再考虑:
|
||||
- 超时提醒
|
||||
- stale discussion 清理
|
||||
- 管理员强制关闭
|
||||
|
||||
---
|
||||
|
||||
### 10.3 结束后继续发言
|
||||
|
||||
这是明确禁止的。
|
||||
|
||||
在讨论完成后:
|
||||
|
||||
- 任何会话都不应继续在该 channel 中讨论
|
||||
- 相关 session 继续使用 `NO_REPLY` 模型覆盖
|
||||
- channel 仅保留为留档用途
|
||||
- 后续发言统一由 moderator bot 回复 channel 已关闭
|
||||
|
||||
这条约束需要严格执行,避免旧讨论“诈尸”。
|
||||
|
||||
---
|
||||
|
||||
### 10.4 状态重复提交
|
||||
|
||||
若 `discuss-callback` 被重复调用,应拒绝处理。
|
||||
|
||||
建议规则:
|
||||
- 仅允许 `active -> completed/closed` 一次转换
|
||||
- 若 channel 已完成或已关闭,则后续 callback 一律失败
|
||||
|
||||
---
|
||||
|
||||
## 11. 推荐状态机
|
||||
|
||||
```text
|
||||
普通 channel
|
||||
└── 不受本方案影响
|
||||
|
||||
讨论 channel
|
||||
created
|
||||
-> active
|
||||
-> idle-reminded (可选显示态)
|
||||
-> completed
|
||||
-> closed
|
||||
```
|
||||
|
||||
### 状态说明
|
||||
|
||||
- `created`:channel 已创建但 kickoff 尚未发完
|
||||
- `active`:讨论进行中
|
||||
- `idle-reminded`:轮转一圈无人发言,moderator 已提醒收尾(实现上可不单独持久化)
|
||||
- `completed`:callback 已成功
|
||||
- `closed`:channel 已关闭,仅保留留档
|
||||
|
||||
MVP 中也可简化为:
|
||||
|
||||
- `active`
|
||||
- `closed`
|
||||
|
||||
只要逻辑上能区分 callback 前后即可。
|
||||
|
||||
---
|
||||
|
||||
## 12. 典型流程示例
|
||||
|
||||
### 12.1 发起讨论
|
||||
原工作 channel 中,某 Agent 发现需要和其他 Agent 对齐方案。
|
||||
|
||||
Agent 调用:
|
||||
|
||||
```json
|
||||
{
|
||||
"callbackChannelId": "origin-channel-123",
|
||||
"discussGuide": "讨论私密协作 channel 的结束与回调机制,需要产出一份总结说明工具参数、结束条件和异常处理策略。"
|
||||
}
|
||||
```
|
||||
|
||||
插件创建私密讨论 channel,并记录 metadata。
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
### 12.3 结束讨论
|
||||
发起讨论 Agent 在自己的 workspace 下写出总结文档,例如:
|
||||
|
||||
```text
|
||||
plans/discussions/csm-discussion-summary.md
|
||||
```
|
||||
|
||||
随后调用:
|
||||
|
||||
```json
|
||||
{
|
||||
"summaryPath": "plans/discussions/csm-discussion-summary.md"
|
||||
}
|
||||
```
|
||||
|
||||
插件校验通过后:
|
||||
- 标记讨论完成
|
||||
- 覆盖讨论 channel 中相关 session 的模型为 `NO_REPLY`
|
||||
- 在原工作 channel 发 moderator message
|
||||
|
||||
---
|
||||
|
||||
### 12.4 原工作流继续
|
||||
原工作 channel 收到 moderator 的结果通知:
|
||||
|
||||
- 结果文档路径
|
||||
- 来源 discussion channel
|
||||
- 状态 completed
|
||||
|
||||
原工作 Session 读取该总结文档,并继续原任务。
|
||||
|
||||
---
|
||||
|
||||
## 13. MVP 实施建议
|
||||
|
||||
建议按以下顺序实现:
|
||||
|
||||
### 第一步:扩展建频道工具
|
||||
- 为 `create private channel` 增加 `callbackChannelId` 和 `discussGuide`
|
||||
- 实现参数联动校验
|
||||
- 讨论模式下写入 metadata
|
||||
|
||||
### 第二步:moderator kickoff
|
||||
- 讨论模式 channel 创建后自动发固定模板消息
|
||||
- 内容包含 callback 操作说明
|
||||
|
||||
### 第三步:接入空转提醒
|
||||
- 复用现有 turn-manager 轮转逻辑
|
||||
- 在讨论模式 channel 中改为空转提醒发起者 callback
|
||||
|
||||
### 第四步:实现 `discuss-callback`
|
||||
- 当前 channel 识别
|
||||
- initiator 身份校验
|
||||
- `summaryPath` 文件存在校验
|
||||
- workspace 范围校验
|
||||
- metadata 更新
|
||||
|
||||
### 第五步:完成关闭逻辑
|
||||
- 指定 session 的 provider/model override 到 `noReplyProvider` / `noReplyModel`
|
||||
- 结束后任何发言统一由 moderator 回复 channel 已关闭
|
||||
|
||||
### 第六步:原 channel 唤醒
|
||||
- moderator 在 origin channel 发布结果消息
|
||||
- 确保原工作 Session 被自然唤醒
|
||||
|
||||
---
|
||||
|
||||
## 14. 后续可扩展方向
|
||||
|
||||
在 MVP 跑通后,可考虑增加以下能力:
|
||||
|
||||
- 讨论超时自动提醒
|
||||
- 管理员强制 callback / 强制关闭
|
||||
- 讨论文档路径规范化
|
||||
- 对总结文档结构给出模板约束
|
||||
- discussion 状态查询工具
|
||||
- 讨论留档索引
|
||||
- callback 成功后自动附带摘要预览
|
||||
|
||||
但这些都应放在 MVP 验证后再做。
|
||||
|
||||
---
|
||||
|
||||
## 15. 结论
|
||||
|
||||
本方案将 Agent 间讨论能力实现为一种“特殊模式的私密 Discord channel”,通过:
|
||||
|
||||
- 扩展 `discord_channel_create`
|
||||
- 新增 `discuss-callback`
|
||||
- 复用现有 turn-manager 轮转发言机制
|
||||
- 使用纯规则驱动的 moderator bot
|
||||
- 在指定 session 上临时应用 `noReplyProvider` / `noReplyModel` 覆盖
|
||||
|
||||
形成一个完整闭环:
|
||||
|
||||
1. 原工作 Agent 发起讨论
|
||||
2. 私密讨论 channel 被创建并自动启动讨论
|
||||
3. 讨论结果写入发起者 workspace 中的文件
|
||||
4. 发起者显式 callback 结束讨论
|
||||
5. 插件关闭讨论 channel 的交互能力
|
||||
6. moderator 将结果路径回传到原工作 channel
|
||||
7. 原工作 Session 被唤醒并继续执行
|
||||
|
||||
这是一个实现成本可控、行为稳定、边界清晰、适合 MVP 落地的方案。
|
||||
382
plans/TASKLIST.md
Normal file
382
plans/TASKLIST.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# TASKLIST - Dirigent 开发任务拆分
|
||||
|
||||
## A. CSM / Discussion Callback
|
||||
|
||||
### A1. 需求与方案冻结
|
||||
- [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 管理模块
|
||||
- [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`
|
||||
- [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`
|
||||
- [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 模块
|
||||
- [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 模块
|
||||
- [x] 新建 discussion service(如 `plugin/core/discussion-service.ts`)
|
||||
- [x] 封装 discussion channel 创建后的初始化逻辑
|
||||
- [x] 封装 callback 校验逻辑
|
||||
- [x] 封装 callback 成功后的收尾逻辑
|
||||
- [x] 封装 origin channel moderator 通知逻辑
|
||||
- [x] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑
|
||||
|
||||
#### A4.3 workspace 路径校验
|
||||
- [x] 新增 path 校验辅助函数
|
||||
- [x] 校验 `summaryPath` 文件存在
|
||||
- [x] 校验 `summaryPath` 位于 initiator workspace 下
|
||||
- [x] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题
|
||||
|
||||
### A5. `plugin/core/moderator-discord.ts`
|
||||
- [x] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程
|
||||
- [x] 如有必要,补充统一错误日志和返回值处理
|
||||
- [x] 确认可被 discussion service 复用发送:
|
||||
- [x] kickoff message
|
||||
- [x] idle reminder
|
||||
- [x] callback 完成通知
|
||||
- [x] channel closed 固定回复
|
||||
|
||||
### A6. `plugin/turn-manager.ts`
|
||||
#### A6.1 理解现有轮转机制
|
||||
- [x] 梳理 `initTurnOrder` / `checkTurn` / `onNewMessage` / `onSpeakerDone` / `advanceTurn`
|
||||
- [x] 确认“轮转一圈无人发言”在现有实现中的判定条件
|
||||
- [x] 确认 discussion 模式需要在哪个信号点插入“idle reminder”
|
||||
|
||||
#### A6.2 discussion 模式的空转处理
|
||||
- [x] 设计 discussion 模式下的 idle reminder 触发方式
|
||||
- [x] 确定是直接改 `turn-manager.ts`,还是由上层在 `nextSpeaker === null` 时识别 discussion channel
|
||||
- [x] 确保 discussion channel 空转时 moderator 会提醒 initiator 收尾
|
||||
- [x] 确保普通 channel 仍保持原有 dormant 行为
|
||||
|
||||
#### A6.3 关闭后禁言
|
||||
- [x] 明确 discussion channel `closed` 后 turn-manager 是否还需要保留状态
|
||||
- [x] 如需要,增加对 closed discussion channel 的快速短路判断
|
||||
- [x] 避免 closed channel 再次进入正常轮转
|
||||
|
||||
### A7. Hooks 与 session 状态
|
||||
#### A7.1 `plugin/hooks/before-model-resolve.ts`
|
||||
- [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`
|
||||
- [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`
|
||||
- [x] 确认该 hook 是否也会参与 turn 收尾,避免与 `before-message-write.ts` 重复处理
|
||||
- [x] 检查 discussion channel 场景下是否需要同步补充 closed/idle 分支保护
|
||||
- [x] 确保 callback 完成后的 closed channel 不会继续触发 handoff
|
||||
|
||||
#### A7.4 `plugin/hooks/message-received.ts`
|
||||
- [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`(如需)
|
||||
- [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`
|
||||
- [x] 梳理 initiator identity 的可获取路径
|
||||
- [x] 确认 callback 时如何稳定识别 initiator account/session
|
||||
- [x] 确认 discussion channel 创建后 turn order 是否需立即 bootstrap
|
||||
- [x] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑
|
||||
|
||||
### A9. `plugin/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
|
||||
- [x] 定稿 discussion started 模板
|
||||
- [x] 模板中包含 `discussGuide`
|
||||
- [x] 模板中明确 initiator 结束责任
|
||||
- [x] 模板中明确 `discuss-callback(summaryPath)` 调用要求
|
||||
|
||||
#### A10.2 idle reminder
|
||||
- [x] 定稿 discussion idle 模板
|
||||
- [x] 模板中提醒 initiator:写总结文件并 callback
|
||||
- [x] 避免提醒文案歧义或像自动总结器
|
||||
|
||||
#### A10.3 origin callback message
|
||||
- [x] 定稿发回原工作 channel 的结果通知模板
|
||||
- [x] 模板中包含 `summaryPath`
|
||||
- [x] 模板中包含来源 discussion channel
|
||||
- [x] 模板中明确“继续基于该总结文件推进原任务”
|
||||
|
||||
#### A10.4 closed reply
|
||||
- [x] 定稿 closed channel 固定回复模板
|
||||
- [x] 明确 channel 已关闭,仅做留档使用
|
||||
|
||||
### A11. `discuss-callback` 详细校验任务
|
||||
- [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. 关闭后的行为封口
|
||||
- [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 工具层测试
|
||||
- [x] 测试普通 `discord_channel_create` 不带新参数时行为不变
|
||||
- [x] 测试 `discord_channel_create` 带 `callbackChannelId` 但缺 `discussGuide` 时失败
|
||||
- [x] 测试 discussion 模式 channel 创建成功
|
||||
- [x] 测试 `discuss-callback` 注册成功并可调用
|
||||
|
||||
#### A13.2 metadata / service 测试
|
||||
- [x] 测试 discussion metadata 创建成功
|
||||
- [x] 测试按 channelId 查询 metadata 成功
|
||||
- [x] 测试状态流转 `active -> completed/closed` 成功
|
||||
- [x] 测试重复 callback 被拒绝
|
||||
|
||||
#### A13.3 turn / hook 测试
|
||||
- [x] 测试 discussion channel 空转后发送 idle reminder
|
||||
- [x] 测试普通 channel 空转逻辑不受影响
|
||||
- [x] 测试 callback 成功后 discussion channel 不再 handoff
|
||||
- [x] 测试 closed discussion channel 新消息不会继续唤醒 Agent
|
||||
|
||||
#### A13.4 路径校验测试
|
||||
- [x] 测试合法 `summaryPath` 通过
|
||||
- [x] 测试不存在文件失败
|
||||
- [x] 测试 workspace 外路径失败
|
||||
- [x] 测试 `..` 路径逃逸失败
|
||||
- [x] 测试绝对路径越界失败
|
||||
|
||||
#### A13.5 回调链路测试
|
||||
- [x] 测试 callback 成功后 moderator 在 origin channel 发出通知
|
||||
- [x] 测试 origin channel 收到路径后能继续原工作流
|
||||
- [x] 测试 discussion channel 后续只保留留档行为
|
||||
|
||||
#### A13.6 文档交付
|
||||
- [x] 根据最终代码实现更新 `plans/CSM.md`
|
||||
- [x] 为 `discord_channel_create` 新增参数补文档
|
||||
- [x] 为 `discuss-callback` 补工具说明文档
|
||||
- [x] 补 discussion metadata 与状态机说明
|
||||
- [x] 补开发/调试说明
|
||||
- [x] 输出 MVP 验收清单
|
||||
|
||||
---
|
||||
|
||||
## B. Multi-Message Mode / Shuffle Mode
|
||||
|
||||
### B1. 方案整理
|
||||
- [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`
|
||||
- [x] 增加 `multiMessageStartMarker`
|
||||
- [x] 增加 `multiMessageEndMarker`
|
||||
- [x] 增加 `multiMessagePromptMarker`
|
||||
- [x] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️`
|
||||
- [x] 评估是否需要增加 shuffle 默认配置项
|
||||
|
||||
#### B2.2 `plugin/rules.ts` / config 类型
|
||||
- [x] 为 multi-message mode 相关配置补类型定义
|
||||
- [x] 为 shuffle mode 相关 channel state / config 补类型定义
|
||||
- [x] 确保运行时读取配置逻辑可访问新增字段
|
||||
|
||||
### B3. `plugin/core/` 新增 channel mode / shuffle state 模块
|
||||
- [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 入口/出口
|
||||
- [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 提示
|
||||
- [x] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker
|
||||
- [x] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker`
|
||||
- [x] 避免重复触发或回环
|
||||
|
||||
#### B4.3 与现有 mention override 的兼容
|
||||
- [x] 明确 multi-message mode 下 human @mention 是否忽略
|
||||
- [x] 避免 multi-message mode 与 mention override 冲突
|
||||
|
||||
### B5. `plugin/hooks/before-model-resolve.ts`
|
||||
- [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
|
||||
- [x] 设计 multi-message mode 下 turn manager 的暂停语义
|
||||
- [x] 明确 pause 是通过外层 gating,还是 turn-manager 内显式状态
|
||||
- [x] 退出 multi-message mode 后恢复 turn manager
|
||||
- [x] 退出时确定下一位 speaker 的选择逻辑
|
||||
|
||||
#### B6.2 Shuffle Mode
|
||||
- [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`
|
||||
- [x] 新增 `/turn-shuffling` 子命令
|
||||
- [x] 支持:
|
||||
- [x] `/turn-shuffling`
|
||||
- [x] `/turn-shuffling on`
|
||||
- [x] `/turn-shuffling off`
|
||||
- [x] 命令返回当前 channel 的 shuffling 状态
|
||||
- [x] 命令帮助文本补充说明
|
||||
|
||||
### B8. `plugin/index.ts`
|
||||
- [x] 注入 channel mode / shuffle state 模块依赖
|
||||
- [x] 将新状态能力传给相关 hooks / turn-manager
|
||||
- [x] 保持初始化关系清晰,避免 mode 逻辑散落
|
||||
|
||||
### B9. moderator 消息模板
|
||||
- [x] 定义 multi-message mode 下的 prompt marker 发送规则
|
||||
- [x] 明确是否需要 start / end 的 moderator 确认消息
|
||||
- [x] 定义退出 multi-message mode 后的 scheduling handoff 触发格式
|
||||
|
||||
### B10. 测试
|
||||
#### B10.1 Multi-Message Mode
|
||||
- [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
|
||||
- [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 的验收清单
|
||||
@@ -1,12 +1,10 @@
|
||||
# WhisperGate Plugin
|
||||
# Dirigent Plugin
|
||||
|
||||
## Hook strategy
|
||||
|
||||
- `message:received` caches a per-session decision from deterministic rules.
|
||||
- `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply.
|
||||
- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is:
|
||||
- `bypass_sender`
|
||||
- `end_symbol:*`
|
||||
- `before_prompt_build` prepends end-marker instruction + scheduling identifier instruction when decision allows speaking.
|
||||
|
||||
## Rules (in order)
|
||||
|
||||
@@ -30,16 +28,21 @@ Optional:
|
||||
- `humanList` (default [])
|
||||
- `agentList` (default [])
|
||||
- `channelPoliciesFile` (per-channel overrides in a standalone JSON file)
|
||||
- `enableWhispergatePolicyTool` (default true)
|
||||
- `schedulingIdentifier` (default `➡️`) — moderator handoff identifier
|
||||
- `enableDirigentPolicyTool` (default true)
|
||||
- `multiMessageStartMarker` (default `↗️`)
|
||||
- `multiMessageEndMarker` (default `↙️`)
|
||||
- `multiMessagePromptMarker` (default `⤵️`)
|
||||
|
||||
Unified optional tool:
|
||||
- `whispergateway_tools`
|
||||
- `dirigent_tools`
|
||||
- Discord actions: `channel-private-create`, `channel-private-update`, `member-list`
|
||||
- Policy actions: `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
||||
- Turn actions: `turn-status`, `turn-advance`, `turn-reset`
|
||||
- `bypassUserIds` (deprecated alias of `humanList`)
|
||||
- `endSymbols` (default ["🔚"])
|
||||
- `enableDiscordControlTool` (default true)
|
||||
- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`)
|
||||
- Discord control actions are executed in-plugin via Discord REST API (no `discordControlApiBaseUrl` needed)
|
||||
- `discordControlApiToken`
|
||||
- `discordControlCallerId`
|
||||
- `enableDebugLogs` (default false)
|
||||
@@ -51,20 +54,35 @@ Policy file behavior:
|
||||
- loaded once on startup into memory
|
||||
- runtime decisions read memory state only
|
||||
- direct file edits do NOT affect memory state
|
||||
- `whispergateway_tools` policy actions update memory first, then persist to file (atomic write)
|
||||
- `dirigent_tools` policy actions update memory first, then persist to file (atomic write)
|
||||
|
||||
## Optional tool: `whispergateway_tools`
|
||||
## Moderator handoff format
|
||||
|
||||
This plugin registers one unified optional tool: `whispergateway_tools`.
|
||||
To use it, add tool allowlist entry for either:
|
||||
- tool name: `whispergateway_tools`
|
||||
- plugin id: `whispergate`
|
||||
When the current speaker NO_REPLYs, the moderator bot sends: `<@NEXT_USER_ID>➡️`
|
||||
|
||||
Supported actions:
|
||||
- Discord: `channel-private-create`, `channel-private-update`, `member-list`
|
||||
- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
||||
This is a non-semantic scheduling message. The scheduling identifier (`➡️` by default) carries no meaning — it simply signals the next agent to check chat history and decide whether to speak.
|
||||
|
||||
## 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)
|
||||
|
||||
```
|
||||
/dirigent status
|
||||
/dirigent turn-status
|
||||
/dirigent turn-advance
|
||||
/dirigent turn-reset
|
||||
/dirigent turn-shuffling
|
||||
/dirigent turn-shuffling on
|
||||
/dirigent turn-shuffling off
|
||||
```
|
||||
|
||||
Debug logging:
|
||||
- set `enableDebugLogs: true` to emit detailed hook diagnostics
|
||||
- optionally set `debugLogChannelIds` to only log selected channel IDs
|
||||
- logs include key ctx fields + decision status at `message_received`, `before_model_resolve`, `before_prompt_build`
|
||||
|
||||
136
plugin/commands/add-guild-command.ts
Normal file
136
plugin/commands/add-guild-command.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
13
plugin/commands/command-utils.ts
Normal file
13
plugin/commands/command-utils.ts
Normal 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;
|
||||
}
|
||||
70
plugin/commands/set-channel-mode-command.ts
Normal file
70
plugin/commands/set-channel-mode-command.ts
Normal 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}".` };
|
||||
},
|
||||
});
|
||||
}
|
||||
157
plugin/core/channel-members.ts
Normal file
157
plugin/core/channel-members.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { IdentityRegistry } from "./identity-registry.js";
|
||||
|
||||
const PERM_VIEW_CHANNEL = 1n << 10n;
|
||||
const PERM_ADMINISTRATOR = 1n << 3n;
|
||||
|
||||
function toBigIntPerm(v: unknown): bigint {
|
||||
if (typeof v === "bigint") return v;
|
||||
if (typeof v === "number") return BigInt(Math.trunc(v));
|
||||
if (typeof v === "string" && v.trim()) {
|
||||
try {
|
||||
return BigInt(v.trim());
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
return 0n;
|
||||
}
|
||||
|
||||
function roleOrMemberType(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function discordRequest(token: string, method: string, path: string): Promise<{ ok: boolean; status: number; json: any; text: string }> {
|
||||
const r = await fetch(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
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, json, text };
|
||||
}
|
||||
|
||||
function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bigint>, channelOverwrites: any[]): boolean {
|
||||
const roleIds: string[] = Array.isArray(member?.roles) ? member.roles : [];
|
||||
let perms = guildRoles.get(guildId) || 0n;
|
||||
for (const rid of roleIds) perms |= guildRoles.get(rid) || 0n;
|
||||
|
||||
if ((perms & PERM_ADMINISTRATOR) !== 0n) return true;
|
||||
|
||||
let everyoneAllow = 0n;
|
||||
let everyoneDeny = 0n;
|
||||
for (const ow of channelOverwrites) {
|
||||
if (String(ow?.id || "") === guildId && roleOrMemberType(ow?.type) === 0) {
|
||||
everyoneAllow = toBigIntPerm(ow?.allow);
|
||||
everyoneDeny = toBigIntPerm(ow?.deny);
|
||||
break;
|
||||
}
|
||||
}
|
||||
perms = (perms & ~everyoneDeny) | everyoneAllow;
|
||||
|
||||
let roleAllow = 0n;
|
||||
let roleDeny = 0n;
|
||||
for (const ow of channelOverwrites) {
|
||||
if (roleOrMemberType(ow?.type) !== 0) continue;
|
||||
const id = String(ow?.id || "");
|
||||
if (id !== guildId && roleIds.includes(id)) {
|
||||
roleAllow |= toBigIntPerm(ow?.allow);
|
||||
roleDeny |= toBigIntPerm(ow?.deny);
|
||||
}
|
||||
}
|
||||
perms = (perms & ~roleDeny) | roleAllow;
|
||||
|
||||
for (const ow of channelOverwrites) {
|
||||
if (roleOrMemberType(ow?.type) !== 1) continue;
|
||||
if (String(ow?.id || "") === String(member?.user?.id || "")) {
|
||||
const allow = toBigIntPerm(ow?.allow);
|
||||
const deny = toBigIntPerm(ow?.deny);
|
||||
perms = (perms & ~deny) | allow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (perms & PERM_VIEW_CHANNEL) !== 0n;
|
||||
}
|
||||
|
||||
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>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
for (const rec of Object.values(accounts)) {
|
||||
if (typeof rec?.token === "string" && rec.token) return rec.token;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
if (!ch.ok) return [];
|
||||
const guildId = String(ch.json?.guild_id || "");
|
||||
if (!guildId) return [];
|
||||
|
||||
const rolesResp = await discordRequest(token, "GET", `/guilds/${guildId}/roles`);
|
||||
if (!rolesResp.ok) return [];
|
||||
const rolePerms = new Map<string, bigint>();
|
||||
for (const r of Array.isArray(rolesResp.json) ? rolesResp.json : []) {
|
||||
rolePerms.set(String(r?.id || ""), toBigIntPerm(r?.permissions));
|
||||
}
|
||||
|
||||
const members: any[] = [];
|
||||
let after = "";
|
||||
while (true) {
|
||||
const q = new URLSearchParams({ limit: "1000" });
|
||||
if (after) q.set("after", after);
|
||||
const mResp = await discordRequest(token, "GET", `/guilds/${guildId}/members?${q.toString()}`);
|
||||
if (!mResp.ok) return [];
|
||||
const batch = Array.isArray(mResp.json) ? mResp.json : [];
|
||||
members.push(...batch);
|
||||
if (batch.length < 1000) break;
|
||||
after = String(batch[batch.length - 1]?.user?.id || "");
|
||||
if (!after) break;
|
||||
}
|
||||
|
||||
const overwrites = Array.isArray(ch.json?.permission_overwrites) ? ch.json.permission_overwrites : [];
|
||||
const visibleUserIds = members
|
||||
.filter((m) => canViewChannel(m, guildId, rolePerms, overwrites))
|
||||
.map((m) => String(m?.user?.id || ""))
|
||||
.filter(Boolean);
|
||||
|
||||
const out = new Set<string>();
|
||||
if (identityRegistry) {
|
||||
const discordToAgent = identityRegistry.buildDiscordToAgentMap();
|
||||
for (const uid of visibleUserIds) {
|
||||
const aid = discordToAgent.get(uid);
|
||||
if (aid) out.add(aid);
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
136
plugin/core/channel-store.ts
Normal file
136
plugin/core/channel-store.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
93
plugin/core/identity-registry.ts
Normal file
93
plugin/core/identity-registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
plugin/core/mentions.ts
Normal file
29
plugin/core/mentions.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMentionedUserIds(content: string): string[] {
|
||||
const regex = /<@!?(\d+)>/g;
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const id = match[1];
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function getModeratorUserIdFromToken(token: string | undefined): string | undefined {
|
||||
if (!token) return undefined;
|
||||
return userIdFromToken(token);
|
||||
}
|
||||
272
plugin/core/moderator-discord.ts
Normal file
272
plugin/core/moderator-discord.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
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);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDiscordUserId(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 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: Logger,
|
||||
): Promise<ModeratorMessageResult> {
|
||||
try {
|
||||
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
const text = await r.text();
|
||||
let json: Record<string, unknown> | null = null;
|
||||
try {
|
||||
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
59
plugin/core/padded-cell.ts
Normal file
59
plugin/core/padded-cell.ts
Normal 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;
|
||||
}
|
||||
119
plugin/core/sidecar-process.ts
Normal file
119
plugin/core/sidecar-process.ts
Normal 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();
|
||||
}
|
||||
272
plugin/hooks/agent-end.ts
Normal file
272
plugin/hooks/agent-end.ts
Normal 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;
|
||||
}
|
||||
165
plugin/hooks/before-model-resolve.ts
Normal file
165
plugin/hooks/before-model-resolve.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
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";
|
||||
|
||||
/** 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 Deps = {
|
||||
api: OpenClawPluginApi;
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
debugMode: boolean;
|
||||
noReplyProvider: string;
|
||||
noReplyModel: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
// 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 sessionKey = ctx.sessionKey;
|
||||
if (!sessionKey) return;
|
||||
|
||||
// Only handle Discord group channel sessions
|
||||
const channelId = parseDiscordChannelId(sessionKey);
|
||||
if (!channelId) return;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// concluded discussion: suppress via no-reply model
|
||||
if (mode === "discussion") {
|
||||
const rec = channelStore.getRecord(channelId);
|
||||
if (rec.discussion?.concluded) {
|
||||
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
128
plugin/hooks/message-received.ts
Normal file
128
plugin/hooks/message-received.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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 { 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 Deps = {
|
||||
api: OpenClawPluginApi;
|
||||
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: 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 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 (!channelId && typeof c.sessionKey === "string") {
|
||||
channelId = parseDiscordChannelId(c.sessionKey);
|
||||
}
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wake / interrupt (skipped when moderator service handles it via HTTP callback) ──
|
||||
if (moderatorHandlesMessages) return;
|
||||
|
||||
const senderId = String(
|
||||
(e.metadata as Record<string, unknown>)?.senderId ??
|
||||
(e.metadata as Record<string, unknown>)?.sender_id ??
|
||||
e.from ?? "",
|
||||
);
|
||||
|
||||
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 (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_received hook error: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
757
plugin/index.ts
757
plugin/index.ts
@@ -1,521 +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 { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.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 { registerAgentEndHook } from "./hooks/agent-end.js";
|
||||
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
||||
import { registerDirigentTools } from "./tools/register-tools.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 DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
type PluginConfig = {
|
||||
moderatorBotToken?: string;
|
||||
scheduleIdentifier?: string;
|
||||
identityFilePath?: string;
|
||||
channelStoreFilePath?: string;
|
||||
debugMode?: boolean;
|
||||
noReplyProvider?: string;
|
||||
noReplyModel?: string;
|
||||
sideCarPort?: number;
|
||||
};
|
||||
|
||||
type PolicyState = {
|
||||
filePath: string;
|
||||
channelPolicies: Record<string, ChannelPolicy>;
|
||||
};
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
const sessionDecision = new Map<string, DecisionRecord>();
|
||||
const MAX_SESSION_DECISIONS = 2000;
|
||||
const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||
function buildEndMarkerInstruction(endSymbols: string[]): string {
|
||||
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
|
||||
return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`;
|
||||
}
|
||||
|
||||
const policyState: PolicyState = {
|
||||
filePath: "",
|
||||
channelPolicies: {},
|
||||
};
|
||||
|
||||
function normalizeChannel(ctx: Record<string, unknown>): string {
|
||||
const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel];
|
||||
for (const c of candidates) {
|
||||
if (typeof c === "string" && c.trim()) return c.trim().toLowerCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeSender(event: Record<string, unknown>, ctx: Record<string, unknown>): string | undefined {
|
||||
const direct = [ctx.senderId, ctx.from, event.from];
|
||||
for (const v of direct) {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
|
||||
const meta = (event.metadata || ctx.metadata) as Record<string, unknown> | undefined;
|
||||
if (!meta) return undefined;
|
||||
const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id];
|
||||
for (const v of metaCandidates) {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveDecisionInputFromPrompt(
|
||||
prompt: string,
|
||||
messageProvider?: string,
|
||||
): {
|
||||
channel: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
content: string;
|
||||
conv: Record<string, unknown>;
|
||||
} {
|
||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||
const channel = (messageProvider || "").toLowerCase();
|
||||
const channelId =
|
||||
(typeof conv.channel_id === "string" && conv.channel_id) ||
|
||||
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")
|
||||
? conv.chat_id.slice("channel:".length)
|
||||
: undefined);
|
||||
const senderId =
|
||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||
(typeof conv.sender === "string" && conv.sender) ||
|
||||
undefined;
|
||||
|
||||
return { channel, channelId, senderId, content: prompt, conv };
|
||||
}
|
||||
|
||||
function pruneDecisionMap(now = Date.now()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const plugins = (root.plugins as Record<string, unknown>) || {};
|
||||
const entries = (plugins.entries as Record<string, unknown>) || {};
|
||||
const entry = (entries.whispergate as Record<string, unknown>) || {};
|
||||
const cfg = (entry.config as Record<string, unknown>) || {};
|
||||
if (Object.keys(cfg).length > 0) {
|
||||
// Merge with defaults to ensure optional fields have values
|
||||
return {
|
||||
enableDiscordControlTool: true,
|
||||
enableWhispergatePolicyTool: true,
|
||||
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
||||
enableDebugLogs: false,
|
||||
debugLogChannelIds: [],
|
||||
...cfg,
|
||||
} as WhisperGateConfig;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolvePoliciesPath(api: OpenClawPluginApi, config: WhisperGateConfig): string {
|
||||
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/whispergate-channel-policies.json");
|
||||
}
|
||||
|
||||
function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConfig) {
|
||||
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(`whispergate: failed init policy file ${filePath}: ${String(err)}`);
|
||||
policyState.channelPolicies = {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistPolicies(api: OpenClawPluginApi): void {
|
||||
const filePath = policyState.filePath;
|
||||
if (!filePath) throw new Error("policy file path not initialized");
|
||||
const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n";
|
||||
const tmp = `${filePath}.tmp`;
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(tmp, before, "utf8");
|
||||
fs.renameSync(tmp, filePath);
|
||||
api.logger.info(`whispergate: policy file persisted: ${filePath}`);
|
||||
}
|
||||
|
||||
function pickDefined(input: Record<string, unknown>) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
|
||||
if (!cfg.enableDebugLogs) return false;
|
||||
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
|
||||
if (allow.length === 0) return true;
|
||||
if (!channelId) return true; // 允许打印,方便排查 channelId 为空的场景
|
||||
return allow.includes(channelId);
|
||||
}
|
||||
|
||||
function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
|
||||
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
|
||||
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
|
||||
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
||||
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,
|
||||
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 getGatewayPort(api: OpenClawPluginApi): number {
|
||||
try {
|
||||
return ((api.config as Record<string, unknown>)?.gateway as Record<string, unknown>)?.port as number ?? 18789;
|
||||
} catch {
|
||||
return 18789;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: "whispergate",
|
||||
name: "WhisperGate",
|
||||
id: "dirigent",
|
||||
name: "Dirigent",
|
||||
register(api: OpenClawPluginApi) {
|
||||
// Merge pluginConfig with defaults (in case config is missing from openclaw.json)
|
||||
const baseConfig = {
|
||||
enableDiscordControlTool: true,
|
||||
enableWhispergatePolicyTool: true,
|
||||
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
||||
...(api.pluginConfig || {}),
|
||||
} as WhisperGateConfig & {
|
||||
enableDiscordControlTool: boolean;
|
||||
discordControlApiBaseUrl: string;
|
||||
discordControlApiToken?: string;
|
||||
discordControlCallerId?: string;
|
||||
enableWhispergatePolicyTool: boolean;
|
||||
};
|
||||
const config = normalizeConfig(api);
|
||||
const pluginDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const openclawDir = path.join(os.homedir(), ".openclaw");
|
||||
|
||||
const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
|
||||
ensurePolicyStateLoaded(api, liveAtRegister);
|
||||
const identityRegistry = new IdentityRegistry(config.identityFilePath);
|
||||
const channelStore = new ChannelStore(config.channelStoreFilePath);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "whispergateway_tools",
|
||||
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel"],
|
||||
},
|
||||
guildId: { 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" },
|
||||
channelId: { 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" },
|
||||
limit: { type: "number" },
|
||||
after: { type: "string" },
|
||||
fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] },
|
||||
dryRun: { type: "boolean" },
|
||||
listMode: { type: "string", enum: ["human-list", "agent-list"] },
|
||||
humanList: { type: "array", items: { type: "string" } },
|
||||
agentList: { type: "array", items: { type: "string" } },
|
||||
endSymbols: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["action"],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & {
|
||||
discordControlApiBaseUrl?: string;
|
||||
discordControlApiToken?: string;
|
||||
discordControlCallerId?: string;
|
||||
enableDiscordControlTool?: boolean;
|
||||
enableWhispergatePolicyTool?: boolean;
|
||||
};
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const moderatorBotToken = config.moderatorBotToken || undefined;
|
||||
const moderatorBotUserId = moderatorBotToken ? getBotUserIdFromToken(moderatorBotToken) : undefined;
|
||||
const moderatorServiceUrl = `http://127.0.0.1:${config.sideCarPort}/moderator`;
|
||||
|
||||
const action = String(params.action || "");
|
||||
const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]);
|
||||
let paddedCellDetected = false;
|
||||
|
||||
if (discordActions.has(action)) {
|
||||
if (live.enableDiscordControlTool === false) {
|
||||
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
|
||||
}
|
||||
const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, "");
|
||||
const body = pickDefined({ ...params, action: action as DiscordControlAction });
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`;
|
||||
if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId;
|
||||
function hasPaddedCell(): boolean {
|
||||
return paddedCellDetected;
|
||||
}
|
||||
|
||||
const r = await fetch(`${baseUrl}/v1/discord/action`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
if (live.enableWhispergatePolicyTool === false) {
|
||||
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
|
||||
}
|
||||
|
||||
if (action === "policy-get") {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "policy-set-channel") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
|
||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||
try {
|
||||
const next: ChannelPolicy = {
|
||||
listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined,
|
||||
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
|
||||
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
|
||||
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined,
|
||||
};
|
||||
policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
|
||||
persistPolicies(api);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] };
|
||||
} catch (err) {
|
||||
policyState.channelPolicies = prev;
|
||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "policy-delete-channel") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||
try {
|
||||
delete policyState.channelPolicies[channelId];
|
||||
persistPolicies(api);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] };
|
||||
} catch (err) {
|
||||
policyState.channelPolicies = prev;
|
||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
);
|
||||
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
try {
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
const preChannelId = typeof c.channelId === "string" ? c.channelId : undefined;
|
||||
const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
||||
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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
api.on("before_model_resolve", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
// ── Gateway lifecycle (once per gateway process) ───────────────────────
|
||||
if (!isGatewayLifecycleRegistered()) {
|
||||
markGatewayLifecycleRegistered();
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const gatewayPort = getGatewayPort(api);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
|
||||
if (live.enableDebugLogs) {
|
||||
api.logger.info(
|
||||
`whispergate: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
|
||||
`promptPreview=${prompt.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
||||
// Only proceed if: discord channel AND prompt contains untrusted metadata
|
||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
||||
|
||||
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,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
sessionDecision.set(key, rec);
|
||||
pruneDecisionMap();
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`whispergate: 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) {
|
||||
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
|
||||
if (rec.needsRestore) {
|
||||
sessionDecision.delete(key);
|
||||
return {
|
||||
providerOverride: undefined,
|
||||
modelOverride: undefined,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记这次执行了 no-reply,下次需要恢复模型
|
||||
rec.needsRestore = true;
|
||||
sessionDecision.set(key, rec);
|
||||
|
||||
// 无论是否有缓存,只要 debug flag 开启就打印决策详情
|
||||
if (live.enableDebugLogs) {
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||
api.logger.info(
|
||||
`whispergate: DEBUG_NO_REPLY_TRIGGER session=${key} ` +
|
||||
`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 ?? "")} ` +
|
||||
`decision=${rec.decision.reason} ` +
|
||||
`shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` +
|
||||
`hasConvMarker=${hasConvMarker} promptLen=${prompt.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
||||
// 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,
|
||||
);
|
||||
|
||||
return {
|
||||
providerOverride: live.noReplyProvider,
|
||||
modelOverride: live.noReplyModel,
|
||||
};
|
||||
});
|
||||
|
||||
api.on("before_prompt_build", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & 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, ctx.messageProvider);
|
||||
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`whispergate: 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}`,
|
||||
);
|
||||
}
|
||||
if (!moderatorBotToken) {
|
||||
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
||||
}
|
||||
|
||||
sessionDecision.delete(key);
|
||||
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(
|
||||
`whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tryAutoScanPaddedCell();
|
||||
|
||||
// Resolve end symbols from config/policy for dynamic instruction
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols);
|
||||
api.on("gateway_stop", () => {
|
||||
stopSideCar(api.logger);
|
||||
});
|
||||
}
|
||||
|
||||
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
|
||||
return { prependContext: instruction };
|
||||
// ── Hooks (registered on every api instance — event-level dedup handles duplicates) ──
|
||||
registerBeforeModelResolveHook({
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
debugMode: config.debugMode,
|
||||
noReplyProvider: config.noReplyProvider,
|
||||
noReplyModel: config.noReplyModel,
|
||||
});
|
||||
|
||||
const interruptTailMatch = registerAgentEndHook({
|
||||
api,
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
// Speaker-list init still handled via message_received (needs OpenClaw API for channel member lookup)
|
||||
registerMessageReceivedHook({
|
||||
api,
|
||||
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,
|
||||
});
|
||||
|
||||
// ── Dirigent API (moderator service → plugin callbacks) ───────────────
|
||||
registerDirigentApi({
|
||||
api,
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Tools ──────────────────────────────────────────────────────────────
|
||||
registerDirigentTools({
|
||||
api,
|
||||
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)");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
{
|
||||
"id": "whispergate",
|
||||
"name": "WhisperGate",
|
||||
"version": "0.1.0",
|
||||
"description": "Rule-based no-reply gate with provider/model override",
|
||||
"id": "dirigent",
|
||||
"name": "Dirigent",
|
||||
"version": "0.3.0",
|
||||
"description": "Rule-based no-reply gate with provider/model override and turn management",
|
||||
"entry": "./index.ts",
|
||||
"configSchema": {
|
||||
"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/whispergate-channel-policies.json" },
|
||||
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
|
||||
"noReplyProvider": { "type": "string" },
|
||||
"noReplyModel": { "type": "string" },
|
||||
"enableDiscordControlTool": { "type": "boolean", "default": true },
|
||||
"enableWhispergatePolicyTool": { "type": "boolean", "default": true },
|
||||
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
|
||||
"discordControlApiToken": { "type": "string" },
|
||||
"discordControlCallerId": { "type": "string" },
|
||||
"enableDebugLogs": { "type": "boolean", "default": false },
|
||||
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] }
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "whispergate-plugin",
|
||||
"version": "0.1.0",
|
||||
"name": "dirigent-plugin",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "WhisperGate OpenClaw plugin",
|
||||
"description": "Dirigent OpenClaw plugin",
|
||||
"scripts": {
|
||||
"check": "node ../scripts/check-plugin-files.mjs",
|
||||
"check:rules": "node ../scripts/validate-rules.mjs"
|
||||
|
||||
132
plugin/rules.ts
132
plugin/rules.ts
@@ -1,132 +0,0 @@
|
||||
export type WhisperGateConfig = {
|
||||
enabled?: boolean;
|
||||
discordOnly?: boolean;
|
||||
listMode?: "human-list" | "agent-list";
|
||||
humanList?: string[];
|
||||
agentList?: string[];
|
||||
channelPoliciesFile?: string;
|
||||
// backward compatibility
|
||||
bypassUserIds?: string[];
|
||||
endSymbols?: string[];
|
||||
noReplyProvider: string;
|
||||
noReplyModel: 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: WhisperGateConfig, 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: WhisperGateConfig;
|
||||
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" };
|
||||
}
|
||||
353
plugin/tools/register-tools.ts
Normal file
353
plugin/tools/register-tools.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
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;
|
||||
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 getGuildIdFromSessionKey(sessionKey: string): string | undefined {
|
||||
// sessionKey doesn't encode guild — it's not available directly.
|
||||
// Guild is passed explicitly by the agent.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined {
|
||||
const m = sessionKey.match(/:discord:channel:(\d+)$/);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
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, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps;
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// 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: {
|
||||
discordUserId: { type: "string", description: "The agent's Discord user ID" },
|
||||
agentName: { type: "string", description: "Display name (optional, defaults to agentId)" },
|
||||
},
|
||||
required: ["discordUserId"],
|
||||
},
|
||||
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}.`);
|
||||
},
|
||||
}));
|
||||
}
|
||||
284
plugin/turn-manager.ts
Normal file
284
plugin/turn-manager.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Turn Manager (v2)
|
||||
*
|
||||
* Per-channel state machine governing who speaks when.
|
||||
* Called from before_model_resolve (check turn) and agent_end (advance turn).
|
||||
*/
|
||||
|
||||
export type SpeakerEntry = {
|
||||
agentId: string;
|
||||
discordUserId: string;
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
|
||||
function channelStates(): Map<string, ChannelTurnState> {
|
||||
if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map<string, ChannelTurnState>();
|
||||
return _G._tmChannelStates as Map<string, ChannelTurnState>;
|
||||
}
|
||||
|
||||
function pendingTurns(): Set<string> {
|
||||
if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set<string>();
|
||||
return _G._tmPendingTurns as Set<string>;
|
||||
}
|
||||
|
||||
function blockedPendingCounts(): Map<string, number> {
|
||||
if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map<string, number>();
|
||||
return _G._tmBlockedPendingCounts as Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getInitializingChannels(): Set<string> {
|
||||
if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
|
||||
return _G._tmInitializingChannels as Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const MAX_BLOCKED_PENDING = 3;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function isTurnPending(channelId: string, agentId: string): boolean {
|
||||
return pendingTurns().has(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
export function clearTurnPending(channelId: string, agentId: string): void {
|
||||
pendingTurns().delete(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake the channel from dormant.
|
||||
* Returns the new first speaker.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
|
||||
*/
|
||||
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]];
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
export function getDebugInfo(channelId: string) {
|
||||
const s = getState(channelId);
|
||||
if (!s) return { exists: false };
|
||||
return {
|
||||
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
294
plugin/web/control-page.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 & 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
112
plugin/web/dirigent-api.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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']) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
docker compose down
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[whispergate] building/starting no-reply API container"
|
||||
docker compose up -d --build whispergate-no-reply-api
|
||||
|
||||
echo "[whispergate] health check"
|
||||
curl -sS http://127.0.0.1:8787/health
|
||||
|
||||
echo "[whispergate] done"
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const modeArg = process.argv[2];
|
||||
if (modeArg !== "--install" && modeArg !== "--uninstall") {
|
||||
console.error("Usage: install-whispergate-openclaw.mjs --install | --uninstall");
|
||||
process.exit(2);
|
||||
}
|
||||
const mode = modeArg === "--install" ? "install" : "uninstall";
|
||||
|
||||
const env = process.env;
|
||||
const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "whispergate");
|
||||
const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway";
|
||||
const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply";
|
||||
const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";
|
||||
const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token";
|
||||
const LIST_MODE = env.LIST_MODE || "human-list";
|
||||
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
|
||||
const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]";
|
||||
const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/whispergate-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir());
|
||||
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
|
||||
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
|
||||
|
||||
const STATE_DIR = (env.STATE_DIR || "~/.openclaw/whispergate-install-records").replace(/^~(?=$|\/)/, os.homedir());
|
||||
const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/whispergate-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
|
||||
|
||||
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
||||
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-whispergate-${mode}-${ts}`;
|
||||
const RECORD_PATH = path.join(STATE_DIR, `whispergate-${ts}.json`);
|
||||
|
||||
const PATH_PLUGINS_LOAD = "plugins.load.paths";
|
||||
const PATH_PLUGIN_ENTRY = "plugins.entries.whispergate";
|
||||
const PATH_PROVIDERS = "models.providers";
|
||||
|
||||
function runOpenclaw(args, { allowFail = false } = {}) {
|
||||
try {
|
||||
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
|
||||
} catch (e) {
|
||||
if (allowFail) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getJson(pathKey) {
|
||||
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
|
||||
if (out == null || out === "") return { exists: false };
|
||||
return { exists: true, value: JSON.parse(out) };
|
||||
}
|
||||
|
||||
function setJson(pathKey, value) {
|
||||
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
|
||||
}
|
||||
|
||||
function unsetPath(pathKey) {
|
||||
runOpenclaw(["config", "unset", pathKey], { allowFail: true });
|
||||
}
|
||||
|
||||
function writeRecord(modeName, before, after) {
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
const rec = {
|
||||
mode: modeName,
|
||||
timestamp: ts,
|
||||
openclawConfigPath: OPENCLAW_CONFIG_PATH,
|
||||
backupPath: BACKUP_PATH,
|
||||
paths: before,
|
||||
applied: after,
|
||||
};
|
||||
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
|
||||
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
|
||||
}
|
||||
|
||||
function readRecord(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function findLatestInstallRecord() {
|
||||
if (!fs.existsSync(STATE_DIR)) return "";
|
||||
const files = fs
|
||||
.readdirSync(STATE_DIR)
|
||||
.filter((f) => /^whispergate-\d+\.json$/.test(f))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const f of files) {
|
||||
const p = path.join(STATE_DIR, f);
|
||||
try {
|
||||
const rec = readRecord(p);
|
||||
if (rec?.mode === "install") return p;
|
||||
} catch {
|
||||
// ignore broken records
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (mode === "install") {
|
||||
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
|
||||
console.log(`[whispergate] backup: ${BACKUP_PATH}`);
|
||||
|
||||
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
|
||||
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
|
||||
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
|
||||
console.log(`[whispergate] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
|
||||
}
|
||||
|
||||
const before = {
|
||||
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
|
||||
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
|
||||
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
|
||||
};
|
||||
|
||||
try {
|
||||
const pluginsNow = getJson("plugins").value || {};
|
||||
const plugins = typeof pluginsNow === "object" ? pluginsNow : {};
|
||||
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {};
|
||||
const paths = Array.isArray(plugins.load.paths) ? plugins.load.paths : [];
|
||||
if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH);
|
||||
plugins.load.paths = paths;
|
||||
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {};
|
||||
plugins.entries.whispergate = {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
discordOnly: true,
|
||||
listMode: LIST_MODE,
|
||||
humanList: JSON.parse(HUMAN_LIST_JSON),
|
||||
agentList: JSON.parse(AGENT_LIST_JSON),
|
||||
channelPoliciesFile: CHANNEL_POLICIES_FILE,
|
||||
endSymbols: JSON.parse(END_SYMBOLS_JSON),
|
||||
noReplyProvider: NO_REPLY_PROVIDER_ID,
|
||||
noReplyModel: NO_REPLY_MODEL_ID,
|
||||
},
|
||||
};
|
||||
setJson("plugins", plugins);
|
||||
|
||||
const providersNow = getJson(PATH_PROVIDERS).value || {};
|
||||
const providers = typeof providersNow === "object" ? providersNow : {};
|
||||
providers[NO_REPLY_PROVIDER_ID] = {
|
||||
baseUrl: NO_REPLY_BASE_URL,
|
||||
apiKey: NO_REPLY_API_KEY,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NO_REPLY_MODEL_ID,
|
||||
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
setJson(PATH_PROVIDERS, providers);
|
||||
|
||||
const after = {
|
||||
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
|
||||
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
|
||||
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
|
||||
};
|
||||
writeRecord("install", before, after);
|
||||
console.log("[whispergate] install ok (config written)");
|
||||
console.log(`[whispergate] record: ${RECORD_PATH}`);
|
||||
console.log("[whispergate] >>> restart gateway to apply: openclaw gateway restart");
|
||||
} catch (e) {
|
||||
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
|
||||
console.error(`[whispergate] install failed; rollback complete: ${String(e)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const recFile = env.RECORD_FILE || findLatestInstallRecord();
|
||||
if (!recFile || !fs.existsSync(recFile)) {
|
||||
console.error("[whispergate] no install record found. set RECORD_FILE=<path> to an install record.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
|
||||
console.log(`[whispergate] backup before uninstall: ${BACKUP_PATH}`);
|
||||
|
||||
const rec = readRecord(recFile);
|
||||
const before = rec.applied || {};
|
||||
const target = rec.paths || {};
|
||||
|
||||
try {
|
||||
const pluginsNow = getJson("plugins").value || {};
|
||||
const plugins = typeof pluginsNow === "object" ? pluginsNow : {};
|
||||
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {};
|
||||
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {};
|
||||
|
||||
if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value;
|
||||
else delete plugins.load.paths;
|
||||
|
||||
if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.whispergate = target[PATH_PLUGIN_ENTRY].value;
|
||||
else delete plugins.entries.whispergate;
|
||||
|
||||
setJson("plugins", plugins);
|
||||
|
||||
if (target[PATH_PROVIDERS]?.exists) setJson(PATH_PROVIDERS, target[PATH_PROVIDERS].value);
|
||||
else unsetPath(PATH_PROVIDERS);
|
||||
|
||||
const after = {
|
||||
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
|
||||
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
|
||||
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
|
||||
};
|
||||
writeRecord("uninstall", before, after);
|
||||
console.log("[whispergate] uninstall ok");
|
||||
console.log(`[whispergate] record: ${RECORD_PATH}`);
|
||||
} catch (e) {
|
||||
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
|
||||
console.error(`[whispergate] uninstall failed; rollback complete: ${String(e)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec node "$SCRIPT_DIR/install-whispergate-openclaw.mjs" "$@"
|
||||
254
scripts/install.mjs
Executable file
254
scripts/install.mjs
Executable file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
// === 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;
|
||||
let argOpenClawDir = null;
|
||||
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 (!modeArg) {
|
||||
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" };
|
||||
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 resolveOpenClawDir() {
|
||||
if (argOpenClawDir) {
|
||||
const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`);
|
||||
return dir;
|
||||
}
|
||||
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 __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 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}/no-reply/v1`;
|
||||
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
|
||||
|
||||
function runOpenclaw(args, allowFail = false) {
|
||||
try { return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); }
|
||||
catch (e) { if (allowFail) return null; throw e; }
|
||||
}
|
||||
function getJson(pathKey) {
|
||||
const out = runOpenclaw(["config", "get", pathKey, "--json"], 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 syncDirRecursive(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.cpSync(src, dest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (mode === "install") {
|
||||
title("Install Dirigent with Skills");
|
||||
|
||||
step(1, 7, "build dist assets");
|
||||
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
||||
const sidecarSrc = path.resolve(REPO_ROOT, "services");
|
||||
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
|
||||
fs.rmSync(distPlugin, { recursive: true, force: true });
|
||||
syncDirRecursive(pluginSrc, distPlugin);
|
||||
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
|
||||
ok("dist assets built");
|
||||
|
||||
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
|
||||
ok("plugin files installed");
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(5, 7, "configure no-reply provider");
|
||||
setJson(`models.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 }],
|
||||
});
|
||||
ok("provider configured");
|
||||
|
||||
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(7, 7, "enable plugin in allowlist");
|
||||
const allow = getJson("plugins.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);
|
||||
}
|
||||
|
||||
if (mode === "uninstall") {
|
||||
title("Uninstall");
|
||||
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"); }
|
||||
runOpenclaw(["config", "unset", "plugins.entries.dirigent"], true);
|
||||
ok("removed plugin entry");
|
||||
|
||||
step(2, 4, "remove plugin load path");
|
||||
const paths = getJson("plugins.load.paths") || [];
|
||||
setJson("plugins.load.paths", paths.filter((p) => p !== PLUGIN_INSTALL_DIR));
|
||||
ok("removed load path");
|
||||
|
||||
step(3, 4, "remove installed files");
|
||||
if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true });
|
||||
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);
|
||||
@@ -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", "whispergate");
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const f of ["index.ts", "rules.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
|
||||
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
|
||||
}
|
||||
|
||||
console.log(`packaged plugin to ${outDir}`);
|
||||
@@ -1,13 +1,13 @@
|
||||
const pluginPath = process.argv[2] || "/opt/WhisperGate/plugin";
|
||||
const pluginPath = process.argv[2] || "/opt/Dirigent/plugin";
|
||||
const provider = process.argv[3] || "openai";
|
||||
const model = process.argv[4] || "whispergate-no-reply-v1";
|
||||
const model = process.argv[4] || "dirigent-no-reply-v1";
|
||||
const bypass = (process.argv[5] || "").split(",").filter(Boolean);
|
||||
|
||||
const payload = {
|
||||
plugins: {
|
||||
load: { paths: [pluginPath] },
|
||||
entries: {
|
||||
whispergate: {
|
||||
dirigent: {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8790}"
|
||||
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
||||
CALLER_ID="${CALLER_ID:-}"
|
||||
|
||||
AUTH_HEADER=()
|
||||
if [[ -n "$AUTH_TOKEN" ]]; then
|
||||
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
||||
fi
|
||||
|
||||
CALLER_HEADER=()
|
||||
if [[ -n "$CALLER_ID" ]]; then
|
||||
CALLER_HEADER=(-H "X-OpenClaw-Caller-Id: ${CALLER_ID}")
|
||||
fi
|
||||
|
||||
echo "[1] health"
|
||||
curl -sS "${BASE_URL}/health" | sed -n '1,20p'
|
||||
|
||||
if [[ -z "${GUILD_ID:-}" ]]; then
|
||||
echo "skip action checks: set GUILD_ID (and optional CHANNEL_ID) to run dryRun actions"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[2] dry-run private create"
|
||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
"${CALLER_HEADER[@]}" \
|
||||
-d "{\"action\":\"channel-private-create\",\"guildId\":\"${GUILD_ID}\",\"name\":\"wg-dryrun\",\"dryRun\":true}" \
|
||||
| sed -n '1,80p'
|
||||
|
||||
if [[ -n "${CHANNEL_ID:-}" ]]; then
|
||||
echo "[3] dry-run private update"
|
||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
"${CALLER_HEADER[@]}" \
|
||||
-d "{\"action\":\"channel-private-update\",\"guildId\":\"${GUILD_ID}\",\"channelId\":\"${CHANNEL_ID}\",\"mode\":\"merge\",\"dryRun\":true}" \
|
||||
| sed -n '1,100p'
|
||||
fi
|
||||
|
||||
echo "[4] member-list (limit=1)"
|
||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
"${CALLER_HEADER[@]}" \
|
||||
-d "{\"action\":\"member-list\",\"guildId\":\"${GUILD_ID}\",\"limit\":1,\"fields\":[\"user.id\",\"user.username\"]}" \
|
||||
| sed -n '1,120p'
|
||||
|
||||
echo "smoke-discord-control: done"
|
||||
@@ -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":"whispergate-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":"whispergate-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
211
scripts/test-features.mjs
Normal 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); });
|
||||
@@ -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
111
services/main.mjs
Normal 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"));
|
||||
514
services/moderator/index.mjs
Normal file
514
services/moderator/index.mjs
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
131
services/no-reply-api/server.mjs
Normal file
131
services/no-reply-api/server.mjs
Normal 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}`);
|
||||
});
|
||||
}
|
||||
29
skills/discord-guilds/SKILL.md
Normal file
29
skills/discord-guilds/SKILL.md
Normal 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"
|
||||
```
|
||||
43
skills/discord-guilds/scripts/add-guild
Executable file
43
skills/discord-guilds/scripts/add-guild
Executable 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}`);
|
||||
175
test/discussion-hooks.test.ts
Normal file
175
test/discussion-hooks.test.ts
Normal 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);
|
||||
});
|
||||
420
test/discussion-service.test.ts
Normal file
420
test/discussion-service.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
141
test/mode-compatibility.test.ts
Normal file
141
test/mode-compatibility.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
106
test/multi-message-mode.test.ts
Normal file
106
test/multi-message-mode.test.ts
Normal 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
215
test/register-tools.test.ts
Normal 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
183
test/shuffle-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user