Merge pull request 'refactor' (#22) from refactor into main
Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
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.
|
||||
20
Makefile
20
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin
|
||||
.PHONY: check check-rules check-files smoke install
|
||||
|
||||
check:
|
||||
cd plugin && npm run check
|
||||
@@ -6,21 +6,11 @@ check:
|
||||
check-rules:
|
||||
node scripts/validate-rules.mjs
|
||||
|
||||
test-api:
|
||||
node scripts/test-no-reply-api.mjs
|
||||
|
||||
up:
|
||||
./scripts/dev-up.sh
|
||||
|
||||
down:
|
||||
./scripts/dev-down.sh
|
||||
check-files:
|
||||
node scripts/check-plugin-files.mjs
|
||||
|
||||
smoke:
|
||||
./scripts/smoke-no-reply-api.sh
|
||||
|
||||
render-config:
|
||||
node scripts/render-openclaw-config.mjs
|
||||
|
||||
package-plugin:
|
||||
node scripts/package-plugin.mjs
|
||||
|
||||
install:
|
||||
node scripts/install.mjs --install
|
||||
|
||||
181
README.md
181
README.md
@@ -1,136 +1,117 @@
|
||||
# Dirigent
|
||||
|
||||
Rule-based no-reply gate + turn manager for OpenClaw (Discord).
|
||||
Turn-management and moderation plugin for OpenClaw (Discord).
|
||||
|
||||
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
|
||||
|
||||
## What it does
|
||||
|
||||
Dirigent adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels:
|
||||
Dirigent adds deterministic routing and **turn-based speaking** for multi-agent Discord channels:
|
||||
|
||||
- **Rule gate (before_model_resolve)**
|
||||
1. Non-Discord → skip
|
||||
2. Sender in bypass list / human list → skip
|
||||
3. Message ends with configured end symbol → skip
|
||||
4. Otherwise → route to no-reply model/provider
|
||||
- **Rule gate (`before_model_resolve`)**
|
||||
- Non-speaker agents → routed to no-reply model (silent turn)
|
||||
- Dormant channels → all agents suppressed until a human message wakes them
|
||||
- Configurable per-channel mode: `chat`, `work`, `discussion`, `report`, `none`
|
||||
|
||||
- **End-symbol enforcement**
|
||||
- Injects instruction: `Your response MUST end with 🔚…`
|
||||
- In group chats, also injects: "If not relevant, reply NO_REPLY"
|
||||
- **Turn management**
|
||||
- Only the current speaker responds; others are silenced
|
||||
- Turn advances when the current speaker ends with a configured symbol or returns `NO_REPLY`
|
||||
- Full round of silence → channel enters **dormant** state
|
||||
- Human message → wakes dormant channel, triggers first speaker
|
||||
|
||||
- **Scheduling identifier (moderator handoff)**
|
||||
- Configurable identifier (default: `➡️`) used by the moderator bot
|
||||
- Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic, just a scheduling signal)
|
||||
- Agent receives instruction explaining the identifier is meaningless — check chat history and decide
|
||||
- **Moderator bot sidecar**
|
||||
- Dedicated Discord Gateway connection (separate bot token) for real-time message push
|
||||
- Sends schedule-trigger messages (`<@USER_ID>➡️`) to signal speaker turns
|
||||
- Notifies the plugin via HTTP callback on new messages (wake/interrupt)
|
||||
|
||||
- **Turn-based speaking (multi-bot)**
|
||||
- Only the current speaker is allowed to respond
|
||||
- Others are forced to no-reply
|
||||
- Turn advances on **end-symbol** or **NO_REPLY**
|
||||
- If all bots NO_REPLY, channel becomes **dormant** until a new human message
|
||||
- **Discussion mode**
|
||||
- Agents can initiate a structured discussion via `create-discussion-channel` tool
|
||||
- Initiator calls `discussion-complete` to conclude; summary is posted to the callback channel
|
||||
|
||||
- **Agent identity injection**
|
||||
- Injects agent name, Discord accountId, and Discord userId into group chat prompts
|
||||
|
||||
- **Human @mention override**
|
||||
- When a `humanList` user @mentions agents, temporarily overrides turn order
|
||||
- Only mentioned agents cycle; original order restores when cycle completes
|
||||
|
||||
- **Per-channel policy runtime**
|
||||
- Policies stored in a standalone JSON file
|
||||
- Update at runtime via `dirigent_policy_set` / `dirigent_policy_delete` tools
|
||||
|
||||
- **Discord control actions (optional)**
|
||||
- Private channel create/update + member list
|
||||
- Via `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` tools
|
||||
- **Channel management tools**
|
||||
- `create-chat-channel`, `create-work-channel`, `create-report-channel` — create typed channels
|
||||
- `create-discussion-channel`, `discussion-complete` — discussion lifecycle
|
||||
- `dirigent-register` — register an agent identity
|
||||
|
||||
---
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence)
|
||||
- `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY`
|
||||
- Discord admin actions are now handled in-plugin via direct Discord REST API calls (no sidecar service)
|
||||
- `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis
|
||||
- `scripts/` — smoke/dev/helper checks
|
||||
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`)
|
||||
- `CHANGELOG.md` — milestone summary
|
||||
```
|
||||
plugin/ OpenClaw plugin (hooks, tools, commands, web UI)
|
||||
core/ Channel store, identity registry, moderator REST helpers
|
||||
hooks/ before_model_resolve, agent_end, message_received
|
||||
tools/ Agent-facing tools
|
||||
commands/ Slash commands
|
||||
web/ Control page + Dirigent API (HTTP routes)
|
||||
services/ Sidecar process — spawned automatically by the plugin
|
||||
main.mjs Unified entry point, routes /no-reply/* and /moderator/*
|
||||
no-reply-api/ OpenAI-compatible server that always returns NO_REPLY
|
||||
moderator/ Discord Gateway client + HTTP control endpoints
|
||||
scripts/ Dev helpers
|
||||
docs/ Architecture and integration notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start (no Docker)
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd no-reply-api
|
||||
node server.mjs
|
||||
node scripts/install.mjs --install
|
||||
```
|
||||
|
||||
Then render config snippet:
|
||||
This copies `plugin/` and `services/` into the OpenClaw plugin directory and registers skills.
|
||||
|
||||
---
|
||||
|
||||
## Sidecar
|
||||
|
||||
The sidecar (`services/main.mjs`) is spawned automatically when openclaw-gateway starts. It exposes:
|
||||
|
||||
| Path prefix | Description |
|
||||
|---|---|
|
||||
| `/no-reply/*` | No-reply model API (`/v1/chat/completions`, `/v1/responses`) |
|
||||
| `/moderator/*` | Moderator bot control (`/send`, `/create-channel`, `/me`, …) |
|
||||
| `/health` | Combined health check |
|
||||
|
||||
Port is configured via `sideCarPort` (default `8787`).
|
||||
|
||||
Smoke-test after gateway start:
|
||||
|
||||
```bash
|
||||
node scripts/render-openclaw-config.mjs
|
||||
```
|
||||
|
||||
See `docs/RUN_MODES.md` for Docker mode.
|
||||
Discord extension capabilities: `docs/DISCORD_CONTROL.md`.
|
||||
|
||||
---
|
||||
|
||||
## Runtime tools & commands
|
||||
|
||||
### Tools (6 individual tools)
|
||||
|
||||
**Discord control:**
|
||||
- `dirigent_discord_channel_create` — Create private channel
|
||||
- `dirigent_discord_channel_update` — Update channel permissions
|
||||
- `dirigent_discord_member_list` — List guild members
|
||||
|
||||
**Policy management:**
|
||||
- `dirigent_policy_get` — Get all policies
|
||||
- `dirigent_policy_set` — Set/update channel policy
|
||||
- `dirigent_policy_delete` — Delete channel policy
|
||||
|
||||
> Turn management is internal to the plugin (not exposed as tools).
|
||||
|
||||
> See `FEAT.md` for full feature documentation.
|
||||
|
||||
### Slash command (Discord)
|
||||
|
||||
```
|
||||
/dirigent status
|
||||
/dirigent turn-status
|
||||
/dirigent turn-advance
|
||||
/dirigent turn-reset
|
||||
make smoke
|
||||
# or:
|
||||
./scripts/smoke-no-reply-api.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config highlights
|
||||
## Plugin config
|
||||
|
||||
Common options (see `docs/INTEGRATION.md`):
|
||||
Key options (in `openclaw.json` under `plugins.entries.dirigent.config`):
|
||||
|
||||
- `listMode`: `human-list` or `agent-list`
|
||||
- `humanList`, `agentList`
|
||||
- `endSymbols`
|
||||
- `schedulingIdentifier` (default `➡️`)
|
||||
- `waitIdentifier` (default `👤`) — agent ends with this to pause all agents until human replies
|
||||
- `channelPoliciesFile` (per-channel overrides)
|
||||
- `moderatorBotToken` (handoff messages)
|
||||
- `enableDebugLogs`, `debugLogChannelIds`
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `moderatorBotToken` | — | Discord bot token for the moderator/sidecar bot |
|
||||
| `scheduleIdentifier` | `➡️` | Symbol appended to schedule-trigger mentions |
|
||||
| `listMode` | `human-list` | `human-list` or `agent-list` |
|
||||
| `humanList` | `[]` | Discord user IDs treated as humans (bypass turn gate) |
|
||||
| `agentList` | `[]` | Discord user IDs treated as agents (when `listMode=agent-list`) |
|
||||
| `noReplyProvider` | `dirigent` | Provider ID for the no-reply model |
|
||||
| `noReplyModel` | `no-reply` | Model ID for the no-reply model |
|
||||
| `sideCarPort` | `8787` | Port the sidecar listens on |
|
||||
| `debugMode` | `false` | Enable verbose debug logging |
|
||||
| `debugLogChannelIds` | `[]` | Channel IDs that receive debug log messages |
|
||||
| `channelPoliciesFile` | `~/.openclaw/dirigent-channel-policies.json` | Per-channel policy overrides |
|
||||
|
||||
---
|
||||
|
||||
## Development plan (incremental commits)
|
||||
## Dev commands
|
||||
|
||||
- [x] Task 1: project docs + structure
|
||||
- [x] Task 2: no-reply API MVP
|
||||
- [x] Task 3: plugin MVP with rule chain
|
||||
- [x] Task 4: sample config + quick verification scripts
|
||||
- [x] Task 5: plugin rule extraction + hardening
|
||||
- [x] Task 6: containerization + compose
|
||||
- [x] Task 7: plugin usage notes
|
||||
- [x] Task 8: sender normalization + TTL + one-shot decision
|
||||
- [x] Task 9: auth-aware no-reply API
|
||||
- [x] Task 10: smoke test helpers
|
||||
- [x] Task 11: plugin structure checker
|
||||
- [x] Task 12: rollout checklist
|
||||
```bash
|
||||
make check # TypeScript check (plugin/)
|
||||
make check-rules # Validate rule-case fixtures
|
||||
make check-files # Verify required files exist
|
||||
make smoke # Smoke-test no-reply endpoint (sidecar must be running)
|
||||
make install # Install plugin + sidecar into OpenClaw
|
||||
```
|
||||
|
||||
281
TEST-PLAN.md
Normal file
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 模型特性)。*
|
||||
@@ -1,11 +0,0 @@
|
||||
services:
|
||||
dirigent-no-reply-api:
|
||||
build:
|
||||
context: ./no-reply-api
|
||||
container_name: dirigent-no-reply-api
|
||||
ports:
|
||||
- "8787:8787"
|
||||
environment:
|
||||
- PORT=8787
|
||||
- NO_REPLY_MODEL=dirigent-no-reply-v1
|
||||
restart: unless-stopped
|
||||
@@ -14,6 +14,9 @@
|
||||
"agentList": [],
|
||||
"endSymbols": ["🔚"],
|
||||
"schedulingIdentifier": "➡️",
|
||||
"multiMessageStartMarker": "↗️",
|
||||
"multiMessageEndMarker": "↙️",
|
||||
"multiMessagePromptMarker": "⤵️",
|
||||
"channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json",
|
||||
"noReplyProvider": "dirigentway",
|
||||
"noReplyModel": "no-reply",
|
||||
|
||||
@@ -58,6 +58,9 @@ Environment overrides:
|
||||
- `CHANNEL_POLICIES_FILE` (standalone channel policy file path)
|
||||
- `CHANNEL_POLICIES_JSON` (only used to initialize file when missing)
|
||||
- `END_SYMBOLS_JSON`
|
||||
- `MULTI_MESSAGE_START_MARKER`
|
||||
- `MULTI_MESSAGE_END_MARKER`
|
||||
- `MULTI_MESSAGE_PROMPT_MARKER`
|
||||
|
||||
The script:
|
||||
- writes via `openclaw config set ... --json`
|
||||
@@ -76,3 +79,5 @@ Policy state semantics:
|
||||
|
||||
- Keep no-reply API bound to loopback/private network.
|
||||
- If you use API auth, set `AUTH_TOKEN` and align provider apiKey usage.
|
||||
- Multi-message mode markers default to `↗️` / `↙️` / `⤵️` when no overrides are supplied.
|
||||
- Shuffle mode is not configured globally in the current implementation; it is a per-channel runtime toggle controlled with `/dirigent turn-shuffling`, `/dirigent turn-shuffling on`, and `/dirigent turn-shuffling off`.
|
||||
|
||||
@@ -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": "dirigent-no-reply-api",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dirigent-no-reply-api",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "dirigent-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 || "no-reply";
|
||||
const authToken = process.env.AUTH_TOKEN || "";
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function isAuthorized(req) {
|
||||
if (!authToken) return true;
|
||||
const header = req.headers.authorization || "";
|
||||
return header === `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
function noReplyChatCompletion(reqBody) {
|
||||
return {
|
||||
id: `chatcmpl_dirigent_${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: reqBody?.model || modelName,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "NO_REPLY" },
|
||||
finish_reason: "stop"
|
||||
}
|
||||
],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
function noReplyResponses(reqBody) {
|
||||
return {
|
||||
id: `resp_dirigent_${Date.now()}`,
|
||||
object: "response",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
model: reqBody?.model || modelName,
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "NO_REPLY" }]
|
||||
}
|
||||
],
|
||||
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
function listModels() {
|
||||
return {
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
id: modelName,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "dirigent"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
return sendJson(res, 200, { ok: true, service: "dirigent-no-reply-api", model: modelName });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/v1/models") {
|
||||
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
||||
return sendJson(res, 200, listModels());
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return sendJson(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
if (!isAuthorized(req)) {
|
||||
return sendJson(res, 401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 1_000_000) req.destroy();
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: "invalid_json" });
|
||||
}
|
||||
|
||||
if (req.url === "/v1/chat/completions") {
|
||||
return sendJson(res, 200, noReplyChatCompletion(parsed));
|
||||
}
|
||||
|
||||
if (req.url === "/v1/responses") {
|
||||
return sendJson(res, 200, noReplyResponses(parsed));
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { error: "not_found" });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[dirigent-no-reply-api] listening on :${port}`);
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"files": [
|
||||
"dist/",
|
||||
"plugin/",
|
||||
"no-reply-api/",
|
||||
"services/",
|
||||
|
||||
"docs/",
|
||||
"scripts/install.mjs",
|
||||
@@ -17,7 +17,8 @@
|
||||
"TASKLIST.md"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/",
|
||||
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/ && cp -r services dist/dirigent/services",
|
||||
"test": "node --test --experimental-strip-types test/**/*.test.ts",
|
||||
"postinstall": "node scripts/install.mjs --install",
|
||||
"uninstall": "node scripts/install.mjs --uninstall",
|
||||
"update": "node scripts/install.mjs --update"
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
### 1.2 配置项
|
||||
|
||||
建议新增以下可配置项:
|
||||
新增并已实现以下可配置项:
|
||||
|
||||
- `multiMessageStartMarker`:默认 `↗️`
|
||||
- `multiMessageEndMarker`:默认 `↙️`
|
||||
- `multiMessagePromptMarker`:默认 `⤵️`
|
||||
|
||||
这些配置应加入插件 config schema,并在运行时可被 hook/turn-manager 使用。
|
||||
这些配置已加入插件 config schema,并在运行时被 `message-received` / `before-message-write` / `before-model-resolve` 使用。
|
||||
|
||||
---
|
||||
|
||||
@@ -100,13 +100,17 @@ multi-message mode 应与 discussion channel / wait-for-human / no-reply 决策
|
||||
|
||||
并新增 slash command:
|
||||
|
||||
- `/turn-shuffling on`
|
||||
- `/turn-shuffling off`
|
||||
- `/turn-shuffling`(查看当前状态)
|
||||
- `/dirigent turn-shuffling on`
|
||||
- `/dirigent turn-shuffling off`
|
||||
- `/dirigent turn-shuffling`(查看当前状态)
|
||||
|
||||
该状态应与 channel 级 policy / runtime state 做清晰分工:
|
||||
- 若只是运行时开关,可放 runtime memory
|
||||
- 若希望重启保留,则需要落盘策略
|
||||
当前实现结论:
|
||||
- `shuffling` 是 **channel 级 runtime state**,存放在 `plugin/core/channel-modes.ts`
|
||||
- 默认值为 `false`
|
||||
- 当前版本**不新增**全局 `shuffle default` 配置项
|
||||
- 重启后会恢复为默认关闭,如需开启需要再次执行命令
|
||||
|
||||
这样与现有实现保持一致,也避免把一次性的实验性调度偏好混入全局静态配置。
|
||||
|
||||
---
|
||||
|
||||
@@ -194,15 +198,52 @@ multi-message mode 应与 discussion channel / wait-for-human / no-reply 决策
|
||||
|
||||
---
|
||||
|
||||
## 6. 结论
|
||||
## 6. 实现状态
|
||||
|
||||
`feat/new-feat-notes` 分支中的内容可以整合为 `main` 下的一份独立规划文档,建议命名为:
|
||||
Multi-Message Mode 与 Shuffle Mode 已经在代码中实现,包括:
|
||||
|
||||
- `plans/CHANNEL_MODES_AND_SHUFFLE.md`
|
||||
- Multi-Message Mode 实现:
|
||||
- `plugin/core/channel-modes.ts` - 管理 channel 运行时状态
|
||||
- `plugin/hooks/message-received.ts` - 检测 start/end marker 并切换模式
|
||||
- `plugin/hooks/before-model-resolve.ts` - 在 multi-message mode 中强制 no-reply
|
||||
- 配置项 `multiMessageStartMarker` (默认 `↗️`)、`multiMessageEndMarker` (默认 `↙️`)、`multiMessagePromptMarker` (默认 `⤵️`)
|
||||
- 在 `plugin/openclaw.plugin.json` 中添加了相应的配置 schema
|
||||
|
||||
其定位是:
|
||||
- 汇总 multi-message mode 与 shuffle mode 的产品行为
|
||||
- 明确它们与现有 turn-manager / moderator / no-reply 机制的关系
|
||||
- 为后续代码开发和 TASKLIST 拆分提供依据
|
||||
- Shuffle Mode 实现:
|
||||
- `plugin/core/channel-modes.ts` - 管理 shuffle 状态
|
||||
- `plugin/turn-manager.ts` - 在每轮结束后根据 shuffle 设置决定是否重洗牌
|
||||
- `/dirigent turn-shuffling` slash command 实现,支持 `on`/`off`/`status` 操作
|
||||
- 确保上一轮最后发言者不会在下一轮中成为第一位
|
||||
- 当前行为是运行时开关,默认关闭,不落盘
|
||||
|
||||
后续应将对应开发任务补充进 `plans/TASKLIST.md`。
|
||||
## 7. 验收清单
|
||||
|
||||
### Multi-Message Mode 验收
|
||||
- [x] 人类发送 start marker (`↗️`) 后进入 multi-message 模式
|
||||
- [x] multi-message 模式中 Agent 被 no-reply 覆盖
|
||||
- [x] 每条人类追加消息都触发 prompt marker (`⤵️`)
|
||||
- [x] 人类发送 end marker (`↙️`) 后退出 multi-message 模式
|
||||
- [x] 退出后 moderator 正确唤醒下一位 Agent
|
||||
- [x] moderator prompt marker 不会触发回环
|
||||
- [x] 与 waiting-for-human 模式兼容
|
||||
- [x] 与 mention override 模式兼容
|
||||
|
||||
### Shuffle Mode 验收
|
||||
- [x] `/dirigent turn-shuffling on/off` 命令生效
|
||||
- [x] shuffling 关闭时 turn order 保持不变
|
||||
- [x] shuffling 开启时每轮结束后会重洗牌
|
||||
- [x] 上一轮最后发言者不会在下一轮中成为第一位
|
||||
- [x] 双 Agent 场景行为符合预期
|
||||
- [x] 单 Agent 场景不会异常
|
||||
- [x] 与 dormant 状态兼容
|
||||
- [x] 与 mention override 兼容
|
||||
|
||||
### 配置项验收
|
||||
- [x] `multiMessageStartMarker` 配置项生效
|
||||
- [x] `multiMessageEndMarker` 配置项生效
|
||||
- [x] `multiMessagePromptMarker` 配置项生效
|
||||
- [x] 配置项在 `plugin/openclaw.plugin.json` 中正确声明
|
||||
|
||||
## 8. 结论
|
||||
|
||||
Multi-Message Mode 与 Shuffle Mode 已成功集成到 Dirigent 插件中,与现有的 turn-manager、moderator handoff、no-reply override 机制协同工作,为用户提供更灵活的多 Agent 协作控制能力。
|
||||
12
plans/CSM.md
12
plans/CSM.md
@@ -195,7 +195,7 @@ moderator bot 的工作流完全不使用模型,所有输出均由模板字符
|
||||
|
||||
当检测到某 channel 为讨论模式 channel 时,moderator bot 自动发 kickoff message。
|
||||
|
||||
建议内容结构如下:
|
||||
建议内容结构如下(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`):
|
||||
|
||||
```text
|
||||
[Discussion Started]
|
||||
@@ -210,7 +210,7 @@ Instructions:
|
||||
2. Work toward a concrete conclusion.
|
||||
3. When the initiator decides the goal has been achieved, the initiator must:
|
||||
- write a summary document to a file
|
||||
- call the tool: discuss-callback
|
||||
- call the tool: discuss-callback(summaryPath)
|
||||
- provide the summary document path
|
||||
|
||||
Completion rule:
|
||||
@@ -255,10 +255,11 @@ After callback:
|
||||
[Discussion Idle]
|
||||
|
||||
No agent responded in the latest discussion round.
|
||||
If the discussion goal has been achieved, the initiator should now:
|
||||
If the discussion goal has already been achieved, the initiator should now:
|
||||
1. write the discussion summary to a file in the workspace
|
||||
2. call discuss-callback with the summary file path
|
||||
|
||||
This reminder does not mean the discussion was automatically summarized or closed.
|
||||
If more discussion is still needed, continue the discussion in this channel.
|
||||
```
|
||||
|
||||
@@ -284,6 +285,7 @@ If more discussion is still needed, continue the discussion in this channel.
|
||||
This discussion channel has been closed.
|
||||
It is now kept for archive/reference only.
|
||||
Further discussion in this channel is ignored.
|
||||
If follow-up work is needed, continue it from the origin work channel instead.
|
||||
```
|
||||
|
||||
这部分实现明确采用已有“在指定 session 上临时覆盖为 no-reply 模型”的方式,而不是修改 Agent 的全局默认模型。
|
||||
@@ -331,7 +333,7 @@ Further discussion in this channel is ignored.
|
||||
- 提供结果文档路径
|
||||
- 用新消息唤醒原工作 channel 上的 Agent 继续执行
|
||||
|
||||
建议模板:
|
||||
建议模板(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`):
|
||||
|
||||
```text
|
||||
[Discussion Result Ready]
|
||||
@@ -473,6 +475,8 @@ moderator bot 在新 channel 中发布 kickoff message。
|
||||
### 12.2 展开讨论
|
||||
参与 Agent 在该 channel 中按现有顺序讨论机制发言。
|
||||
|
||||
讨论 channel 的 participant 集合继续复用现有 channel member bootstrap 逻辑:由 `plugin/core/turn-bootstrap.ts` 调用 `fetchVisibleChannelBotAccountIds(...)` 基于 Discord 可见成员与已有 account 映射发现可参与的 bot account,再交给 `initTurnOrder(...)` 建立轮转状态,而不是为 discussion 模式额外维护一套成员发现流程。
|
||||
|
||||
如果轮转一圈无人发言,则 moderator 提醒发起者:
|
||||
- 若已达成目标,请写总结文档并 callback
|
||||
|
||||
|
||||
@@ -3,342 +3,380 @@
|
||||
## A. CSM / Discussion Callback
|
||||
|
||||
### A1. 需求与方案冻结
|
||||
- [ ] 通读并确认 `plans/CSM.md` 中的 MVP 范围、非目标和边界条件
|
||||
- [ ] 确认 CSM 第一版只新增一条对外工具:`discuss-callback`
|
||||
- [ ] 确认 `discord_channel_create` 仅做参数扩展,不改变普通建频道行为
|
||||
- [ ] 确认讨论结束后的 session 级 no-reply 覆盖继续沿用现有插件机制
|
||||
- [ ] 确认 `summaryPath` 的合法范围仅限发起讨论 Agent 的 workspace
|
||||
- [ ] 确认 discussion metadata 是否仅做内存态,还是需要落盘恢复
|
||||
- [x] 通读并确认 `plans/CSM.md` 中的 MVP 范围、非目标和边界条件
|
||||
- [x] 确认 CSM 第一版只新增一条对外工具:`discuss-callback`
|
||||
- [x] 确认 `discord_channel_create` 仅做参数扩展,不改变普通建频道行为
|
||||
- [x] 确认讨论结束后的 session 级 no-reply 覆盖继续沿用现有插件机制
|
||||
- [x] 确认 `summaryPath` 的合法范围仅限发起讨论 Agent 的 workspace
|
||||
- [x] 确认 discussion metadata 是否仅做内存态,还是需要落盘恢复
|
||||
|
||||
### A2. 模块拆分与落点确认
|
||||
- [ ] 确认 `plugin/tools/register-tools.ts` 负责:
|
||||
- [ ] 扩展 `discord_channel_create`
|
||||
- [ ] 注册 `discuss-callback`
|
||||
- [ ] 确认 `plugin/core/` 下新增 discussion metadata 管理模块
|
||||
- [ ] 确认 `plugin/core/moderator-discord.ts` 继续负责 moderator 发消息能力
|
||||
- [ ] 确认 `plugin/turn-manager.ts` 仅负责 turn 状态与轮转,不直接承担业务文案拼接
|
||||
- [ ] 确认 discussion 业务编排逻辑应放在新模块,而不是散落到多个 hook 中
|
||||
- [ ] 确认 origin callback 与 discussion close 逻辑的调用路径
|
||||
- [. ] 确认 `plugin/tools/register-tools.ts` 负责:
|
||||
- [. ] 扩展 `discord_channel_create`
|
||||
- [. ] 注册 `discuss-callback`
|
||||
- [. ] 确认 `plugin/core/` 下新增 discussion metadata 管理模块
|
||||
- [x] 确认 `plugin/core/moderator-discord.ts` 继续负责 moderator 发消息能力
|
||||
- [x] 确认 `plugin/turn-manager.ts` 仅负责 turn 状态与轮转,不直接承担业务文案拼接
|
||||
- [x] 确认 discussion 业务编排逻辑应放在新模块,而不是散落到多个 hook 中
|
||||
- [x] 确认 origin callback 与 discussion close 逻辑的调用路径
|
||||
|
||||
### A3. `plugin/tools/register-tools.ts`
|
||||
#### A3.1 扩展 `discord_channel_create`
|
||||
- [ ] 阅读当前 `discord_channel_create` 的参数定义与执行逻辑
|
||||
- [ ] 为 `discord_channel_create` 增加可选参数 `callbackChannelId`
|
||||
- [ ] 为 `discord_channel_create` 增加可选参数 `discussGuide`
|
||||
- [ ] 添加联动校验:若传 `callbackChannelId`,则必须传 `discussGuide`
|
||||
- [ ] 保证不传 `callbackChannelId` 时,行为与当前版本完全一致
|
||||
- [ ] 创建成功后识别是否为 discussion 模式 channel
|
||||
- [ ] 在 discussion 模式下调用 metadata 初始化逻辑
|
||||
- [ ] 在 discussion 模式下调用 moderator kickoff 发送逻辑
|
||||
- [x] 阅读当前 `discord_channel_create` 的参数定义与执行逻辑
|
||||
- [x] 为 `discord_channel_create` 增加可选参数 `callbackChannelId`
|
||||
- [x] 为 `discord_channel_create` 增加可选参数 `discussGuide`
|
||||
- [x] 添加联动校验:若传 `callbackChannelId`,则必须传 `discussGuide`
|
||||
- [x] 保证不传 `callbackChannelId` 时,行为与当前版本完全一致
|
||||
- [x] 创建成功后识别是否为 discussion 模式 channel
|
||||
- [x] 在 discussion 模式下调用 metadata 初始化逻辑
|
||||
- [x] 在 discussion 模式下调用 moderator kickoff 发送逻辑
|
||||
|
||||
#### A3.2 注册 `discuss-callback`
|
||||
- [ ] 定义 `discuss-callback` 的 parameters schema
|
||||
- [ ] 注册新工具 `discuss-callback`
|
||||
- [ ] 将 `discuss-callback` 执行逻辑接到 discussion service / manager
|
||||
- [ ] 为工具失败场景返回可读错误信息
|
||||
- [x] 定义 `discuss-callback` 的 parameters schema
|
||||
- [x] 注册新工具 `discuss-callback`
|
||||
- [x] 将 `discuss-callback` 执行逻辑接到 discussion service / manager
|
||||
- [x] 为工具失败场景返回可读错误信息
|
||||
|
||||
### A4. `plugin/core/` 新增 discussion metadata/service 模块
|
||||
#### A4.1 新建 metadata/state 模块
|
||||
- [ ] 新建 discussion state 类型定义文件(如 `plugin/core/discussion-state.ts`)
|
||||
- [ ] 定义 discussion metadata 类型:
|
||||
- [ ] `mode`
|
||||
- [ ] `discussionChannelId`
|
||||
- [ ] `originChannelId`
|
||||
- [ ] `initiatorAgentId`
|
||||
- [ ] `initiatorSessionId`
|
||||
- [ ] `discussGuide`
|
||||
- [ ] `status`
|
||||
- [ ] `createdAt`
|
||||
- [ ] `completedAt`
|
||||
- [ ] `summaryPath`
|
||||
- [ ] 提供按 `discussionChannelId` 查询 metadata 的方法
|
||||
- [ ] 提供创建 metadata 的方法
|
||||
- [ ] 提供更新状态的方法
|
||||
- [ ] 提供关闭 discussion channel 的状态写入方法
|
||||
- [x] 新建 discussion state 类型定义文件(如 `plugin/core/discussion-state.ts`)
|
||||
- [x] 定义 discussion metadata 类型:
|
||||
- [x] `mode`
|
||||
- [x] `discussionChannelId`
|
||||
- [x] `originChannelId`
|
||||
- [x] `initiatorAgentId`
|
||||
- [x] `initiatorSessionId`
|
||||
- [x] `discussGuide`
|
||||
- [x] `status`
|
||||
- [x] `createdAt`
|
||||
- [x] `completedAt`
|
||||
- [x] `summaryPath`
|
||||
- [x] 提供按 `discussionChannelId` 查询 metadata 的方法
|
||||
- [x] 提供创建 metadata 的方法
|
||||
- [x] 提供更新状态的方法
|
||||
- [x] 提供关闭 discussion channel 的状态写入方法
|
||||
|
||||
#### A4.2 新建 discussion service 模块
|
||||
- [ ] 新建 discussion service(如 `plugin/core/discussion-service.ts`)
|
||||
- [ ] 封装 discussion channel 创建后的初始化逻辑
|
||||
- [ ] 封装 callback 校验逻辑
|
||||
- [ ] 封装 callback 成功后的收尾逻辑
|
||||
- [ ] 封装 origin channel moderator 通知逻辑
|
||||
- [ ] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑
|
||||
- [x] 新建 discussion service(如 `plugin/core/discussion-service.ts`)
|
||||
- [x] 封装 discussion channel 创建后的初始化逻辑
|
||||
- [x] 封装 callback 校验逻辑
|
||||
- [x] 封装 callback 成功后的收尾逻辑
|
||||
- [x] 封装 origin channel moderator 通知逻辑
|
||||
- [x] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑
|
||||
|
||||
#### A4.3 workspace 路径校验
|
||||
- [ ] 新增 path 校验辅助函数
|
||||
- [ ] 校验 `summaryPath` 文件存在
|
||||
- [ ] 校验 `summaryPath` 位于 initiator workspace 下
|
||||
- [ ] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题
|
||||
- [x] 新增 path 校验辅助函数
|
||||
- [x] 校验 `summaryPath` 文件存在
|
||||
- [x] 校验 `summaryPath` 位于 initiator workspace 下
|
||||
- [x] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题
|
||||
|
||||
### A5. `plugin/core/moderator-discord.ts`
|
||||
- [ ] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程
|
||||
- [ ] 如有必要,补充统一错误日志和返回值处理
|
||||
- [ ] 确认可被 discussion service 复用发送:
|
||||
- [ ] kickoff message
|
||||
- [ ] idle reminder
|
||||
- [ ] callback 完成通知
|
||||
- [ ] channel closed 固定回复
|
||||
- [x] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程
|
||||
- [x] 如有必要,补充统一错误日志和返回值处理
|
||||
- [x] 确认可被 discussion service 复用发送:
|
||||
- [x] kickoff message
|
||||
- [x] idle reminder
|
||||
- [x] callback 完成通知
|
||||
- [x] channel closed 固定回复
|
||||
|
||||
### A6. `plugin/turn-manager.ts`
|
||||
#### A6.1 理解现有轮转机制
|
||||
- [ ] 梳理 `initTurnOrder` / `checkTurn` / `onNewMessage` / `onSpeakerDone` / `advanceTurn`
|
||||
- [ ] 确认“轮转一圈无人发言”在现有实现中的判定条件
|
||||
- [ ] 确认 discussion 模式需要在哪个信号点插入“idle reminder”
|
||||
- [x] 梳理 `initTurnOrder` / `checkTurn` / `onNewMessage` / `onSpeakerDone` / `advanceTurn`
|
||||
- [x] 确认“轮转一圈无人发言”在现有实现中的判定条件
|
||||
- [x] 确认 discussion 模式需要在哪个信号点插入“idle reminder”
|
||||
|
||||
#### A6.2 discussion 模式的空转处理
|
||||
- [ ] 设计 discussion 模式下的 idle reminder 触发方式
|
||||
- [ ] 确定是直接改 `turn-manager.ts`,还是由上层在 `nextSpeaker === null` 时识别 discussion channel
|
||||
- [ ] 确保 discussion channel 空转时 moderator 会提醒 initiator 收尾
|
||||
- [ ] 确保普通 channel 仍保持原有 dormant 行为
|
||||
- [x] 设计 discussion 模式下的 idle reminder 触发方式
|
||||
- [x] 确定是直接改 `turn-manager.ts`,还是由上层在 `nextSpeaker === null` 时识别 discussion channel
|
||||
- [x] 确保 discussion channel 空转时 moderator 会提醒 initiator 收尾
|
||||
- [x] 确保普通 channel 仍保持原有 dormant 行为
|
||||
|
||||
#### A6.3 关闭后禁言
|
||||
- [ ] 明确 discussion channel `closed` 后 turn-manager 是否还需要保留状态
|
||||
- [ ] 如需要,增加对 closed discussion channel 的快速短路判断
|
||||
- [ ] 避免 closed channel 再次进入正常轮转
|
||||
- [x] 明确 discussion channel `closed` 后 turn-manager 是否还需要保留状态
|
||||
- [x] 如需要,增加对 closed discussion channel 的快速短路判断
|
||||
- [x] 避免 closed channel 再次进入正常轮转
|
||||
|
||||
### A7. Hooks 与 session 状态
|
||||
#### A7.1 `plugin/hooks/before-model-resolve.ts`
|
||||
- [ ] 梳理当前 session 级 no-reply 覆盖的触发路径
|
||||
- [ ] 确认如何将 closed discussion channel 相关 session 强制落到 `noReplyProvider` / `noReplyModel`
|
||||
- [ ] 确认该逻辑是通过 metadata 状态判断,还是通过额外 session 标记判断
|
||||
- [ ] 确保该覆盖只作用于指定 discussion session,不影响其他 channel/session
|
||||
- [ ] 为 closed discussion channel 的覆盖路径补充调试日志
|
||||
- [x] 梳理当前 session 级 no-reply 覆盖的触发路径
|
||||
- [x] 确认如何将 closed discussion channel 相关 session 强制落到 `noReplyProvider` / `noReplyModel`
|
||||
- [x] 确认该逻辑是通过 metadata 状态判断,还是通过额外 session 标记判断
|
||||
- [x] 确保该覆盖只作用于指定 discussion session,不影响其他 channel/session
|
||||
- [x] 为 closed discussion channel 的覆盖路径补充调试日志
|
||||
|
||||
#### A7.2 `plugin/hooks/before-message-write.ts`
|
||||
- [ ] 梳理当前 NO_REPLY / end-symbol / waitIdentifier 的处理逻辑
|
||||
- [ ] 找到 discussion channel 中“轮转一圈无人发言”后最适合触发 idle reminder 的位置
|
||||
- [ ] 如果 `nextSpeaker === null` 且当前 channel 是 active discussion channel:
|
||||
- [ ] 调用 moderator idle reminder
|
||||
- [ ] 不直接让流程无提示沉默结束
|
||||
- [ ] 避免重复发送 idle reminder
|
||||
- [ ] closed discussion channel 下,阻止继续进入正常 handoff 流程
|
||||
- [x] 梳理当前 NO_REPLY / end-symbol / waitIdentifier 的处理逻辑
|
||||
- [x] 找到 discussion channel 中“轮转一圈无人发言”后最适合触发 idle reminder 的位置
|
||||
- [x] 如果 `nextSpeaker === null` 且当前 channel 是 active discussion channel:
|
||||
- [x] 调用 moderator idle reminder
|
||||
- [x] 不直接让流程无提示沉默结束
|
||||
- [x] 避免重复发送 idle reminder
|
||||
- [x] closed discussion channel 下,阻止继续进入正常 handoff 流程
|
||||
|
||||
#### A7.3 `plugin/hooks/message-sent.ts`
|
||||
- [ ] 确认该 hook 是否也会参与 turn 收尾,避免与 `before-message-write.ts` 重复处理
|
||||
- [ ] 检查 discussion channel 场景下是否需要同步补充 closed/idle 分支保护
|
||||
- [ ] 确保 callback 完成后的 closed channel 不会继续触发 handoff
|
||||
- [x] 确认该 hook 是否也会参与 turn 收尾,避免与 `before-message-write.ts` 重复处理
|
||||
- [x] 检查 discussion channel 场景下是否需要同步补充 closed/idle 分支保护
|
||||
- [x] 确保 callback 完成后的 closed channel 不会继续触发 handoff
|
||||
|
||||
#### A7.4 `plugin/hooks/message-received.ts`
|
||||
- [ ] 梳理 moderator bot 消息当前是否已被过滤,避免 moderator 自己再次触发讨论链路
|
||||
- [ ] 对 closed discussion channel 的新消息增加统一处理入口
|
||||
- [ ] 若 closed discussion channel 收到新消息:
|
||||
- [ ] 不再唤醒任何 Agent 正常讨论
|
||||
- [ ] 由 moderator 回复“channel 已关闭,仅做留档使用”
|
||||
- [ ] 避免 moderator 的 closed 提示消息反复触发自身处理
|
||||
- [x] 梳理 moderator bot 消息当前是否已被过滤,避免 moderator 自己再次触发讨论链路
|
||||
- [x] 对 closed discussion channel 的新消息增加统一处理入口
|
||||
- [x] 若 closed discussion channel 收到新消息:
|
||||
- [x] 不再唤醒任何 Agent 正常讨论
|
||||
- [x] 由 moderator 回复“channel 已关闭,仅做留档使用”
|
||||
- [x] 避免 moderator 的 closed 提示消息反复触发自身处理
|
||||
|
||||
#### A7.5 `plugin/core/session-state.ts`(如需)
|
||||
- [ ] 检查现有 session 相关缓存是否适合扩展 discussion 状态
|
||||
- [ ] 若需要,为 discussion session 增加专用标记缓存
|
||||
- [ ] 区分:普通 no-reply 决策 vs discussion close 强制 no-reply
|
||||
- [ ] 确保 session 生命周期结束后相关缓存可清理
|
||||
- [x] 检查现有 session 相关缓存是否适合扩展 discussion 状态
|
||||
- [x] 若需要,为 discussion session 增加专用标记缓存
|
||||
- [x] 区分:普通 no-reply 决策 vs discussion close 强制 no-reply
|
||||
- [x] 确保 session 生命周期结束后相关缓存可清理
|
||||
|
||||
### A8. `plugin/core/identity.ts` / `plugin/core/channel-members.ts` / `plugin/core/turn-bootstrap.ts`
|
||||
- [ ] 梳理 initiator identity 的可获取路径
|
||||
- [ ] 确认 callback 时如何稳定识别 initiator account/session
|
||||
- [ ] 确认 discussion channel 创建后 turn order 是否需立即 bootstrap
|
||||
- [ ] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑
|
||||
- [x] 梳理 initiator identity 的可获取路径
|
||||
- [x] 确认 callback 时如何稳定识别 initiator account/session
|
||||
- [x] 确认 discussion channel 创建后 turn order 是否需立即 bootstrap
|
||||
- [x] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑
|
||||
|
||||
### A9. `plugin/index.ts`
|
||||
- [ ] 注入新增 discussion metadata/service 模块依赖
|
||||
- [ ] 将 discussion service 传入工具注册逻辑
|
||||
- [ ] 将 discussion 相关辅助能力传入需要的 hooks
|
||||
- [ ] 保持插件初始化结构清晰,避免在 `index.ts` 中堆业务细节
|
||||
- [x] 注入新增 discussion metadata/service 模块依赖
|
||||
- [x] 将 discussion service 传入工具注册逻辑
|
||||
- [x] 将 discussion 相关辅助能力传入需要的 hooks
|
||||
- [x] 保持插件初始化结构清晰,避免在 `index.ts` 中堆业务细节
|
||||
|
||||
### A13.2 metadata / service 测试
|
||||
- [x] 测试 discussion metadata 创建成功
|
||||
- [x] 测试按 channelId 查询 metadata 成功
|
||||
- [x] 测试状态流转 `active -> completed/closed` 成功
|
||||
- [x] 测试重复 callback 被拒绝
|
||||
|
||||
### A13.4 路径校验测试
|
||||
- [x] 测试合法 `summaryPath` 通过
|
||||
- [x] 测试不存在文件失败
|
||||
- [x] 测试 workspace 外路径失败
|
||||
- [x] 测试 `..` 路径逃逸失败
|
||||
- [x] 测试绝对路径越界失败
|
||||
|
||||
### A13.5 回调链路测试
|
||||
- [x] 测试 callback 成功后 moderator 在 origin channel 发出通知
|
||||
- [x] 测试 origin channel 收到路径后能继续原工作流
|
||||
- [x] 测试 discussion channel 后续只保留留档行为
|
||||
|
||||
### B10.2 Shuffle Mode
|
||||
- [x] 测试 `/turn-shuffling on/off` 生效
|
||||
- [x] 测试 shuffling 关闭时 turn order 不变
|
||||
- [x] 测试 shuffling 开启时每轮结束后会 reshuffle
|
||||
- [x] 测试上一轮最后 speaker 不会成为下一轮第一位
|
||||
- [x] 测试双 Agent 场景行为符合预期
|
||||
- [x] 测试单 Agent 场景不会异常
|
||||
|
||||
### B10.3 兼容性测试
|
||||
- [x] 测试 multi-message mode 与 waiting-for-human 的边界
|
||||
- [x] 测试 multi-message mode 与 mention override 的边界
|
||||
- [x] 测试 shuffle mode 与 dormant 状态的边界
|
||||
- [x] 测试 shuffle mode 与 mention override 的边界
|
||||
|
||||
### B11. 文档收尾
|
||||
- [x] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
|
||||
- [x] 为新增配置项补文档
|
||||
- [x] 为 `/turn-shuffling` 补使用说明
|
||||
- [x] 输出 Multi-Message Mode / Shuffle Mode 的验收清单
|
||||
|
||||
### A10. moderator 消息模板整理
|
||||
#### A10.1 kickoff message
|
||||
- [ ] 定稿 discussion started 模板
|
||||
- [ ] 模板中包含 `discussGuide`
|
||||
- [ ] 模板中明确 initiator 结束责任
|
||||
- [ ] 模板中明确 `discuss-callback(summaryPath)` 调用要求
|
||||
- [x] 定稿 discussion started 模板
|
||||
- [x] 模板中包含 `discussGuide`
|
||||
- [x] 模板中明确 initiator 结束责任
|
||||
- [x] 模板中明确 `discuss-callback(summaryPath)` 调用要求
|
||||
|
||||
#### A10.2 idle reminder
|
||||
- [ ] 定稿 discussion idle 模板
|
||||
- [ ] 模板中提醒 initiator:写总结文件并 callback
|
||||
- [ ] 避免提醒文案歧义或像自动总结器
|
||||
- [x] 定稿 discussion idle 模板
|
||||
- [x] 模板中提醒 initiator:写总结文件并 callback
|
||||
- [x] 避免提醒文案歧义或像自动总结器
|
||||
|
||||
#### A10.3 origin callback message
|
||||
- [ ] 定稿发回原工作 channel 的结果通知模板
|
||||
- [ ] 模板中包含 `summaryPath`
|
||||
- [ ] 模板中包含来源 discussion channel
|
||||
- [ ] 模板中明确“继续基于该总结文件推进原任务”
|
||||
- [x] 定稿发回原工作 channel 的结果通知模板
|
||||
- [x] 模板中包含 `summaryPath`
|
||||
- [x] 模板中包含来源 discussion channel
|
||||
- [x] 模板中明确“继续基于该总结文件推进原任务”
|
||||
|
||||
#### A10.4 closed reply
|
||||
- [ ] 定稿 closed channel 固定回复模板
|
||||
- [ ] 明确 channel 已关闭,仅做留档使用
|
||||
- [x] 定稿 closed channel 固定回复模板
|
||||
- [x] 明确 channel 已关闭,仅做留档使用
|
||||
|
||||
### A11. `discuss-callback` 详细校验任务
|
||||
- [ ] 校验当前 channel 必须是 discussion channel
|
||||
- [ ] 校验当前 discussion 状态必须是 `active`
|
||||
- [ ] 校验调用者必须是 initiator
|
||||
- [ ] 校验 `summaryPath` 非空
|
||||
- [ ] 校验 `summaryPath` 文件存在
|
||||
- [ ] 校验 `summaryPath` 路径在 initiator workspace 内
|
||||
- [ ] 校验 callback 未重复执行
|
||||
- [ ] callback 成功后写入 `completedAt`
|
||||
- [ ] callback 成功后记录 `summaryPath`
|
||||
- [ ] callback 成功后切换 discussion 状态为 `completed` / `closed`
|
||||
- [x] 校验当前 channel 必须是 discussion channel
|
||||
- [x] 校验当前 discussion 状态必须是 `active`
|
||||
- [x] 校验调用者必须是 initiator
|
||||
- [x] 校验 `summaryPath` 非空
|
||||
- [x] 校验 `summaryPath` 文件存在
|
||||
- [x] 校验 `summaryPath` 路径在 initiator workspace 内
|
||||
- [x] 校验 callback 未重复执行
|
||||
- [x] callback 成功后写入 `completedAt`
|
||||
- [x] callback 成功后记录 `summaryPath`
|
||||
- [x] callback 成功后切换 discussion 状态为 `completed` / `closed`
|
||||
|
||||
### A12. 关闭后的行为封口
|
||||
- [ ] closed discussion channel 中所有旧 session 继续使用 no-reply 覆盖
|
||||
- [ ] closed discussion channel 中任何新消息都不再进入真实讨论
|
||||
- [ ] closed discussion channel 的任何新消息统一走 moderator 固定回复
|
||||
- [ ] 防止 closed channel 中 moderator 自己的回复再次触发回环
|
||||
- [ ] 明确 archived-only 的最终行为与边界
|
||||
- [x] closed discussion channel 中所有旧 session 继续使用 no-reply 覆盖
|
||||
- [x] closed discussion channel 中任何新消息都不再进入真实讨论
|
||||
- [x] closed discussion channel 的任何新消息统一走 moderator 固定回复
|
||||
- [x] 防止 closed channel 中 moderator 自己的回复再次触发回环
|
||||
- [x] 明确 archived-only 的最终行为与边界
|
||||
|
||||
### A13. 测试与文档收尾
|
||||
#### A13.1 工具层测试
|
||||
- [ ] 测试普通 `discord_channel_create` 不带新参数时行为不变
|
||||
- [ ] 测试 `discord_channel_create` 带 `callbackChannelId` 但缺 `discussGuide` 时失败
|
||||
- [ ] 测试 discussion 模式 channel 创建成功
|
||||
- [ ] 测试 `discuss-callback` 注册成功并可调用
|
||||
- [x] 测试普通 `discord_channel_create` 不带新参数时行为不变
|
||||
- [x] 测试 `discord_channel_create` 带 `callbackChannelId` 但缺 `discussGuide` 时失败
|
||||
- [x] 测试 discussion 模式 channel 创建成功
|
||||
- [x] 测试 `discuss-callback` 注册成功并可调用
|
||||
|
||||
#### A13.2 metadata / service 测试
|
||||
- [ ] 测试 discussion metadata 创建成功
|
||||
- [ ] 测试按 channelId 查询 metadata 成功
|
||||
- [ ] 测试状态流转 `active -> completed/closed` 成功
|
||||
- [ ] 测试重复 callback 被拒绝
|
||||
- [x] 测试 discussion metadata 创建成功
|
||||
- [x] 测试按 channelId 查询 metadata 成功
|
||||
- [x] 测试状态流转 `active -> completed/closed` 成功
|
||||
- [x] 测试重复 callback 被拒绝
|
||||
|
||||
#### A13.3 turn / hook 测试
|
||||
- [ ] 测试 discussion channel 空转后发送 idle reminder
|
||||
- [ ] 测试普通 channel 空转逻辑不受影响
|
||||
- [ ] 测试 callback 成功后 discussion channel 不再 handoff
|
||||
- [ ] 测试 closed discussion channel 新消息不会继续唤醒 Agent
|
||||
- [x] 测试 discussion channel 空转后发送 idle reminder
|
||||
- [x] 测试普通 channel 空转逻辑不受影响
|
||||
- [x] 测试 callback 成功后 discussion channel 不再 handoff
|
||||
- [x] 测试 closed discussion channel 新消息不会继续唤醒 Agent
|
||||
|
||||
#### A13.4 路径校验测试
|
||||
- [ ] 测试合法 `summaryPath` 通过
|
||||
- [ ] 测试不存在文件失败
|
||||
- [ ] 测试 workspace 外路径失败
|
||||
- [ ] 测试 `..` 路径逃逸失败
|
||||
- [ ] 测试绝对路径越界失败
|
||||
- [x] 测试合法 `summaryPath` 通过
|
||||
- [x] 测试不存在文件失败
|
||||
- [x] 测试 workspace 外路径失败
|
||||
- [x] 测试 `..` 路径逃逸失败
|
||||
- [x] 测试绝对路径越界失败
|
||||
|
||||
#### A13.5 回调链路测试
|
||||
- [ ] 测试 callback 成功后 moderator 在 origin channel 发出通知
|
||||
- [ ] 测试 origin channel 收到路径后能继续原工作流
|
||||
- [ ] 测试 discussion channel 后续只保留留档行为
|
||||
- [x] 测试 callback 成功后 moderator 在 origin channel 发出通知
|
||||
- [x] 测试 origin channel 收到路径后能继续原工作流
|
||||
- [x] 测试 discussion channel 后续只保留留档行为
|
||||
|
||||
#### A13.6 文档交付
|
||||
- [ ] 根据最终代码实现更新 `plans/CSM.md`
|
||||
- [ ] 为 `discord_channel_create` 新增参数补文档
|
||||
- [ ] 为 `discuss-callback` 补工具说明文档
|
||||
- [ ] 补 discussion metadata 与状态机说明
|
||||
- [ ] 补开发/调试说明
|
||||
- [ ] 输出 MVP 验收清单
|
||||
- [x] 根据最终代码实现更新 `plans/CSM.md`
|
||||
- [x] 为 `discord_channel_create` 新增参数补文档
|
||||
- [x] 为 `discuss-callback` 补工具说明文档
|
||||
- [x] 补 discussion metadata 与状态机说明
|
||||
- [x] 补开发/调试说明
|
||||
- [x] 输出 MVP 验收清单
|
||||
|
||||
---
|
||||
|
||||
## B. Multi-Message Mode / Shuffle Mode
|
||||
|
||||
### B1. 方案整理
|
||||
- [ ] 通读并确认 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
|
||||
- [ ] 确认 Multi-Message Mode 与 Shuffle Mode 的 MVP 范围
|
||||
- [ ] 确认两项能力是否都只做 channel 级 runtime state,不立即落盘
|
||||
- [ ] 明确它们与 discussion channel / waiting-for-human / dormant 的优先级关系
|
||||
- [x] 通读并确认 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
|
||||
- [x] 确认 Multi-Message Mode 与 Shuffle Mode 的 MVP 范围
|
||||
- [x] 确认两项能力是否都只做 channel 级 runtime state,不立即落盘
|
||||
- [x] 明确它们与 discussion channel / waiting-for-human / dormant 的优先级关系
|
||||
|
||||
### B2. 配置与 schema
|
||||
#### B2.1 `plugin/openclaw.plugin.json`
|
||||
- [ ] 增加 `multiMessageStartMarker`
|
||||
- [ ] 增加 `multiMessageEndMarker`
|
||||
- [ ] 增加 `multiMessagePromptMarker`
|
||||
- [ ] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️`
|
||||
- [ ] 评估是否需要增加 shuffle 默认配置项
|
||||
- [x] 增加 `multiMessageStartMarker`
|
||||
- [x] 增加 `multiMessageEndMarker`
|
||||
- [x] 增加 `multiMessagePromptMarker`
|
||||
- [x] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️`
|
||||
- [x] 评估是否需要增加 shuffle 默认配置项
|
||||
|
||||
#### B2.2 `plugin/rules.ts` / config 类型
|
||||
- [ ] 为 multi-message mode 相关配置补类型定义
|
||||
- [ ] 为 shuffle mode 相关 channel state / config 补类型定义
|
||||
- [ ] 确保运行时读取配置逻辑可访问新增字段
|
||||
- [x] 为 multi-message mode 相关配置补类型定义
|
||||
- [x] 为 shuffle mode 相关 channel state / config 补类型定义
|
||||
- [x] 确保运行时读取配置逻辑可访问新增字段
|
||||
|
||||
### B3. `plugin/core/` 新增 channel mode / shuffle state 模块
|
||||
- [ ] 新增 channel mode state 模块(如 `plugin/core/channel-modes.ts`)
|
||||
- [ ] 定义 channel mode:`normal` / `multi-message`
|
||||
- [ ] 提供 `enterMultiMessageMode(channelId)`
|
||||
- [ ] 提供 `exitMultiMessageMode(channelId)`
|
||||
- [ ] 提供 `isMultiMessageMode(channelId)`
|
||||
- [ ] 提供 shuffle 开关状态存取方法
|
||||
- [ ] 评估 shuffle state 是否应并入 turn-manager 内部状态
|
||||
- [x] 新增 channel mode state 模块(如 `plugin/core/channel-modes.ts`)
|
||||
- [x] 定义 channel mode:`normal` / `multi-message`
|
||||
- [x] 提供 `enterMultiMessageMode(channelId)`
|
||||
- [x] 提供 `exitMultiMessageMode(channelId)`
|
||||
- [x] 提供 `isMultiMessageMode(channelId)`
|
||||
- [x] 提供 shuffle 开关状态存取方法
|
||||
- [x] 评估 shuffle state 是否应并入 turn-manager 内部状态
|
||||
|
||||
### B4. `plugin/hooks/message-received.ts`
|
||||
#### B4.1 Multi-Message Mode 入口/出口
|
||||
- [ ] 检测 human 消息中的 multi-message start marker
|
||||
- [ ] start marker 命中时,将 channel 切换到 multi-message mode
|
||||
- [ ] 检测 human 消息中的 multi-message end marker
|
||||
- [ ] end marker 命中时,将 channel 退出 multi-message mode
|
||||
- [ ] 避免 moderator 自己的 prompt marker 消息触发 mode 切换
|
||||
- [x] 检测 human 消息中的 multi-message start marker
|
||||
- [x] start marker 命中时,将 channel 切换到 multi-message mode
|
||||
- [x] 检测 human 消息中的 multi-message end marker
|
||||
- [x] end marker 命中时,将 channel 退出 multi-message mode
|
||||
- [x] 避免 moderator 自己的 prompt marker 消息触发 mode 切换
|
||||
|
||||
#### B4.2 Multi-Message Mode 中的 moderator 提示
|
||||
- [ ] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker
|
||||
- [ ] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker`
|
||||
- [ ] 避免重复触发或回环
|
||||
- [x] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker
|
||||
- [x] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker`
|
||||
- [x] 避免重复触发或回环
|
||||
|
||||
#### B4.3 与现有 mention override 的兼容
|
||||
- [ ] 明确 multi-message mode 下 human @mention 是否忽略
|
||||
- [ ] 避免 multi-message mode 与 mention override 冲突
|
||||
- [x] 明确 multi-message mode 下 human @mention 是否忽略
|
||||
- [x] 避免 multi-message mode 与 mention override 冲突
|
||||
|
||||
### B5. `plugin/hooks/before-model-resolve.ts`
|
||||
- [ ] 当 channel 处于 multi-message mode 时,强制相关 session 走 `noReplyProvider` / `noReplyModel`
|
||||
- [ ] 确保 multi-message mode 的 no-reply 覆盖优先于普通 turn 决策
|
||||
- [ ] 确保退出 multi-message mode 后恢复正常 turn 逻辑
|
||||
- [ ] 补充必要调试日志
|
||||
- [x] 当 channel 处于 multi-message mode 时,强制相关 session 走 `noReplyProvider` / `noReplyModel`
|
||||
- [x] 确保 multi-message mode 的 no-reply 覆盖优先于普通 turn 决策
|
||||
- [x] 确保退出 multi-message mode 后恢复正常 turn 逻辑
|
||||
- [x] 补充必要调试日志
|
||||
|
||||
### B6. `plugin/turn-manager.ts`
|
||||
#### B6.1 Multi-Message Mode 与 turn pause/resume
|
||||
- [ ] 设计 multi-message mode 下 turn manager 的暂停语义
|
||||
- [ ] 明确 pause 是通过外层 gating,还是 turn-manager 内显式状态
|
||||
- [ ] 退出 multi-message mode 后恢复 turn manager
|
||||
- [ ] 退出时确定下一位 speaker 的选择逻辑
|
||||
- [x] 设计 multi-message mode 下 turn manager 的暂停语义
|
||||
- [x] 明确 pause 是通过外层 gating,还是 turn-manager 内显式状态
|
||||
- [x] 退出 multi-message mode 后恢复 turn manager
|
||||
- [x] 退出时确定下一位 speaker 的选择逻辑
|
||||
|
||||
#### B6.2 Shuffle Mode
|
||||
- [ ] 为每个 channel 增加 `shuffling` 开关状态
|
||||
- [ ] 识别“一轮最后一位 speaker 发言完成”的边界点
|
||||
- [ ] 在进入下一轮前执行 reshuffle
|
||||
- [ ] 保证上一轮最后 speaker 不会成为新一轮第一位
|
||||
- [ ] 处理单 Agent 场景
|
||||
- [ ] 处理双 Agent 场景
|
||||
- [ ] 处理 mention override / waiting-for-human / dormant 状态下的 reshape 边界
|
||||
- [x] 为每个 channel 增加 `shuffling` 开关状态
|
||||
- [x] 识别“一轮最后位 speaker 发言完成”的边界点
|
||||
- [x] 在进入下一轮前执行 reshuffle
|
||||
- [x] 保证上一轮最后 speaker 不会成为新一轮第一位
|
||||
- [x] 处理单 Agent 场景
|
||||
- [x] 处理双 Agent 场景
|
||||
- [x] 处理 mention override / waiting-for-human / dormant 状态下的 reshape 边界
|
||||
|
||||
### B7. `plugin/commands/dirigent-command.ts`
|
||||
- [ ] 新增 `/turn-shuffling` 子命令
|
||||
- [ ] 支持:
|
||||
- [ ] `/turn-shuffling`
|
||||
- [ ] `/turn-shuffling on`
|
||||
- [ ] `/turn-shuffling off`
|
||||
- [ ] 命令返回当前 channel 的 shuffling 状态
|
||||
- [ ] 命令帮助文本补充说明
|
||||
- [x] 新增 `/turn-shuffling` 子命令
|
||||
- [x] 支持:
|
||||
- [x] `/turn-shuffling`
|
||||
- [x] `/turn-shuffling on`
|
||||
- [x] `/turn-shuffling off`
|
||||
- [x] 命令返回当前 channel 的 shuffling 状态
|
||||
- [x] 命令帮助文本补充说明
|
||||
|
||||
### B8. `plugin/index.ts`
|
||||
- [ ] 注入 channel mode / shuffle state 模块依赖
|
||||
- [ ] 将新状态能力传给相关 hooks / turn-manager
|
||||
- [ ] 保持初始化关系清晰,避免 mode 逻辑散落
|
||||
- [x] 注入 channel mode / shuffle state 模块依赖
|
||||
- [x] 将新状态能力传给相关 hooks / turn-manager
|
||||
- [x] 保持初始化关系清晰,避免 mode 逻辑散落
|
||||
|
||||
### B9. moderator 消息模板
|
||||
- [ ] 定义 multi-message mode 下的 prompt marker 发送规则
|
||||
- [ ] 明确是否需要 start / end 的 moderator 确认消息
|
||||
- [ ] 定义退出 multi-message mode 后的 scheduling handoff 触发格式
|
||||
- [x] 定义 multi-message mode 下的 prompt marker 发送规则
|
||||
- [x] 明确是否需要 start / end 的 moderator 确认消息
|
||||
- [x] 定义退出 multi-message mode 后的 scheduling handoff 触发格式
|
||||
|
||||
### B10. 测试
|
||||
#### B10.1 Multi-Message Mode
|
||||
- [ ] 测试 human 发送 start marker 后进入 multi-message mode
|
||||
- [ ] 测试 multi-message mode 中 Agent 被 no-reply 覆盖
|
||||
- [ ] 测试每条 human 追加消息都触发 prompt marker
|
||||
- [ ] 测试 human 发送 end marker 后退出 multi-message mode
|
||||
- [ ] 测试退出后 moderator 正确 handoff 给下一位 Agent
|
||||
- [ ] 测试 moderator prompt marker 不会触发回环
|
||||
- [x] 测试 human 发送 start marker 后进入 multi-message mode
|
||||
- [x] 测试 multi-message mode 中 Agent 被 no-reply 覆盖
|
||||
- [x] 测试每条 human 追加消息都触发 prompt marker
|
||||
- [x] 测试 human 发送 end marker 后退出 multi-message mode
|
||||
- [x] 测试退出后 moderator 正确 handoff 给下一位 Agent
|
||||
- [x] 测试 moderator prompt marker 不会触发回环
|
||||
|
||||
#### B10.2 Shuffle Mode
|
||||
- [ ] 测试 `/turn-shuffling on/off` 生效
|
||||
- [ ] 测试 shuffling 关闭时 turn order 不变
|
||||
- [ ] 测试 shuffling 开启时每轮结束后会 reshuffle
|
||||
- [ ] 测试上一轮最后 speaker 不会成为下一轮第一位
|
||||
- [ ] 测试双 Agent 场景行为符合预期
|
||||
- [ ] 测试单 Agent 场景不会异常
|
||||
- [x] 测试 `/turn-shuffling on/off` 生效
|
||||
- [x] 测试 shuffling 关闭时 turn order 不变
|
||||
- [x] 测试 shuffling 开启时每轮结束后会 reshuffle
|
||||
- [x] 测试上一轮最后 speaker 不会成为下一轮第一位
|
||||
- [x] 测试双 Agent 场景行为符合预期
|
||||
- [x] 测试单 Agent 场景不会异常
|
||||
|
||||
#### B10.3 兼容性测试
|
||||
- [ ] 测试 multi-message mode 与 waiting-for-human 的边界
|
||||
- [ ] 测试 multi-message mode 与 mention override 的边界
|
||||
- [ ] 测试 shuffle mode 与 dormant 状态的边界
|
||||
- [ ] 测试 shuffle mode 与 mention override 的边界
|
||||
- [x] 测试 multi-message mode 与 waiting-for-human 的边界
|
||||
- [x] 测试 multi-message mode 与 mention override 的边界
|
||||
- [x] 测试 shuffle mode 与 dormant 状态的边界
|
||||
- [x] 测试 shuffle mode 与 mention override 的边界
|
||||
|
||||
### B11. 文档收尾
|
||||
- [ ] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
|
||||
- [ ] 为新增配置项补文档
|
||||
- [ ] 为 `/turn-shuffling` 补使用说明
|
||||
- [ ] 输出 Multi-Message Mode / Shuffle Mode 的验收清单
|
||||
- [x] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
|
||||
- [x] 为新增配置项补文档
|
||||
- [x] 为 `/turn-shuffling` 补使用说明
|
||||
- [x] 输出 Multi-Message Mode / Shuffle Mode 的验收清单
|
||||
|
||||
@@ -30,6 +30,9 @@ Optional:
|
||||
- `channelPoliciesFile` (per-channel overrides in a standalone JSON file)
|
||||
- `schedulingIdentifier` (default `➡️`) — moderator handoff identifier
|
||||
- `enableDirigentPolicyTool` (default true)
|
||||
- `multiMessageStartMarker` (default `↗️`)
|
||||
- `multiMessageEndMarker` (default `↙️`)
|
||||
- `multiMessagePromptMarker` (default `⤵️`)
|
||||
|
||||
Unified optional tool:
|
||||
- `dirigent_tools`
|
||||
@@ -59,6 +62,15 @@ When the current speaker NO_REPLYs, the moderator bot sends: `<@NEXT_USER_ID>➡
|
||||
|
||||
This is a non-semantic scheduling message. The scheduling identifier (`➡️` by default) carries no meaning — it simply signals the next agent to check chat history and decide whether to speak.
|
||||
|
||||
## Multi-message mode / shuffle mode
|
||||
|
||||
- Human sends the configured start marker (default `↗️`) → channel enters multi-message mode.
|
||||
- While active, agents are forced to no-reply and the moderator sends only the configured prompt marker (default `⤵️`) after each additional human message.
|
||||
- Human sends the configured end marker (default `↙️`) → channel exits multi-message mode and normal scheduling resumes.
|
||||
- No separate moderator "entered/exited mode" confirmation message is sent; the markers themselves are the protocol.
|
||||
- The first moderator message after exit uses the normal scheduling handoff format: `<@NEXT_USER_ID>➡️`.
|
||||
- `/dirigent turn-shuffling`, `/dirigent turn-shuffling on`, and `/dirigent turn-shuffling off` control per-channel reshuffling between completed rounds.
|
||||
|
||||
## Slash command (Discord)
|
||||
|
||||
```
|
||||
@@ -66,6 +78,9 @@ This is a non-semantic scheduling message. The scheduling identifier (`➡️` b
|
||||
/dirigent turn-status
|
||||
/dirigent turn-advance
|
||||
/dirigent turn-reset
|
||||
/dirigent turn-shuffling
|
||||
/dirigent turn-shuffling on
|
||||
/dirigent turn-shuffling off
|
||||
```
|
||||
|
||||
Debug logging:
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
export function extractDiscordChannelId(ctx: Record<string, unknown>, event?: Record<string, unknown>): string | undefined {
|
||||
const candidates: unknown[] = [
|
||||
ctx.conversationId,
|
||||
ctx.OriginatingTo,
|
||||
event?.to,
|
||||
(event?.metadata as Record<string, unknown>)?.to,
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (typeof c !== "string" || !c.trim()) continue;
|
||||
const s = c.trim();
|
||||
|
||||
if (s.startsWith("channel:")) {
|
||||
const id = s.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (s.startsWith("discord:channel:")) {
|
||||
const id = s.slice("discord:channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (/^\d{15,}$/.test(s)) return s;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromSessionKey(sessionKey?: string): string | undefined {
|
||||
if (!sessionKey) return undefined;
|
||||
|
||||
const canonical = sessionKey.match(/discord:(?:channel|group):(\d+)/);
|
||||
if (canonical?.[1]) return canonical[1];
|
||||
|
||||
const suffix = sessionKey.match(/:channel:(\d+)$/);
|
||||
if (suffix?.[1]) return suffix[1];
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
|
||||
const marker = "Conversation info (untrusted metadata):";
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx < 0) return undefined;
|
||||
const tail = text.slice(idx + marker.length);
|
||||
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
|
||||
if (!m) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromConversationMetadata(conv: Record<string, unknown>): string | undefined {
|
||||
if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) {
|
||||
const id = conv.chat_id.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (typeof conv.conversation_label === "string") {
|
||||
const labelMatch = conv.conversation_label.match(/channel id:(\d+)/);
|
||||
if (labelMatch?.[1]) return labelMatch[1];
|
||||
}
|
||||
|
||||
if (typeof conv.channel_id === "string" && /^\d+$/.test(conv.channel_id)) {
|
||||
return conv.channel_id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type CommandDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { filePath: string; channelPolicies: Record<string, unknown> };
|
||||
persistPolicies: (api: OpenClawPluginApi) => void;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
};
|
||||
|
||||
export function registerDirigentCommand(deps: CommandDeps): void {
|
||||
const { api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded } = deps;
|
||||
|
||||
api.registerCommand({
|
||||
name: "dirigent",
|
||||
description: "Dirigent runtime commands",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const args = cmdCtx.args || "";
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const subCmd = parts[0] || "help";
|
||||
|
||||
if (subCmd === "help") {
|
||||
return {
|
||||
text:
|
||||
`Dirigent commands:\n` +
|
||||
`/dirigent status - Show current channel status\n` +
|
||||
`/dirigent turn-status - Show turn-based speaking status\n` +
|
||||
`/dirigent turn-advance - Manually advance turn\n` +
|
||||
`/dirigent turn-reset - Reset turn order\n` +
|
||||
`/dirigent_policy get <discordChannelId>\n` +
|
||||
`/dirigent_policy set <discordChannelId> <policy-json>\n` +
|
||||
`/dirigent_policy delete <discordChannelId>`,
|
||||
};
|
||||
}
|
||||
|
||||
if (subCmd === "status") {
|
||||
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-status") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-advance") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-reset") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
resetTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true }) };
|
||||
}
|
||||
|
||||
return { text: `Unknown subcommand: ${subCmd}`, isError: true };
|
||||
},
|
||||
});
|
||||
|
||||
api.registerCommand({
|
||||
name: "dirigent_policy",
|
||||
description: "Dirigent channel policy CRUD",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const live = baseConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
const args = (cmdCtx.args || "").trim();
|
||||
if (!args) {
|
||||
return {
|
||||
text:
|
||||
"Usage:\n" +
|
||||
"/dirigent_policy get <discordChannelId>\n" +
|
||||
"/dirigent_policy set <discordChannelId> <policy-json>\n" +
|
||||
"/dirigent_policy delete <discordChannelId>",
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const [opRaw, channelIdRaw, ...rest] = args.split(/\s+/);
|
||||
const op = (opRaw || "").toLowerCase();
|
||||
const channelId = (channelIdRaw || "").trim();
|
||||
|
||||
if (!channelId || !/^\d+$/.test(channelId)) {
|
||||
return { text: "channelId is required and must be numeric Discord channel id", isError: true };
|
||||
}
|
||||
|
||||
if (op === "get") {
|
||||
const policy = (policyState.channelPolicies as Record<string, unknown>)[channelId];
|
||||
return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) };
|
||||
}
|
||||
|
||||
if (op === "delete") {
|
||||
delete (policyState.channelPolicies as Record<string, unknown>)[channelId];
|
||||
persistPolicies(api);
|
||||
return { text: JSON.stringify({ ok: true, channelId, deleted: true }) };
|
||||
}
|
||||
|
||||
if (op === "set") {
|
||||
const jsonText = rest.join(" ").trim();
|
||||
if (!jsonText) {
|
||||
return { text: "set requires <policy-json>", isError: true };
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
return { text: `invalid policy-json: ${String(e)}`, isError: true };
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
if (typeof parsed.listMode === "string") next.listMode = parsed.listMode;
|
||||
if (Array.isArray(parsed.humanList)) next.humanList = parsed.humanList.map(String);
|
||||
if (Array.isArray(parsed.agentList)) next.agentList = parsed.agentList.map(String);
|
||||
if (Array.isArray(parsed.endSymbols)) next.endSymbols = parsed.endSymbols.map(String);
|
||||
|
||||
(policyState.channelPolicies as Record<string, unknown>)[channelId] = next;
|
||||
persistPolicies(api);
|
||||
return { text: JSON.stringify({ ok: true, channelId, policy: next }, null, 2) };
|
||||
}
|
||||
|
||||
return { text: `unsupported op: ${op}. use get|set|delete`, isError: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
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}".` };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { buildUserIdToAccountIdMap } from "./identity.js";
|
||||
import type { IdentityRegistry } from "./identity-registry.js";
|
||||
|
||||
const PERM_VIEW_CHANNEL = 1n << 10n;
|
||||
const PERM_ADMINISTRATOR = 1n << 3n;
|
||||
@@ -84,7 +84,14 @@ function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bi
|
||||
return (perms & PERM_VIEW_CHANNEL) !== 0n;
|
||||
}
|
||||
|
||||
function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
|
||||
function getDiscoveryToken(api: OpenClawPluginApi): string | undefined {
|
||||
// Prefer moderator bot token from pluginConfig — it has guild member access
|
||||
const pluginCfg = (api.pluginConfig as Record<string, unknown>) || {};
|
||||
const moderatorToken = pluginCfg.moderatorBotToken;
|
||||
if (typeof moderatorToken === "string" && moderatorToken) {
|
||||
return moderatorToken;
|
||||
}
|
||||
// Fall back to any discord account token
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
@@ -95,8 +102,15 @@ function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise<string[]> {
|
||||
const token = getAnyDiscordToken(api);
|
||||
/**
|
||||
* Returns agentIds for all agents visible in the channel, resolved via the identity registry.
|
||||
*/
|
||||
export async function fetchVisibleChannelBotAccountIds(
|
||||
api: OpenClawPluginApi,
|
||||
channelId: string,
|
||||
identityRegistry?: IdentityRegistry,
|
||||
): Promise<string[]> {
|
||||
const token = getDiscoveryToken(api);
|
||||
if (!token) return [];
|
||||
|
||||
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
|
||||
@@ -131,11 +145,13 @@ export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, c
|
||||
.map((m) => String(m?.user?.id || ""))
|
||||
.filter(Boolean);
|
||||
|
||||
const userToAccount = buildUserIdToAccountIdMap(api);
|
||||
const out = new Set<string>();
|
||||
for (const uid of visibleUserIds) {
|
||||
const aid = userToAccount.get(uid);
|
||||
if (aid) out.add(aid);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDiscordUserIdFromAccount(api: OpenClawPluginApi, accountId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const acct = accounts[accountId];
|
||||
if (!acct?.token || typeof acct.token !== "string") return undefined;
|
||||
return userIdFromToken(acct.token);
|
||||
}
|
||||
|
||||
export function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
for (const b of bindings) {
|
||||
if (b.agentId === agentId) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
return match.accountId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
|
||||
let accountId: string | undefined;
|
||||
for (const b of bindings) {
|
||||
if (b.agentId === agentId) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
accountId = match.accountId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!accountId) return undefined;
|
||||
|
||||
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
|
||||
const name = (agent?.name as string) || agentId;
|
||||
const discordUserId = resolveDiscordUserIdFromAccount(api, accountId);
|
||||
|
||||
let identity = `You are ${name} (Discord account: ${accountId}`;
|
||||
if (discordUserId) identity += `, Discord userId: ${discordUserId}`;
|
||||
identity += `).`;
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map<string, string> {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const map = new Map<string, string>();
|
||||
for (const [accountId, acct] of Object.entries(accounts)) {
|
||||
if (typeof acct.token === "string") {
|
||||
const userId = userIdFromToken(acct.token);
|
||||
if (userId) map.set(userId, accountId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
@@ -25,7 +23,7 @@ export function extractMentionedUserIds(content: string): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function getModeratorUserId(config: DirigentConfig): string | undefined {
|
||||
if (!config.moderatorBotToken) return undefined;
|
||||
return userIdFromToken(config.moderatorBotToken);
|
||||
export function getModeratorUserIdFromToken(token: string | undefined): string | undefined {
|
||||
if (!token) return undefined;
|
||||
return userIdFromToken(token);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
||||
|
||||
export function userIdFromBotToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
@@ -17,15 +19,19 @@ export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string):
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const acct = accounts[accountId];
|
||||
if (!acct?.token || typeof acct.token !== "string") return undefined;
|
||||
return userIdFromToken(acct.token);
|
||||
return userIdFromBotToken(acct.token);
|
||||
}
|
||||
|
||||
export type ModeratorMessageResult =
|
||||
| { ok: true; status: number; channelId: string; messageId?: string }
|
||||
| { ok: false; status?: number; channelId: string; error: string };
|
||||
|
||||
export async function sendModeratorMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (msg: string) => void; warn: (msg: string) => void },
|
||||
): Promise<boolean> {
|
||||
logger: Logger,
|
||||
): Promise<ModeratorMessageResult> {
|
||||
try {
|
||||
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
||||
method: "POST",
|
||||
@@ -35,15 +41,232 @@ export async function sendModeratorMessage(
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`);
|
||||
return false;
|
||||
|
||||
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;
|
||||
}
|
||||
logger.info(`dirigent: moderator message sent to channel=${channelId}`);
|
||||
return true;
|
||||
|
||||
if (!r.ok) {
|
||||
const error = `discord api error (${r.status}): ${text || "<empty response>"}`;
|
||||
logger.warn(`dirigent: moderator send failed channel=${channelId} ${error}`);
|
||||
return { ok: false, status: r.status, channelId, error };
|
||||
}
|
||||
|
||||
const messageId = typeof json?.id === "string" ? json.id : undefined;
|
||||
logger.info(`dirigent: moderator message sent to channel=${channelId} messageId=${messageId ?? "unknown"}`);
|
||||
return { ok: true, status: r.status, channelId, messageId };
|
||||
} catch (err) {
|
||||
logger.warn(`dirigent: moderator send error: ${String(err)}`);
|
||||
return false;
|
||||
const error = String(err);
|
||||
logger.warn(`dirigent: moderator send error channel=${channelId}: ${error}`);
|
||||
return { ok: false, channelId, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a Discord message. */
|
||||
export async function deleteMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!r.ok) {
|
||||
logger.warn(`dirigent: deleteMessage failed channel=${channelId} msg=${messageId} status=${r.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`dirigent: deleteMessage error: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-channel last schedule-trigger message ID.
|
||||
* Stored on globalThis so it survives VM-context hot-reloads.
|
||||
* Used by sendScheduleTrigger to delete the PREVIOUS trigger when a new one is sent.
|
||||
*/
|
||||
const _LAST_TRIGGER_KEY = "_dirigentLastTriggerMsgId";
|
||||
if (!(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] = new Map<string, string>();
|
||||
}
|
||||
const lastTriggerMsgId: Map<string, string> = (globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] as Map<string, string>;
|
||||
|
||||
/**
|
||||
* Send a schedule-identifier trigger message, then delete the PREVIOUS one for
|
||||
* this channel (so only the current trigger is visible at any time).
|
||||
*
|
||||
* In debugMode the previous message is NOT deleted, leaving a full trigger
|
||||
* history visible in Discord for inspection.
|
||||
*/
|
||||
export async function sendScheduleTrigger(
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: Logger,
|
||||
debugMode = false,
|
||||
): Promise<void> {
|
||||
const prevMsgId = lastTriggerMsgId.get(channelId);
|
||||
const result = await sendModeratorMessage(token, channelId, content, logger);
|
||||
if (!result.ok || !result.messageId) return;
|
||||
|
||||
if (!debugMode) {
|
||||
// Track the new message so the NEXT call can delete it
|
||||
lastTriggerMsgId.set(channelId, result.messageId);
|
||||
// Delete the previous trigger with a small delay
|
||||
if (prevMsgId) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await deleteMessage(token, channelId, prevMsgId, logger);
|
||||
}
|
||||
}
|
||||
// debugMode: don't track, don't delete — every trigger stays in history
|
||||
}
|
||||
|
||||
/** Get the latest message ID in a channel (for use as poll anchor). */
|
||||
export async function getLatestMessageId(
|
||||
token: string,
|
||||
channelId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?limit=1`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!r.ok) return undefined;
|
||||
const msgs = (await r.json()) as Array<{ id: string }>;
|
||||
return msgs[0]?.id;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type DiscordMessage = { id: string; author: { id: string }; content: string };
|
||||
|
||||
/**
|
||||
* Poll the channel until any message from agentDiscordUserId with id > anchorId
|
||||
* appears. The anchor is set in before_model_resolve just before the LLM call,
|
||||
* so any agent message after it must belong to this turn.
|
||||
*
|
||||
* Uses exponential back-off starting at initialPollMs, doubling each miss,
|
||||
* capped at maxPollMs. Times out after timeoutMs (default 30 s) to avoid
|
||||
* getting permanently stuck when the agent's Discord message is never delivered.
|
||||
*
|
||||
* @returns { matched: true } when found, { interrupted: true } when aborted,
|
||||
* { matched: false, interrupted: false } on timeout.
|
||||
*/
|
||||
export async function pollForTailMatch(opts: {
|
||||
token: string;
|
||||
channelId: string;
|
||||
anchorId: string;
|
||||
agentDiscordUserId: string;
|
||||
/** Initial poll interval in ms (default 800). */
|
||||
initialPollMs?: number;
|
||||
/** Maximum poll interval in ms (default 8 000). */
|
||||
maxPollMs?: number;
|
||||
/** Give up and return after this many ms (default 30 000). */
|
||||
timeoutMs?: number;
|
||||
/** Callback checked before each poll; if true, polling is aborted. */
|
||||
isInterrupted?: () => boolean;
|
||||
}): Promise<{ matched: boolean; interrupted: boolean }> {
|
||||
const {
|
||||
token, channelId, anchorId, agentDiscordUserId,
|
||||
initialPollMs = 800,
|
||||
maxPollMs = 8_000,
|
||||
timeoutMs = 30_000,
|
||||
isInterrupted = () => false,
|
||||
} = opts;
|
||||
|
||||
let interval = initialPollMs;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (true) {
|
||||
if (isInterrupted()) return { matched: false, interrupted: true };
|
||||
if (Date.now() >= deadline) return { matched: false, interrupted: false };
|
||||
|
||||
try {
|
||||
const r = await fetch(
|
||||
`https://discord.com/api/v10/channels/${channelId}/messages?limit=20`,
|
||||
{ headers: { Authorization: `Bot ${token}` } },
|
||||
);
|
||||
if (r.ok) {
|
||||
const msgs = (await r.json()) as DiscordMessage[];
|
||||
const found = msgs.some(
|
||||
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
|
||||
);
|
||||
if (found) return { matched: true, interrupted: false };
|
||||
}
|
||||
} catch {
|
||||
// ignore transient errors, keep polling
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
// Exponential back-off, capped at maxPollMs
|
||||
interval = Math.min(interval * 2, maxPollMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
|
||||
export async function createDiscordChannel(opts: {
|
||||
token: string;
|
||||
guildId: string;
|
||||
name: string;
|
||||
/** Permission overwrites: [{id, type (0=role/1=member), allow, deny}] */
|
||||
permissionOverwrites?: Array<{ id: string; type: number; allow?: string; deny?: string }>;
|
||||
logger: Logger;
|
||||
}): Promise<string> {
|
||||
const { token, guildId, name, permissionOverwrites = [], logger } = opts;
|
||||
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, type: 0, permission_overwrites: permissionOverwrites }),
|
||||
});
|
||||
const json = (await r.json()) as Record<string, unknown>;
|
||||
if (!r.ok) {
|
||||
const err = `Discord channel create failed (${r.status}): ${JSON.stringify(json)}`;
|
||||
logger.warn(`dirigent: ${err}`);
|
||||
throw new Error(err);
|
||||
}
|
||||
const channelId = json.id as string;
|
||||
logger.info(`dirigent: created Discord channel ${name} id=${channelId} in guild=${guildId}`);
|
||||
return channelId;
|
||||
}
|
||||
|
||||
/** Fetch guilds where the moderator bot has admin permissions. */
|
||||
export async function fetchAdminGuilds(token: string): Promise<Array<{ id: string; name: string }>> {
|
||||
const r = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const guilds = (await r.json()) as Array<{ id: string; name: string; permissions: string }>;
|
||||
const ADMIN = 8n;
|
||||
return guilds.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN);
|
||||
}
|
||||
|
||||
/** Fetch private group channels in a guild visible to the moderator bot. */
|
||||
export async function fetchGuildChannels(
|
||||
token: string,
|
||||
guildId: string,
|
||||
): Promise<Array<{ id: string; name: string; type: number }>> {
|
||||
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const channels = (await r.json()) as Array<{ id: string; name: string; type: number }>;
|
||||
// type 0 = GUILD_TEXT; filter to text channels only (group private channels are type 0)
|
||||
return channels.filter((c) => c.type === 0);
|
||||
}
|
||||
|
||||
/** Get bot's own Discord user ID from token. */
|
||||
export function getBotUserIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
let noReplyProcess: ChildProcess | null = null;
|
||||
|
||||
export function startNoReplyApi(
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
pluginDir: string,
|
||||
port = 8787,
|
||||
): void {
|
||||
logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`);
|
||||
|
||||
if (noReplyProcess) {
|
||||
logger.info("dirigent: no-reply API already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs");
|
||||
logger.info(`dirigent: resolved serverPath=${serverPath}`);
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("dirigent: no-reply API server found, spawning process...");
|
||||
|
||||
noReplyProcess = spawn(process.execPath, [serverPath], {
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
|
||||
noReplyProcess.on("exit", (code, signal) => {
|
||||
logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`);
|
||||
noReplyProcess = null;
|
||||
});
|
||||
|
||||
logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`);
|
||||
}
|
||||
|
||||
export function stopNoReplyApi(logger: { info: (m: string) => void }): void {
|
||||
if (!noReplyProcess) return;
|
||||
logger.info("dirigent: stopping no-reply API");
|
||||
noReplyProcess.kill("SIGTERM");
|
||||
noReplyProcess = null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Decision } from "../rules.js";
|
||||
|
||||
export type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
export const MAX_SESSION_DECISIONS = 2000;
|
||||
export const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const sessionDecision = new Map<string, DecisionRecord>();
|
||||
export const sessionAllowed = new Map<string, boolean>();
|
||||
export const sessionInjected = new Set<string>();
|
||||
export const sessionChannelId = new Map<string, string>();
|
||||
export const sessionAccountId = new Map<string, string>();
|
||||
export const sessionTurnHandled = new Set<string>();
|
||||
|
||||
export function pruneDecisionMap(now = Date.now()): void {
|
||||
for (const [k, v] of sessionDecision.entries()) {
|
||||
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
||||
}
|
||||
|
||||
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
|
||||
const keys = sessionDecision.keys();
|
||||
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
|
||||
const k = keys.next();
|
||||
if (k.done) break;
|
||||
sessionDecision.delete(k.value);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { initTurnOrder } from "../turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "./channel-members.js";
|
||||
|
||||
const channelSeenAccounts = new Map<string, Set<string>>();
|
||||
const channelBootstrapTried = new Set<string>();
|
||||
let cacheLoaded = false;
|
||||
|
||||
function cachePath(api: OpenClawPluginApi): string {
|
||||
return api.resolvePath("~/.openclaw/dirigent-channel-members.json");
|
||||
}
|
||||
|
||||
function loadCache(api: OpenClawPluginApi): void {
|
||||
if (cacheLoaded) return;
|
||||
cacheLoaded = true;
|
||||
const p = cachePath(api);
|
||||
try {
|
||||
if (!fs.existsSync(p)) return;
|
||||
const raw = fs.readFileSync(p, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, { botAccountIds?: string[]; source?: string; guildId?: string; updatedAt?: string }>;
|
||||
for (const [channelId, rec] of Object.entries(parsed || {})) {
|
||||
const ids = Array.isArray(rec?.botAccountIds) ? rec.botAccountIds.filter(Boolean) : [];
|
||||
if (ids.length > 0) channelSeenAccounts.set(channelId, new Set(ids));
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn?.(`dirigent: failed loading channel member cache: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function inferGuildIdFromChannelId(api: OpenClawPluginApi, channelId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
for (const rec of Object.values(accounts)) {
|
||||
const chMap = (rec?.channels as Record<string, Record<string, unknown>> | undefined) || undefined;
|
||||
if (!chMap) continue;
|
||||
const direct = chMap[channelId];
|
||||
const prefixed = chMap[`channel:${channelId}`];
|
||||
const found = (direct || prefixed) as Record<string, unknown> | undefined;
|
||||
if (found && typeof found.guildId === "string" && found.guildId.trim()) return found.guildId.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function persistCache(api: OpenClawPluginApi): void {
|
||||
const p = cachePath(api);
|
||||
const out: Record<string, { botAccountIds: string[]; updatedAt: string; source: string; guildId?: string }> = {};
|
||||
for (const [channelId, set] of channelSeenAccounts.entries()) {
|
||||
out[channelId] = {
|
||||
botAccountIds: [...set],
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "dirigent/turn-bootstrap",
|
||||
guildId: inferGuildIdFromChannelId(api, channelId),
|
||||
};
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, "utf8");
|
||||
fs.renameSync(tmp, p);
|
||||
} catch (err) {
|
||||
api.logger.warn?.(`dirigent: failed persisting channel member cache: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(bindings)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const b of bindings) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
ids.push(match.accountId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] {
|
||||
const allBots = new Set(getAllBotAccountIds(api));
|
||||
const seen = channelSeenAccounts.get(channelId);
|
||||
if (!seen) return [];
|
||||
return [...seen].filter((id) => allBots.has(id));
|
||||
}
|
||||
|
||||
export function recordChannelAccount(api: OpenClawPluginApi, channelId: string, accountId: string): boolean {
|
||||
loadCache(api);
|
||||
let seen = channelSeenAccounts.get(channelId);
|
||||
if (!seen) {
|
||||
seen = new Set();
|
||||
channelSeenAccounts.set(channelId, seen);
|
||||
}
|
||||
if (seen.has(accountId)) return false;
|
||||
seen.add(accountId);
|
||||
persistCache(api);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): Promise<void> {
|
||||
loadCache(api);
|
||||
let botAccounts = getChannelBotAccountIds(api, channelId);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: turn-debug ensureTurnOrder enter channel=${channelId} cached=${JSON.stringify(botAccounts)} bootstrapTried=${channelBootstrapTried.has(channelId)}`,
|
||||
);
|
||||
|
||||
if (botAccounts.length === 0 && !channelBootstrapTried.has(channelId)) {
|
||||
channelBootstrapTried.add(channelId);
|
||||
const discovered = await fetchVisibleChannelBotAccountIds(api, channelId).catch(() => [] as string[]);
|
||||
api.logger.info(
|
||||
`dirigent: turn-debug ensureTurnOrder bootstrap-discovered channel=${channelId} discovered=${JSON.stringify(discovered)}`,
|
||||
);
|
||||
for (const aid of discovered) recordChannelAccount(api, channelId, aid);
|
||||
botAccounts = getChannelBotAccountIds(api, channelId);
|
||||
}
|
||||
|
||||
if (botAccounts.length > 0) {
|
||||
api.logger.info(
|
||||
`dirigent: turn-debug ensureTurnOrder initTurnOrder channel=${channelId} members=${JSON.stringify(botAccounts)}`,
|
||||
);
|
||||
initTurnOrder(channelId, botAccounts);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
export function pickDefined(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
|
||||
if (!cfg.enableDebugLogs) return false;
|
||||
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
|
||||
if (allow.length === 0) return true;
|
||||
if (!channelId) return true;
|
||||
return allow.includes(channelId);
|
||||
}
|
||||
|
||||
export function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
|
||||
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
|
||||
return {
|
||||
sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined,
|
||||
commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined,
|
||||
messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined,
|
||||
channel: typeof ctx.channel === "string" ? ctx.channel : undefined,
|
||||
channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined,
|
||||
senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined,
|
||||
from: typeof ctx.from === "string" ? ctx.from : undefined,
|
||||
metaSenderId:
|
||||
typeof meta.senderId === "string"
|
||||
? meta.senderId
|
||||
: typeof meta.sender_id === "string"
|
||||
? meta.sender_id
|
||||
: undefined,
|
||||
metaUserId:
|
||||
typeof meta.userId === "string"
|
||||
? meta.userId
|
||||
: typeof meta.user_id === "string"
|
||||
? meta.user_id
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
extractDiscordChannelId,
|
||||
extractDiscordChannelIdFromConversationMetadata,
|
||||
extractDiscordChannelIdFromSessionKey,
|
||||
extractUntrustedConversationInfo,
|
||||
} from "./channel-resolver.js";
|
||||
|
||||
export type DerivedDecisionInput = {
|
||||
channel: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
content: string;
|
||||
conv: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function deriveDecisionInputFromPrompt(params: {
|
||||
prompt: string;
|
||||
messageProvider?: string;
|
||||
sessionKey?: string;
|
||||
ctx?: Record<string, unknown>;
|
||||
event?: Record<string, unknown>;
|
||||
}): DerivedDecisionInput {
|
||||
const { prompt, messageProvider, sessionKey, ctx, event } = params;
|
||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||
const channel = (messageProvider || "").toLowerCase();
|
||||
|
||||
let channelId = extractDiscordChannelId(ctx || {}, event);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromSessionKey(sessionKey);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromConversationMetadata(conv);
|
||||
|
||||
const senderId =
|
||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||
(typeof conv.sender === "string" && conv.sender) ||
|
||||
undefined;
|
||||
|
||||
return { channel, channelId, senderId, content: prompt, conv };
|
||||
}
|
||||
272
plugin/hooks/agent-end.ts
Normal file
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;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { getTurnDebugInfo, onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type BeforeMessageWriteDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
} = deps;
|
||||
|
||||
api.on("before_message_write", (event, ctx) => {
|
||||
try {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
|
||||
);
|
||||
|
||||
const key = ctx.sessionKey;
|
||||
let channelId: string | undefined;
|
||||
let accountId: string | undefined;
|
||||
|
||||
if (key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
accountId = sessionAccountId.get(key);
|
||||
}
|
||||
|
||||
let content = "";
|
||||
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
||||
const msgContent = msg?.content;
|
||||
if (msg) {
|
||||
const role = msg.role as string | undefined;
|
||||
if (role && role !== "assistant") return;
|
||||
|
||||
// Detect tool calls — intermediate model step, not a final response.
|
||||
// Skip turn processing entirely to avoid false NO_REPLY detection.
|
||||
if (Array.isArray(msgContent)) {
|
||||
const hasToolCalls = (msgContent as Record<string, unknown>[]).some(
|
||||
(part) => part?.type === "toolCall" || part?.type === "tool_call" || part?.type === "tool_use",
|
||||
);
|
||||
if (hasToolCalls) {
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write skipping tool-call message session=${key ?? "undefined"} channel=${channelId ?? "undefined"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "string") content += part;
|
||||
else if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string") {
|
||||
content += (part as Record<string, unknown>).text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!content) {
|
||||
content = ((event as Record<string, unknown>).content as string) || "";
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write session=${key ?? "undefined"} channel=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} contentType=${typeof content} content=${String(content).slice(0, 200)}`,
|
||||
);
|
||||
|
||||
if (!key || !channelId || !accountId) return;
|
||||
|
||||
const currentTurn = getTurnDebugInfo(channelId);
|
||||
if (currentTurn.currentSpeaker !== accountId) {
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
// Treat explicit NO/NO_REPLY keywords as no-reply.
|
||||
const wasNoReply = isNoReply;
|
||||
|
||||
const turnDebug = getTurnDebugInfo(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
|
||||
);
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write wait-for-human triggered session=${key} channel=${channelId} accountId=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAllowed = sessionAllowed.get(key);
|
||||
|
||||
if (wasNoReply) {
|
||||
const noReplyKeyword = /^NO$/i.test(trimmed) ? "NO" : "NO_REPLY";
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed} keyword=${noReplyKeyword}`,
|
||||
);
|
||||
|
||||
if (wasAllowed === undefined) return;
|
||||
|
||||
if (wasAllowed === false) {
|
||||
sessionAllowed.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
|
||||
if (!nextSpeaker) {
|
||||
if (shouldDebugLog(live, channelId)) {
|
||||
api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => {
|
||||
api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
} else if (hasEndSymbol) {
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, false);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
} else {
|
||||
api.logger.info(`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,138 +1,165 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { checkTurn } from "../turn-manager.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
import type { ChannelStore } from "../core/channel-store.js";
|
||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||
import { isCurrentSpeaker, isTurnPending, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
||||
import { getLatestMessageId, sendScheduleTrigger } from "../core/moderator-discord.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
|
||||
export function parseDiscordChannelId(sessionKey: string): string | undefined {
|
||||
const m = sessionKey.match(/:discord:channel:(\d+)$/);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforeModelResolveDeps = {
|
||||
type Deps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
|
||||
pruneDecisionMap: () => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
debugMode: boolean;
|
||||
noReplyProvider: string;
|
||||
noReplyModel: string;
|
||||
};
|
||||
|
||||
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
} = deps;
|
||||
/**
|
||||
* Process-level deduplication for before_model_resolve events.
|
||||
* Uses a WeakSet keyed on the event object — works when OpenClaw passes
|
||||
* the same event reference to all stacked handlers (hot-reload scenario).
|
||||
* Stored on globalThis so it persists across module reloads.
|
||||
*/
|
||||
const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents";
|
||||
if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] = new WeakSet<object>();
|
||||
}
|
||||
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
|
||||
|
||||
export function registerBeforeModelResolveHook(deps: Deps): void {
|
||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, noReplyProvider, noReplyModel } = deps;
|
||||
|
||||
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
|
||||
const initializingChannels = getInitializingChannels();
|
||||
|
||||
api.on("before_model_resolve", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
// Deduplicate: if another handler instance already processed this event
|
||||
// object, skip. Prevents double-counting from hot-reload stacked handlers.
|
||||
const eventObj = event as object;
|
||||
if (processedBeforeModelResolveEvents.has(eventObj)) return;
|
||||
processedBeforeModelResolveEvents.add(eventObj);
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const sessionKey = ctx.sessionKey;
|
||||
if (!sessionKey) return;
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
// Only handle Discord group channel sessions
|
||||
const channelId = parseDiscordChannelId(sessionKey);
|
||||
if (!channelId) return;
|
||||
|
||||
if (live.enableDebugLogs) {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
|
||||
`promptPreview=${prompt.slice(0, 300)}`,
|
||||
);
|
||||
const mode = channelStore.getMode(channelId);
|
||||
|
||||
// dead/report mode: suppress all via no-reply model
|
||||
if (mode === "report" || mode === "dead" as string) {
|
||||
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||
}
|
||||
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
||||
|
||||
if (derived.channelId) {
|
||||
sessionChannelId.set(key, derived.channelId);
|
||||
}
|
||||
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (resolvedAccountId) {
|
||||
sessionAccountId.set(key, resolvedAccountId);
|
||||
}
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
sessionDecision.set(key, rec);
|
||||
pruneDecisionMap();
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_model_resolve recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
// concluded discussion: suppress via no-reply model
|
||||
if (mode === "discussion") {
|
||||
const rec = channelStore.getRecord(channelId);
|
||||
if (rec.discussion?.concluded) {
|
||||
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||
}
|
||||
}
|
||||
|
||||
if (derived.channelId) {
|
||||
await ensureTurnOrder(api, derived.channelId);
|
||||
const accountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (accountId) {
|
||||
const turnCheck = checkTurn(derived.channelId, accountId);
|
||||
if (!turnCheck.allowed) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(
|
||||
`dirigent: before_model_resolve blocking out-of-turn speaker session=${key} channel=${derived.channelId} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker}`,
|
||||
);
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
// disabled modes: no turn management
|
||||
if (mode === "none" || mode === "work") return;
|
||||
|
||||
// chat / discussion (active): check turn
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return;
|
||||
|
||||
// If speaker list not yet loaded, initialize it now
|
||||
if (!hasSpeakers(channelId)) {
|
||||
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
|
||||
if (initializingChannels.has(channelId)) {
|
||||
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
|
||||
// incrementBlockedPending so agent_end knows to expect a stale NO_REPLY completion later
|
||||
incrementBlockedPending(channelId, agentId);
|
||||
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||
}
|
||||
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;
|
||||
}
|
||||
sessionAllowed.set(key, true);
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
|
||||
return;
|
||||
} finally {
|
||||
initializingChannels.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) return;
|
||||
// Channel is dormant: suppress via no-reply model
|
||||
if (isDormant(channelId)) {
|
||||
api.logger.info(`dirigent: before_model_resolve suppressing dormant agentId=${agentId} channel=${channelId}`);
|
||||
incrementBlockedPending(channelId, agentId);
|
||||
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = { noReply: true };
|
||||
if (rec.decision.provider) out.provider = rec.decision.provider;
|
||||
if (rec.decision.model) out.model = rec.decision.model;
|
||||
return out;
|
||||
if (!isCurrentSpeaker(channelId, agentId)) {
|
||||
api.logger.info(`dirigent: before_model_resolve suppressing non-speaker agentId=${agentId} channel=${channelId}`);
|
||||
incrementBlockedPending(channelId, agentId);
|
||||
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||
}
|
||||
|
||||
// If a turn is already in progress for this agent, this is a duplicate wakeup
|
||||
// (e.g. agent woke itself via a message-tool send). Suppress it.
|
||||
if (isTurnPending(channelId, agentId)) {
|
||||
api.logger.info(`dirigent: before_model_resolve turn already in progress, suppressing self-wakeup agentId=${agentId} channel=${channelId}`);
|
||||
incrementBlockedPending(channelId, agentId);
|
||||
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||
}
|
||||
|
||||
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
|
||||
markTurnStarted(channelId, agentId);
|
||||
|
||||
// Current speaker: record anchor message ID for tail-match polling
|
||||
if (moderatorBotToken) {
|
||||
try {
|
||||
const anchorId = await getLatestMessageId(moderatorBotToken, channelId);
|
||||
if (anchorId) {
|
||||
setAnchor(channelId, agentId, anchorId);
|
||||
api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify agent has a known Discord user ID (needed for tail-match later)
|
||||
const identity = identityRegistry.findByAgentId(agentId);
|
||||
if (!identity) {
|
||||
api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforePromptBuildDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionInjected: Set<string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
buildEndMarkerInstruction: (endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string) => string;
|
||||
buildSchedulingIdentifierInstruction: (schedulingIdentifier: string) => string;
|
||||
buildAgentIdentity: (api: OpenClawPluginApi, agentId: string) => string;
|
||||
};
|
||||
|
||||
export function registerBeforePromptBuildHook(deps: BeforePromptBuildDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
buildEndMarkerInstruction,
|
||||
buildSchedulingIdentifierInstruction,
|
||||
buildAgentIdentity,
|
||||
} = deps;
|
||||
|
||||
api.on("before_prompt_build", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_prompt_build recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sessionDecision.delete(key);
|
||||
|
||||
if (sessionInjected.has(key)) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record<string, any>);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId);
|
||||
|
||||
let identity = "";
|
||||
if (isGroupChat && ctx.agentId) {
|
||||
const idStr = buildAgentIdentity(api, ctx.agentId);
|
||||
if (idStr) {
|
||||
identity = `\n\nYour agent identity: ${idStr}.`;
|
||||
}
|
||||
}
|
||||
|
||||
const schedulingInstruction = isGroupChat ? buildSchedulingIdentifierInstruction(schedulingId) : "";
|
||||
(event as Record<string, unknown>).prompt = `${prompt}\n\n${instruction}${identity}${schedulingInstruction}`;
|
||||
sessionInjected.add(key);
|
||||
});
|
||||
}
|
||||
@@ -1,113 +1,128 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId } from "../channel-resolver.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
import type { ChannelStore } from "../core/channel-store.js";
|
||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||
import { parseDiscordChannelId } from "./before-model-resolve.js";
|
||||
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
||||
import { sendScheduleTrigger, userIdFromBotToken } from "../core/moderator-discord.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
import type { InterruptFn } from "./agent-end.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageReceivedDeps = {
|
||||
type Deps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
debugCtxSummary: (ctx: Record<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
getModeratorUserId: (cfg: DirigentConfig) => string | undefined;
|
||||
recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean;
|
||||
extractMentionedUserIds: (content: string) => string[];
|
||||
buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map<string, string>;
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
interruptTailMatch: InterruptFn;
|
||||
debugMode: boolean;
|
||||
/**
|
||||
* When true, the moderator service handles wake-from-dormant and
|
||||
* interrupt-tail-match via HTTP callback. This hook only runs speaker-list
|
||||
* initialization in that case.
|
||||
*/
|
||||
moderatorHandlesMessages?: boolean;
|
||||
};
|
||||
|
||||
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
shouldDebugLog,
|
||||
debugCtxSummary,
|
||||
ensureTurnOrder,
|
||||
getModeratorUserId,
|
||||
recordChannelAccount,
|
||||
extractMentionedUserIds,
|
||||
buildUserIdToAccountIdMap,
|
||||
} = deps;
|
||||
export function registerMessageReceivedHook(deps: Deps): void {
|
||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch, debugMode, moderatorHandlesMessages } = deps;
|
||||
const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined;
|
||||
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
try {
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
const preChannelId = extractDiscordChannelId(c, e);
|
||||
const livePre = baseConfig as DirigentConfig & DebugConfig;
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
const e = event as Record<string, unknown>;
|
||||
const c = ctx as Record<string, unknown>;
|
||||
|
||||
// Extract Discord channel ID from ctx or event metadata
|
||||
let channelId: string | undefined;
|
||||
|
||||
if (typeof c.channelId === "string") {
|
||||
const bare = c.channelId.match(/^(\d+)$/)?.[1] ?? c.channelId.match(/:(\d+)$/)?.[1];
|
||||
if (bare) channelId = bare;
|
||||
}
|
||||
if (!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);
|
||||
}
|
||||
}
|
||||
|
||||
if (preChannelId) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
|
||||
const from =
|
||||
(typeof metadata?.senderId === "string" && metadata.senderId) ||
|
||||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
|
||||
// ── Wake / interrupt (skipped when moderator service handles it via HTTP callback) ──
|
||||
if (moderatorHandlesMessages) return;
|
||||
|
||||
const moderatorUserId = getModeratorUserId(livePre);
|
||||
if (moderatorUserId && from === moderatorUserId) {
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`);
|
||||
}
|
||||
} else {
|
||||
const humanList = livePre.humanList || livePre.bypassUserIds || [];
|
||||
const isHuman = humanList.includes(from);
|
||||
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
|
||||
const senderId = String(
|
||||
(e.metadata as Record<string, unknown>)?.senderId ??
|
||||
(e.metadata as Record<string, unknown>)?.sender_id ??
|
||||
e.from ?? "",
|
||||
);
|
||||
|
||||
if (senderAccountId && senderAccountId !== "default") {
|
||||
const isNew = recordChannelAccount(api, preChannelId, senderAccountId);
|
||||
if (isNew) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
|
||||
}
|
||||
}
|
||||
const currentSpeakerIsThisSender = (() => {
|
||||
if (!senderId) return false;
|
||||
const entry = identityRegistry.findByDiscordUserId(senderId);
|
||||
if (!entry) return false;
|
||||
return isCurrentSpeaker(channelId!, entry.agentId);
|
||||
})();
|
||||
|
||||
if (isHuman) {
|
||||
const messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || "";
|
||||
const mentionedUserIds = extractMentionedUserIds(messageContent);
|
||||
if (!currentSpeakerIsThisSender) {
|
||||
if (senderId !== moderatorBotUserId) {
|
||||
interruptTailMatch(channelId);
|
||||
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
|
||||
}
|
||||
|
||||
if (mentionedUserIds.length > 0) {
|
||||
const userIdMap = buildUserIdToAccountIdMap(api);
|
||||
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid);
|
||||
|
||||
if (mentionedAccountIds.length > 0) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds);
|
||||
if (overrideSet) {
|
||||
api.logger.info(
|
||||
`dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`,
|
||||
);
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`,
|
||||
);
|
||||
if (isDormant(channelId) && moderatorBotToken && senderId !== moderatorBotUserId) {
|
||||
const first = wakeFromDormant(channelId);
|
||||
if (first) {
|
||||
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
|
||||
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
|
||||
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message hook failed: ${String(err)}`);
|
||||
api.logger.warn(`dirigent: message_received hook error: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "../channel-resolver.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageSentDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function registerMessageSentHook(deps: MessageSentDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
} = deps;
|
||||
|
||||
api.on("message_sent", async (event, ctx) => {
|
||||
try {
|
||||
const key = ctx.sessionKey;
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` +
|
||||
`ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` +
|
||||
`ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` +
|
||||
`session=${key ?? "undefined"}`,
|
||||
);
|
||||
|
||||
let channelId = extractDiscordChannelId(c, e);
|
||||
if (!channelId && key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
}
|
||||
if (!channelId && key) {
|
||||
channelId = extractDiscordChannelIdFromSessionKey(key);
|
||||
}
|
||||
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
|
||||
const content = (event.content as string) || "";
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
if (!channelId || !accountId) return;
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
// Treat explicit NO/NO_REPLY keywords as no-reply.
|
||||
const wasNoReply = isNoReply;
|
||||
|
||||
if (key && sessionTurnHandled.has(key)) {
|
||||
sessionTurnHandled.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasNoReply || hasEndSymbol) {
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
|
||||
const trigger = wasNoReply ? "no_reply_keyword" : "end_symbol";
|
||||
const noReplyKeyword = wasNoReply ? (/^NO$/i.test(trimmed) ? "NO" : "NO_REPLY") : "";
|
||||
const keywordNote = wasNoReply ? ` keyword=${noReplyKeyword}` : "";
|
||||
api.logger.info(
|
||||
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}${keywordNote}`,
|
||||
);
|
||||
|
||||
if (wasNoReply && nextSpeaker && live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
await sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
410
plugin/index.ts
410
plugin/index.ts
@@ -1,206 +1,294 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { DirigentConfig } from "./rules.js";
|
||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
||||
import { IdentityRegistry } from "./core/identity-registry.js";
|
||||
import { ChannelStore } from "./core/channel-store.js";
|
||||
import { scanPaddedCell } from "./core/padded-cell.js";
|
||||
import { startSideCar, stopSideCar } from "./core/sidecar-process.js";
|
||||
import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
|
||||
import { registerBeforePromptBuildHook } from "./hooks/before-prompt-build.js";
|
||||
import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js";
|
||||
import { registerMessageSentHook } from "./hooks/message-sent.js";
|
||||
import { registerDirigentCommand } from "./commands/dirigent-command.js";
|
||||
import { registerAgentEndHook } from "./hooks/agent-end.js";
|
||||
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
||||
import { registerDirigentTools } from "./tools/register-tools.js";
|
||||
import { ensurePolicyStateLoaded, persistPolicies, policyState } from "./policy/store.js";
|
||||
import { buildAgentIdentity, buildUserIdToAccountIdMap, resolveAccountId } from "./core/identity.js";
|
||||
import { extractMentionedUserIds, getModeratorUserId } from "./core/mentions.js";
|
||||
import { ensureTurnOrder, recordChannelAccount } from "./core/turn-bootstrap.js";
|
||||
import { debugCtxSummary, pickDefined, shouldDebugLog } from "./core/utils.js";
|
||||
import { resolveDiscordUserId, sendModeratorMessage } from "./core/moderator-discord.js";
|
||||
import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js";
|
||||
import {
|
||||
DECISION_TTL_MS,
|
||||
pruneDecisionMap,
|
||||
sessionAccountId,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
sessionTurnHandled,
|
||||
} from "./core/session-state.js";
|
||||
import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js";
|
||||
import { registerAddGuildCommand } from "./commands/add-guild-command.js";
|
||||
import { registerControlPage } from "./web/control-page.js";
|
||||
import { registerDirigentApi } from "./web/dirigent-api.js";
|
||||
import { sendModeratorMessage, sendScheduleTrigger, getBotUserIdFromToken } from "./core/moderator-discord.js";
|
||||
import { setSpeakerList, isCurrentSpeaker, isDormant, wakeFromDormant } from "./turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
type PluginConfig = {
|
||||
moderatorBotToken?: string;
|
||||
scheduleIdentifier?: string;
|
||||
identityFilePath?: string;
|
||||
channelStoreFilePath?: string;
|
||||
debugMode?: boolean;
|
||||
noReplyProvider?: string;
|
||||
noReplyModel?: string;
|
||||
sideCarPort?: number;
|
||||
};
|
||||
|
||||
type NormalizedDirigentConfig = DirigentConfig & {
|
||||
enableDiscordControlTool: boolean;
|
||||
enableDirigentPolicyTool: boolean;
|
||||
};
|
||||
|
||||
function normalizePluginConfig(api: OpenClawPluginApi): NormalizedDirigentConfig {
|
||||
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
|
||||
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
||||
return {
|
||||
enableDiscordControlTool: true,
|
||||
enableDirigentPolicyTool: true,
|
||||
enableDebugLogs: false,
|
||||
debugLogChannelIds: [],
|
||||
noReplyPort: 8787,
|
||||
schedulingIdentifier: "➡️",
|
||||
waitIdentifier: "👤",
|
||||
...(api.pluginConfig || {}),
|
||||
} as NormalizedDirigentConfig;
|
||||
moderatorBotToken: cfg.moderatorBotToken ?? "",
|
||||
scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️",
|
||||
identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"),
|
||||
channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"),
|
||||
debugMode: cfg.debugMode ?? false,
|
||||
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
|
||||
noReplyModel: cfg.noReplyModel ?? "no-reply",
|
||||
sideCarPort: cfg.sideCarPort ?? 8787,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string {
|
||||
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
|
||||
let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK, NO, or an empty response) must NOT include ${symbols}.`;
|
||||
if (isGroupChat) {
|
||||
instruction += `\n\nGroup chat rules: If this message is not relevant to you, does not need your response, or you have nothing valuable to add, reply with NO_REPLY. Do not speak just for the sake of speaking.`;
|
||||
instruction += `\n\nWait for human reply: If you need a human to respond to your message, end with ${waitIdentifier} instead of ${symbols}. This pauses all agents until a human speaks. Use this sparingly — only when you are confident the human is actively participating in the discussion (has sent a message recently). Do NOT use it speculatively.`;
|
||||
function getGatewayPort(api: OpenClawPluginApi): number {
|
||||
try {
|
||||
return ((api.config as Record<string, unknown>)?.gateway as Record<string, unknown>)?.port as number ?? 18789;
|
||||
} catch {
|
||||
return 18789;
|
||||
}
|
||||
return instruction;
|
||||
}
|
||||
|
||||
function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): string {
|
||||
return `\n\nScheduling identifier: "${schedulingIdentifier}". This identifier itself is meaningless — it carries no semantic content. When you receive a message containing <@YOUR_USER_ID> followed by the scheduling identifier, check recent chat history and decide whether you have something to say. If not, reply NO_REPLY.`;
|
||||
/**
|
||||
* Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once
|
||||
* when the gateway process starts/stops, not per agent session. We guard these on
|
||||
* globalThis so only the first register() call adds the lifecycle handlers.
|
||||
*
|
||||
* Agent-session events (before_model_resolve, agent_end, message_received) are
|
||||
* delivered via the api instance that belongs to each individual agent session.
|
||||
* OpenClaw creates a new VM context (and calls register() again) for each hot-reload
|
||||
* within a session. We register those handlers unconditionally — event-level dedup
|
||||
* (WeakSet / runId Set, also stored on globalThis) prevents double-processing.
|
||||
*
|
||||
* All VM contexts share the real globalThis because they run in the same Node.js
|
||||
* process as openclaw-gateway.
|
||||
*/
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
const _GATEWAY_LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered";
|
||||
|
||||
function isGatewayLifecycleRegistered(): boolean {
|
||||
return !!_G[_GATEWAY_LIFECYCLE_KEY];
|
||||
}
|
||||
|
||||
function markGatewayLifecycleRegistered(): void {
|
||||
_G[_GATEWAY_LIFECYCLE_KEY] = true;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "dirigent",
|
||||
name: "Dirigent",
|
||||
register(api: OpenClawPluginApi) {
|
||||
const baseConfig = normalizePluginConfig(api);
|
||||
ensurePolicyStateLoaded(api, baseConfig);
|
||||
|
||||
// Resolve plugin directory for locating sibling modules (no-reply-api/)
|
||||
// Note: api.resolvePath(".") returns cwd, not script directory. Use import.meta.url instead.
|
||||
const config = normalizeConfig(api);
|
||||
const pluginDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
api.logger.info(`dirigent: pluginDir resolved from import.meta.url: ${pluginDir}`);
|
||||
const openclawDir = path.join(os.homedir(), ".openclaw");
|
||||
|
||||
// Gateway lifecycle: start/stop no-reply API and moderator bot with the gateway
|
||||
api.on("gateway_start", () => {
|
||||
api.logger.info(`dirigent: gateway_start event received`);
|
||||
const identityRegistry = new IdentityRegistry(config.identityFilePath);
|
||||
const channelStore = new ChannelStore(config.channelStoreFilePath);
|
||||
|
||||
const live = normalizePluginConfig(api);
|
||||
const moderatorBotToken = config.moderatorBotToken || undefined;
|
||||
const moderatorBotUserId = moderatorBotToken ? getBotUserIdFromToken(moderatorBotToken) : undefined;
|
||||
const moderatorServiceUrl = `http://127.0.0.1:${config.sideCarPort}/moderator`;
|
||||
|
||||
// Check no-reply-api server file exists
|
||||
const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs");
|
||||
api.logger.info(`dirigent: checking no-reply-api server at ${serverPath}, exists=${fs.existsSync(serverPath)}`);
|
||||
let paddedCellDetected = false;
|
||||
|
||||
// Additional debug: list what's in the plugin directory
|
||||
try {
|
||||
const entries = fs.readdirSync(pluginDir);
|
||||
api.logger.info(`dirigent: plugin dir (${pluginDir}) entries: ${JSON.stringify(entries)}`);
|
||||
} catch (e) {
|
||||
api.logger.warn(`dirigent: cannot read plugin dir: ${String(e)}`);
|
||||
function hasPaddedCell(): boolean {
|
||||
return paddedCellDetected;
|
||||
}
|
||||
|
||||
function tryAutoScanPaddedCell(): void {
|
||||
const count = scanPaddedCell(identityRegistry, openclawDir, api.logger);
|
||||
paddedCellDetected = count >= 0;
|
||||
if (paddedCellDetected) {
|
||||
api.logger.info(`dirigent: padded-cell detected — ${count} identity entries auto-registered`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway lifecycle (once per gateway process) ───────────────────────
|
||||
if (!isGatewayLifecycleRegistered()) {
|
||||
markGatewayLifecycleRegistered();
|
||||
|
||||
const gatewayPort = getGatewayPort(api);
|
||||
|
||||
// Start unified services (no-reply API + moderator bot)
|
||||
startSideCar(
|
||||
api.logger,
|
||||
pluginDir,
|
||||
config.sideCarPort,
|
||||
moderatorBotToken,
|
||||
undefined, // pluginApiToken — gateway handles auth for plugin routes
|
||||
gatewayPort,
|
||||
config.debugMode,
|
||||
);
|
||||
|
||||
if (!moderatorBotToken) {
|
||||
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
||||
}
|
||||
|
||||
startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787));
|
||||
api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`);
|
||||
tryAutoScanPaddedCell();
|
||||
|
||||
if (live.moderatorBotToken) {
|
||||
api.logger.info("dirigent: starting moderator bot presence...");
|
||||
startModeratorPresence(live.moderatorBotToken, api.logger);
|
||||
api.logger.info("dirigent: moderator bot presence started");
|
||||
} else {
|
||||
api.logger.info("dirigent: moderator bot not starting - no moderatorBotToken in config");
|
||||
}
|
||||
});
|
||||
|
||||
api.on("gateway_stop", () => {
|
||||
stopNoReplyApi(api.logger);
|
||||
stopModeratorPresence();
|
||||
api.logger.info("dirigent: gateway stopping, services shut down");
|
||||
});
|
||||
|
||||
// Register tools
|
||||
registerDirigentTools({
|
||||
api,
|
||||
baseConfig,
|
||||
pickDefined,
|
||||
});
|
||||
|
||||
// Turn management is handled internally by the plugin (not exposed as tools).
|
||||
// Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control.
|
||||
|
||||
registerMessageReceivedHook({
|
||||
api,
|
||||
baseConfig,
|
||||
shouldDebugLog,
|
||||
debugCtxSummary,
|
||||
ensureTurnOrder,
|
||||
getModeratorUserId,
|
||||
recordChannelAccount,
|
||||
extractMentionedUserIds,
|
||||
buildUserIdToAccountIdMap,
|
||||
});
|
||||
api.on("gateway_stop", () => {
|
||||
stopSideCar(api.logger);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Hooks (registered on every api instance — event-level dedup handles duplicates) ──
|
||||
registerBeforeModelResolveHook({
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
debugMode: config.debugMode,
|
||||
noReplyProvider: config.noReplyProvider,
|
||||
noReplyModel: config.noReplyModel,
|
||||
});
|
||||
|
||||
registerBeforePromptBuildHook({
|
||||
const interruptTailMatch = registerAgentEndHook({
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
buildEndMarkerInstruction,
|
||||
buildSchedulingIdentifierInstruction,
|
||||
buildAgentIdentity,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
debugMode: config.debugMode,
|
||||
onDiscussionDormant: async (channelId: string) => {
|
||||
const live = normalizeConfig(api);
|
||||
if (!live.moderatorBotToken) return;
|
||||
const rec = channelStore.getRecord(channelId);
|
||||
if (!rec.discussion || rec.discussion.concluded) return;
|
||||
const initiatorEntry = identityRegistry.findByAgentId(rec.discussion.initiatorAgentId);
|
||||
const mention = initiatorEntry ? `<@${initiatorEntry.discordUserId}>` : rec.discussion.initiatorAgentId;
|
||||
await sendModeratorMessage(
|
||||
live.moderatorBotToken,
|
||||
channelId,
|
||||
`${mention} Discussion is idle. Please summarize the results and call \`discussion-complete\`.`,
|
||||
api.logger,
|
||||
).catch(() => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
// Register slash commands for Discord
|
||||
registerDirigentCommand({
|
||||
// Speaker-list init still handled via message_received (needs OpenClaw API for channel member lookup)
|
||||
registerMessageReceivedHook({
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
persistPolicies,
|
||||
ensurePolicyStateLoaded,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
interruptTailMatch,
|
||||
debugMode: config.debugMode,
|
||||
// When moderator service is active it handles wake/interrupt via HTTP callback;
|
||||
// message_received only needs to run speaker-list initialization.
|
||||
moderatorHandlesMessages: !!moderatorBotToken,
|
||||
});
|
||||
|
||||
// Handle NO_REPLY detection before message write
|
||||
registerBeforeMessageWriteHook({
|
||||
// ── Dirigent API (moderator service → plugin callbacks) ───────────────
|
||||
registerDirigentApi({
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
channelStore,
|
||||
moderatorBotUserId,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
moderatorServiceUrl,
|
||||
moderatorServiceToken: undefined,
|
||||
debugMode: config.debugMode,
|
||||
onNewMessage: async ({ channelId, senderId }) => {
|
||||
const mode = channelStore.getMode(channelId);
|
||||
|
||||
// Modes where agents don't participate
|
||||
if (mode === "none" || mode === "work" || mode === "report") return;
|
||||
|
||||
// Skip messages from the moderator bot itself (schedule triggers, etc.)
|
||||
if (senderId === moderatorBotUserId) return;
|
||||
|
||||
// Concluded discussion: send "closed" reply via moderator service
|
||||
if (mode === "discussion") {
|
||||
const rec = channelStore.getRecord(channelId);
|
||||
if (rec.discussion?.concluded && moderatorBotToken) {
|
||||
await sendModeratorMessage(
|
||||
moderatorBotToken,
|
||||
channelId,
|
||||
"This discussion is closed and no longer active.",
|
||||
api.logger,
|
||||
).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Identify sender — is it the current speaker?
|
||||
const senderEntry = identityRegistry.findByDiscordUserId(senderId);
|
||||
const currentSpeakerIsThisSender = senderEntry
|
||||
? isCurrentSpeaker(channelId, senderEntry.agentId)
|
||||
: false;
|
||||
|
||||
if (!currentSpeakerIsThisSender) {
|
||||
// Non-current-speaker: interrupt any ongoing tail-match poll
|
||||
interruptTailMatch(channelId);
|
||||
api.logger.info(`dirigent: moderator-callback interrupt tail-match channel=${channelId} senderId=${senderId}`);
|
||||
|
||||
// Wake from dormant if needed
|
||||
if (isDormant(channelId) && moderatorBotToken) {
|
||||
const first = wakeFromDormant(channelId);
|
||||
if (first) {
|
||||
const msg = `<@${first.discordUserId}>${config.scheduleIdentifier}`;
|
||||
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, config.debugMode);
|
||||
api.logger.info(`dirigent: moderator-callback woke dormant channel=${channelId} first=${first.agentId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Turn advance: when an agent sends a message, check if it signals end of turn
|
||||
registerMessageSentHook({
|
||||
// ── Tools ──────────────────────────────────────────────────────────────
|
||||
registerDirigentTools({
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
onDiscussionCreate: async ({ channelId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => {
|
||||
const live = normalizeConfig(api);
|
||||
if (!live.moderatorBotToken) return;
|
||||
|
||||
// Post discussion-guide to wake participants
|
||||
await sendModeratorMessage(live.moderatorBotToken, channelId, discussionGuide, api.logger)
|
||||
.catch(() => undefined);
|
||||
|
||||
// Initialize speaker list
|
||||
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
|
||||
const speakers = agentIds
|
||||
.map((aid) => {
|
||||
const entry = identityRegistry.findByAgentId(aid);
|
||||
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
|
||||
})
|
||||
.filter((s): s is NonNullable<typeof s> => s !== null);
|
||||
|
||||
if (speakers.length > 0) {
|
||||
setSpeakerList(channelId, speakers);
|
||||
const first = speakers[0];
|
||||
await sendScheduleTrigger(
|
||||
live.moderatorBotToken,
|
||||
channelId,
|
||||
`<@${first.discordUserId}>${live.scheduleIdentifier}`,
|
||||
api.logger,
|
||||
live.debugMode,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Commands ───────────────────────────────────────────────────────────
|
||||
registerSetChannelModeCommand({ api, channelStore });
|
||||
registerAddGuildCommand(api);
|
||||
|
||||
// ── Control page ───────────────────────────────────────────────────────
|
||||
registerControlPage({
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken,
|
||||
openclawDir,
|
||||
hasPaddedCell,
|
||||
});
|
||||
|
||||
api.logger.info("dirigent: plugin registered (v2)");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* Minimal Discord Gateway connection to keep the moderator bot "online".
|
||||
* Uses Node.js built-in WebSocket (Node 22+).
|
||||
*
|
||||
* IMPORTANT: Only ONE instance should exist per bot token.
|
||||
* Uses a singleton guard to prevent multiple connections.
|
||||
*/
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let heartbeatAcked = true;
|
||||
let lastSequence: number | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let resumeUrl: string | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let destroyed = false;
|
||||
let started = false; // singleton guard
|
||||
|
||||
type Logger = {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
};
|
||||
|
||||
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
||||
const MAX_RECONNECT_DELAY_MS = 60_000;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
function sendPayload(data: Record<string, unknown>) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat(intervalMs: number) {
|
||||
stopHeartbeat();
|
||||
heartbeatAcked = true;
|
||||
|
||||
// First heartbeat after jitter
|
||||
const jitter = Math.floor(Math.random() * intervalMs);
|
||||
const firstTimer = setTimeout(() => {
|
||||
if (destroyed) return;
|
||||
if (!heartbeatAcked) {
|
||||
// Missed ACK — zombie connection, close and reconnect
|
||||
ws?.close(4000, "missed heartbeat ack");
|
||||
return;
|
||||
}
|
||||
heartbeatAcked = false;
|
||||
sendPayload({ op: 1, d: lastSequence });
|
||||
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (destroyed) return;
|
||||
if (!heartbeatAcked) {
|
||||
ws?.close(4000, "missed heartbeat ack");
|
||||
return;
|
||||
}
|
||||
heartbeatAcked = false;
|
||||
sendPayload({ op: 1, d: lastSequence });
|
||||
}, intervalMs);
|
||||
}, jitter);
|
||||
|
||||
// Store the first timer so we can clear it
|
||||
heartbeatInterval = firstTimer as unknown as ReturnType<typeof setInterval>;
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
clearTimeout(heartbeatInterval as unknown as ReturnType<typeof setTimeout>);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
stopHeartbeat();
|
||||
if (ws) {
|
||||
// Remove all handlers to avoid ghost callbacks
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
try { ws.close(1000); } catch { /* ignore */ }
|
||||
ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connect(token: string, logger: Logger, isResume = false) {
|
||||
if (destroyed) return;
|
||||
|
||||
// Clean up any existing connection first
|
||||
cleanup();
|
||||
|
||||
const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
logger.warn(`dirigent: moderator ws constructor failed: ${String(err)}`);
|
||||
scheduleReconnect(token, logger, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWs = ws; // capture for closure
|
||||
|
||||
ws.onopen = () => {
|
||||
if (currentWs !== ws || destroyed) return; // stale
|
||||
|
||||
reconnectAttempts = 0; // reset on successful open
|
||||
|
||||
if (isResume && sessionId) {
|
||||
sendPayload({
|
||||
op: 6,
|
||||
d: { token, session_id: sessionId, seq: lastSequence },
|
||||
});
|
||||
} else {
|
||||
sendPayload({
|
||||
op: 2,
|
||||
d: {
|
||||
token,
|
||||
intents: 0,
|
||||
properties: {
|
||||
os: "linux",
|
||||
browser: "dirigent",
|
||||
device: "dirigent",
|
||||
},
|
||||
presence: {
|
||||
status: "online",
|
||||
activities: [{ name: "Moderating", type: 3 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (evt: MessageEvent) => {
|
||||
if (currentWs !== ws || destroyed) return;
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
|
||||
const { op, t, s, d } = msg;
|
||||
|
||||
if (s != null) lastSequence = s;
|
||||
|
||||
switch (op) {
|
||||
case 10: // Hello
|
||||
startHeartbeat(d.heartbeat_interval);
|
||||
break;
|
||||
case 11: // Heartbeat ACK
|
||||
heartbeatAcked = true;
|
||||
break;
|
||||
case 1: // Heartbeat request
|
||||
sendPayload({ op: 1, d: lastSequence });
|
||||
break;
|
||||
case 0: // Dispatch
|
||||
if (t === "READY") {
|
||||
sessionId = d.session_id;
|
||||
resumeUrl = d.resume_gateway_url;
|
||||
logger.info("dirigent: moderator bot connected and online");
|
||||
}
|
||||
if (t === "RESUMED") {
|
||||
logger.info("dirigent: moderator bot resumed");
|
||||
}
|
||||
break;
|
||||
case 7: // Reconnect request
|
||||
logger.info("dirigent: moderator bot reconnect requested by Discord");
|
||||
cleanup();
|
||||
scheduleReconnect(token, logger, true);
|
||||
break;
|
||||
case 9: // Invalid Session
|
||||
logger.warn(`dirigent: moderator bot invalid session, resumable=${d}`);
|
||||
cleanup();
|
||||
sessionId = d ? sessionId : null;
|
||||
// Wait longer before re-identifying
|
||||
setTimeout(() => {
|
||||
if (!destroyed) connect(token, logger, !!d && !!sessionId);
|
||||
}, 3000 + Math.random() * 2000);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (evt: CloseEvent) => {
|
||||
if (currentWs !== ws) return; // stale ws
|
||||
stopHeartbeat();
|
||||
if (destroyed) return;
|
||||
|
||||
const code = evt.code;
|
||||
|
||||
// Non-recoverable codes — stop reconnecting
|
||||
if (code === 4004) {
|
||||
logger.warn("dirigent: moderator bot token invalid (4004), stopping");
|
||||
started = false;
|
||||
return;
|
||||
}
|
||||
if (code === 4010 || code === 4011 || code === 4013 || code === 4014) {
|
||||
logger.warn(`dirigent: moderator bot fatal close (${code}), re-identifying`);
|
||||
sessionId = null;
|
||||
scheduleReconnect(token, logger, false);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`dirigent: moderator bot disconnected (code=${code}), will reconnect`);
|
||||
const canResume = !!sessionId && code !== 4012;
|
||||
scheduleReconnect(token, logger, canResume);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect(token: string, logger: Logger, resume: boolean) {
|
||||
if (destroyed) return;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
|
||||
// Exponential backoff with cap
|
||||
reconnectAttempts++;
|
||||
const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS);
|
||||
const jitter = Math.random() * 1000;
|
||||
const delay = baseDelay + jitter;
|
||||
|
||||
logger.info(`dirigent: moderator reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect(token, logger, resume);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the moderator bot's Discord Gateway connection.
|
||||
* Singleton: calling multiple times with the same token is safe (no-op).
|
||||
*/
|
||||
export function startModeratorPresence(token: string, logger: Logger): void {
|
||||
if (started) {
|
||||
logger.info("dirigent: moderator presence already started, skipping");
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
destroyed = false;
|
||||
reconnectAttempts = 0;
|
||||
connect(token, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the moderator bot.
|
||||
*/
|
||||
export function stopModeratorPresence(): void {
|
||||
destroyed = true;
|
||||
started = false;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
@@ -8,25 +8,15 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"discordOnly": { "type": "boolean", "default": true },
|
||||
"listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" },
|
||||
"humanList": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"agentList": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"channelPoliciesFile": { "type": "string", "default": "~/.openclaw/dirigent-channel-policies.json" },
|
||||
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
|
||||
"schedulingIdentifier": { "type": "string", "default": "➡️" },
|
||||
"waitIdentifier": { "type": "string", "default": "👤" },
|
||||
"noReplyProvider": { "type": "string" },
|
||||
"noReplyModel": { "type": "string" },
|
||||
"noReplyPort": { "type": "number", "default": 8787 },
|
||||
"enableDiscordControlTool": { "type": "boolean", "default": true },
|
||||
"enableDirigentPolicyTool": { "type": "boolean", "default": true },
|
||||
"enableDebugLogs": { "type": "boolean", "default": false },
|
||||
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"moderatorBotToken": { "type": "string" }
|
||||
"moderatorBotToken": { "type": "string" },
|
||||
"scheduleIdentifier": { "type": "string", "default": "➡️" },
|
||||
"identityFilePath": { "type": "string" },
|
||||
"channelStoreFilePath": { "type": "string" },
|
||||
"debugMode": { "type": "boolean", "default": false },
|
||||
"noReplyProvider": { "type": "string", "default": "dirigent" },
|
||||
"noReplyModel": { "type": "string", "default": "no-reply" },
|
||||
"sideCarPort": { "type": "number", "default": 8787 }
|
||||
},
|
||||
"required": ["noReplyProvider", "noReplyModel"]
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { ChannelPolicy, DirigentConfig } from "../rules.js";
|
||||
|
||||
export type PolicyState = {
|
||||
filePath: string;
|
||||
channelPolicies: Record<string, ChannelPolicy>;
|
||||
};
|
||||
|
||||
export const policyState: PolicyState = {
|
||||
filePath: "",
|
||||
channelPolicies: {},
|
||||
};
|
||||
|
||||
export function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string {
|
||||
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json");
|
||||
}
|
||||
|
||||
export function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig): void {
|
||||
if (policyState.filePath) return;
|
||||
const filePath = resolvePoliciesPath(api, config);
|
||||
policyState.filePath = filePath;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "{}\n", "utf8");
|
||||
policyState.channelPolicies = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
|
||||
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: failed init policy file ${filePath}: ${String(err)}`);
|
||||
policyState.channelPolicies = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function persistPolicies(api: OpenClawPluginApi): void {
|
||||
if (!policyState.filePath) throw new Error("policy state not initialized");
|
||||
const dir = path.dirname(policyState.filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${policyState.filePath}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(policyState.channelPolicies, null, 2)}\n`, "utf8");
|
||||
fs.renameSync(tmp, policyState.filePath);
|
||||
api.logger.info(`dirigent: policy file updated at ${policyState.filePath}`);
|
||||
}
|
||||
139
plugin/rules.ts
139
plugin/rules.ts
@@ -1,139 +0,0 @@
|
||||
export type DirigentConfig = {
|
||||
enabled?: boolean;
|
||||
discordOnly?: boolean;
|
||||
listMode?: "human-list" | "agent-list";
|
||||
humanList?: string[];
|
||||
agentList?: string[];
|
||||
channelPoliciesFile?: string;
|
||||
// backward compatibility
|
||||
bypassUserIds?: string[];
|
||||
endSymbols?: string[];
|
||||
/** Scheduling identifier sent by moderator to activate agents (default: ➡️) */
|
||||
schedulingIdentifier?: string;
|
||||
/** Wait identifier: agent ends with this when waiting for a human reply (default: 👤) */
|
||||
waitIdentifier?: string;
|
||||
noReplyProvider: string;
|
||||
noReplyModel: string;
|
||||
noReplyPort?: number;
|
||||
/** Discord bot token for the moderator bot (used for turn handoff messages) */
|
||||
moderatorBotToken?: string;
|
||||
};
|
||||
|
||||
export type ChannelPolicy = {
|
||||
listMode?: "human-list" | "agent-list";
|
||||
humanList?: string[];
|
||||
agentList?: string[];
|
||||
endSymbols?: string[];
|
||||
};
|
||||
|
||||
export type Decision = {
|
||||
shouldUseNoReply: boolean;
|
||||
shouldInjectEndMarkerPrompt: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip trailing OpenClaw metadata blocks from the prompt to get the actual message content.
|
||||
* The prompt format is: [prepend instruction]\n\n[message]\n\nConversation info (untrusted metadata):\n```json\n{...}\n```\n\nSender (untrusted metadata):\n```json\n{...}\n```
|
||||
*/
|
||||
function stripTrailingMetadata(input: string): string {
|
||||
// Remove all trailing "XXX (untrusted metadata):\n```json\n...\n```" blocks
|
||||
let text = input;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const m = text.match(/\n*[A-Z][^\n]*\(untrusted metadata\):\s*```json\s*[\s\S]*?```\s*$/);
|
||||
if (!m) break;
|
||||
text = text.slice(0, text.length - m[0].length);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function getLastChar(input: string): string {
|
||||
const t = stripTrailingMetadata(input).trim();
|
||||
if (!t.length) return "";
|
||||
// Use Array.from to handle multi-byte characters (emoji, surrogate pairs)
|
||||
const chars = Array.from(t);
|
||||
return chars[chars.length - 1] || "";
|
||||
}
|
||||
|
||||
export function resolvePolicy(config: DirigentConfig, channelId?: string, channelPolicies?: Record<string, ChannelPolicy>) {
|
||||
const globalMode = config.listMode || "human-list";
|
||||
const globalHuman = config.humanList || config.bypassUserIds || [];
|
||||
const globalAgent = config.agentList || [];
|
||||
const globalEnd = config.endSymbols || ["🔚"];
|
||||
|
||||
if (!channelId) {
|
||||
return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd };
|
||||
}
|
||||
|
||||
const cp = channelPolicies || {};
|
||||
const scoped = cp[channelId];
|
||||
if (!scoped) {
|
||||
return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd };
|
||||
}
|
||||
|
||||
return {
|
||||
listMode: scoped.listMode || globalMode,
|
||||
humanList: scoped.humanList || globalHuman,
|
||||
agentList: scoped.agentList || globalAgent,
|
||||
endSymbols: scoped.endSymbols || globalEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateDecision(params: {
|
||||
config: DirigentConfig;
|
||||
channel?: string;
|
||||
channelId?: string;
|
||||
channelPolicies?: Record<string, ChannelPolicy>;
|
||||
senderId?: string;
|
||||
content?: string;
|
||||
}): Decision {
|
||||
const { config } = params;
|
||||
|
||||
if (config.enabled === false) {
|
||||
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
const channel = (params.channel || "").toLowerCase();
|
||||
if (config.discordOnly !== false && channel !== "discord") {
|
||||
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" };
|
||||
}
|
||||
|
||||
// DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId),
|
||||
// this is a DM session where untrusted metadata is not injected. Always allow through.
|
||||
if (!params.senderId && !params.channelId) {
|
||||
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "dm_no_metadata_bypass" };
|
||||
}
|
||||
|
||||
const policy = resolvePolicy(config, params.channelId, params.channelPolicies);
|
||||
|
||||
const mode = policy.listMode;
|
||||
const humanList = policy.humanList;
|
||||
const agentList = policy.agentList;
|
||||
|
||||
const senderId = params.senderId || "";
|
||||
const inHumanList = !!senderId && humanList.includes(senderId);
|
||||
const inAgentList = !!senderId && agentList.includes(senderId);
|
||||
|
||||
const lastChar = getLastChar(params.content || "");
|
||||
const hasEnd = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
|
||||
if (mode === "human-list") {
|
||||
if (inHumanList) {
|
||||
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "human_list_sender" };
|
||||
}
|
||||
if (hasEnd) {
|
||||
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` };
|
||||
}
|
||||
return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "rule_match_no_end_symbol" };
|
||||
}
|
||||
|
||||
// agent-list mode: listed senders require end symbol; others bypass requirement.
|
||||
if (!inAgentList) {
|
||||
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "non_agent_list_sender" };
|
||||
}
|
||||
if (hasEnd) {
|
||||
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` };
|
||||
}
|
||||
return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "agent_list_missing_end_symbol" };
|
||||
}
|
||||
@@ -1,160 +1,353 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type DiscordControlAction = "channel-private-create" | "channel-private-update";
|
||||
import type { ChannelStore } from "../core/channel-store.js";
|
||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||
import { createDiscordChannel, getBotUserIdFromToken } from "../core/moderator-discord.js";
|
||||
import { setSpeakerList } from "../turn-manager.js";
|
||||
|
||||
type ToolDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
pickDefined: (obj: Record<string, unknown>) => Record<string, unknown>;
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
/** Called by create-discussion-channel to initialize the discussion. */
|
||||
onDiscussionCreate?: (params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
initiatorAgentId: string;
|
||||
callbackGuildId: string;
|
||||
callbackChannelId: string;
|
||||
discussionGuide: string;
|
||||
participants: string[];
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
|
||||
if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") {
|
||||
return { accountId, token: accounts[accountId].token as string };
|
||||
}
|
||||
for (const [aid, rec] of Object.entries(accounts)) {
|
||||
if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token };
|
||||
}
|
||||
return null;
|
||||
function getGuildIdFromSessionKey(sessionKey: string): string | undefined {
|
||||
// sessionKey doesn't encode guild — it's not available directly.
|
||||
// Guild is passed explicitly by the agent.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function discordRequest(token: string, method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; text: string; json: any }> {
|
||||
const r = await fetch(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
|
||||
return { ok: r.ok, status: r.status, text, json };
|
||||
function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined {
|
||||
const m = sessionKey.match(/:discord:channel:(\d+)$/);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
function roleOrMemberType(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
||||
return 0;
|
||||
function textResult(text: string) {
|
||||
return { content: [{ type: "text" as const, text }], details: undefined };
|
||||
}
|
||||
|
||||
function errorResult(text: string) {
|
||||
return { content: [{ type: "text" as const, text }], details: { error: true } };
|
||||
}
|
||||
|
||||
export function registerDirigentTools(deps: ToolDeps): void {
|
||||
const { api, baseConfig, pickDefined } = deps;
|
||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps;
|
||||
|
||||
async function executeDiscordAction(action: DiscordControlAction, params: Record<string, unknown>) {
|
||||
const live = baseConfig as DirigentConfig & {
|
||||
enableDiscordControlTool?: boolean;
|
||||
discordControlAccountId?: string;
|
||||
};
|
||||
if (live.enableDiscordControlTool === false) {
|
||||
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
|
||||
}
|
||||
|
||||
const selected = parseAccountToken(api, (params.accountId as string | undefined) || live.discordControlAccountId);
|
||||
if (!selected) {
|
||||
return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true };
|
||||
}
|
||||
const token = selected.token;
|
||||
|
||||
if (action === "channel-private-create") {
|
||||
const guildId = String(params.guildId || "").trim();
|
||||
const name = String(params.name || "").trim();
|
||||
if (!guildId || !name) return { content: [{ type: "text", text: "guildId and name are required" }], isError: true };
|
||||
|
||||
const allowedUserIds = Array.isArray(params.allowedUserIds) ? params.allowedUserIds.map(String) : [];
|
||||
const allowedRoleIds = Array.isArray(params.allowedRoleIds) ? params.allowedRoleIds.map(String) : [];
|
||||
const allowMask = String(params.allowMask || "1024");
|
||||
const denyEveryoneMask = String(params.denyEveryoneMask || "1024");
|
||||
|
||||
const overwrites: any[] = [
|
||||
{ id: guildId, type: 0, allow: "0", deny: denyEveryoneMask },
|
||||
...allowedRoleIds.map((id) => ({ id, type: 0, allow: allowMask, deny: "0" })),
|
||||
...allowedUserIds.map((id) => ({ id, type: 1, allow: allowMask, deny: "0" })),
|
||||
];
|
||||
|
||||
const body = pickDefined({
|
||||
name,
|
||||
type: typeof params.type === "number" ? params.type : 0,
|
||||
parent_id: params.parentId,
|
||||
topic: params.topic,
|
||||
position: params.position,
|
||||
nsfw: params.nsfw,
|
||||
permission_overwrites: overwrites,
|
||||
});
|
||||
|
||||
const resp = await discordRequest(token, "POST", `/guilds/${guildId}/channels`, body);
|
||||
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
|
||||
}
|
||||
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
|
||||
const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge";
|
||||
const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : [];
|
||||
const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : [];
|
||||
const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : [];
|
||||
const allowMask = String(params.allowMask || "1024");
|
||||
const denyMask = String(params.denyMask || "0");
|
||||
|
||||
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
|
||||
if (!ch.ok) return { content: [{ type: "text", text: `discord action failed (${ch.status}): ${ch.text}` }], isError: true };
|
||||
|
||||
const current = Array.isArray(ch.json?.permission_overwrites) ? [...ch.json.permission_overwrites] : [];
|
||||
const guildId = String(ch.json?.guild_id || "");
|
||||
const everyone = current.find((x: any) => String(x?.id || "") === guildId && roleOrMemberType(x?.type) === 0);
|
||||
|
||||
let next: any[] = mode === "replace" ? (everyone ? [everyone] : []) : current.filter((x: any) => !removeTargetIds.includes(String(x?.id || "")));
|
||||
for (const id of addRoleIds) {
|
||||
next = next.filter((x: any) => String(x?.id || "") !== id);
|
||||
next.push({ id, type: 0, allow: allowMask, deny: denyMask });
|
||||
}
|
||||
for (const id of addUserIds) {
|
||||
next = next.filter((x: any) => String(x?.id || "") !== id);
|
||||
next.push({ id, type: 1, allow: allowMask, deny: denyMask });
|
||||
}
|
||||
|
||||
const resp = await discordRequest(token, "PATCH", `/channels/${channelId}`, { permission_overwrites: next });
|
||||
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
|
||||
}
|
||||
|
||||
api.registerTool({
|
||||
name: "dirigent_discord_control",
|
||||
description: "Create/update Discord private channels using the configured Discord bot token",
|
||||
inputSchema: {
|
||||
// ───────────────────────────────────────────────
|
||||
// dirigent-register
|
||||
// ───────────────────────────────────────────────
|
||||
api.registerTool((ctx) => ({
|
||||
name: "dirigent-register",
|
||||
label: "Dirigent Register",
|
||||
description: "Register or update this agent's Discord user ID in Dirigent's identity registry.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: { type: "string", enum: ["channel-private-create", "channel-private-update"] },
|
||||
accountId: { type: "string" },
|
||||
guildId: { type: "string" },
|
||||
channelId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
type: { type: "number" },
|
||||
parentId: { type: "string" },
|
||||
topic: { type: "string" },
|
||||
position: { type: "number" },
|
||||
nsfw: { type: "boolean" },
|
||||
allowedUserIds: { type: "array", items: { type: "string" } },
|
||||
allowedRoleIds: { type: "array", items: { type: "string" } },
|
||||
allowMask: { type: "string" },
|
||||
denyEveryoneMask: { type: "string" },
|
||||
mode: { type: "string", enum: ["merge", "replace"] },
|
||||
addUserIds: { type: "array", items: { type: "string" } },
|
||||
addRoleIds: { type: "array", items: { type: "string" } },
|
||||
removeTargetIds: { type: "array", items: { type: "string" } },
|
||||
denyMask: { type: "string" },
|
||||
discordUserId: { type: "string", description: "The agent's Discord user ID" },
|
||||
agentName: { type: "string", description: "Display name (optional, defaults to agentId)" },
|
||||
},
|
||||
required: ["action"],
|
||||
required: ["discordUserId"],
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
handler: async (params) => executeDiscordAction(params.action as DiscordControlAction, params as Record<string, unknown>),
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// 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}.`);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,412 +1,284 @@
|
||||
/**
|
||||
* Turn-based speaking manager for group channels.
|
||||
* Turn Manager (v2)
|
||||
*
|
||||
* Rules:
|
||||
* - Humans (humanList) are never in the turn order
|
||||
* - Turn order is auto-populated from channel/server members minus humans
|
||||
* - currentSpeaker can be null (dormant state)
|
||||
* - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null)
|
||||
* - Dormant → any new message reactivates:
|
||||
* - If sender is NOT in turn order → current = first in list
|
||||
* - If sender IS in turn order → current = next after sender
|
||||
* Per-channel state machine governing who speaks when.
|
||||
* Called from before_model_resolve (check turn) and agent_end (advance turn).
|
||||
*/
|
||||
|
||||
export type ChannelTurnState = {
|
||||
/** Ordered accountIds for this channel (auto-populated, shuffled) */
|
||||
turnOrder: string[];
|
||||
/** Current speaker accountId, or null if dormant */
|
||||
currentSpeaker: string | null;
|
||||
/** Set of accountIds that have NO_REPLY'd this cycle */
|
||||
noRepliedThisCycle: Set<string>;
|
||||
/** Timestamp of last state change */
|
||||
lastChangedAt: number;
|
||||
// ── Mention override state ──
|
||||
/** Original turn order saved when override is active */
|
||||
savedTurnOrder?: string[];
|
||||
/** First agent in override cycle; used to detect cycle completion */
|
||||
overrideFirstAgent?: string;
|
||||
// ── Wait-for-human state ──
|
||||
/** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */
|
||||
waitingForHuman: boolean;
|
||||
export type SpeakerEntry = {
|
||||
agentId: string;
|
||||
discordUserId: string;
|
||||
};
|
||||
|
||||
const channelTurns = new Map<string, ChannelTurnState>();
|
||||
|
||||
/** Turn timeout: if the current speaker hasn't responded, auto-advance */
|
||||
const TURN_TIMEOUT_MS = 60_000;
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// --- public API ---
|
||||
type ChannelTurnState = {
|
||||
speakerList: SpeakerEntry[];
|
||||
currentIndex: number;
|
||||
/** Tracks which agents sent empty turns in the current cycle. */
|
||||
emptyThisCycle: Set<string>;
|
||||
/** Tracks which agents completed a turn at all this cycle. */
|
||||
completedThisCycle: Set<string>;
|
||||
dormant: boolean;
|
||||
/** Discord message ID recorded at before_model_resolve, used as poll anchor. */
|
||||
anchorMessageId: Map<string, string>; // agentId → messageId
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize or update the turn order for a channel.
|
||||
* Called with the list of bot accountIds (already filtered, humans excluded).
|
||||
* All mutable state is stored on globalThis so it persists across VM-context
|
||||
* hot-reloads within the same gateway process. OpenClaw re-imports this module
|
||||
* in a fresh isolated VM context on each reload, but all contexts share the real
|
||||
* globalThis object because they run in the same Node.js process.
|
||||
*/
|
||||
export function initTurnOrder(channelId: string, botAccountIds: string[]): void {
|
||||
const existing = channelTurns.get(channelId);
|
||||
if (existing) {
|
||||
// Compare membership against base order.
|
||||
// If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison.
|
||||
const baseOrder = existing.savedTurnOrder || existing.turnOrder;
|
||||
const oldSet = new Set(baseOrder);
|
||||
const newSet = new Set(botAccountIds);
|
||||
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
|
||||
if (same) return; // no change
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` +
|
||||
`oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` +
|
||||
`oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`,
|
||||
);
|
||||
function channelStates(): Map<string, ChannelTurnState> {
|
||||
if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map<string, ChannelTurnState>();
|
||||
return _G._tmChannelStates as Map<string, ChannelTurnState>;
|
||||
}
|
||||
|
||||
const nextOrder = shuffleArray(botAccountIds);
|
||||
function pendingTurns(): Set<string> {
|
||||
if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set<string>();
|
||||
return _G._tmPendingTurns as Set<string>;
|
||||
}
|
||||
|
||||
// Mention override active: update only the saved base order.
|
||||
// Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered.
|
||||
if (existing.savedTurnOrder) {
|
||||
existing.savedTurnOrder = nextOrder;
|
||||
existing.lastChangedAt = Date.now();
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` +
|
||||
`savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` +
|
||||
`keptCurrent=${existing.currentSpeaker}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-mention flow: preserve previous behavior (re-init to dormant).
|
||||
channelTurns.set(channelId, {
|
||||
turnOrder: nextOrder,
|
||||
currentSpeaker: null, // start dormant
|
||||
noRepliedThisCycle: new Set(),
|
||||
lastChangedAt: Date.now(),
|
||||
waitingForHuman: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`,
|
||||
);
|
||||
|
||||
const nextOrder = shuffleArray(botAccountIds);
|
||||
channelTurns.set(channelId, {
|
||||
turnOrder: nextOrder,
|
||||
currentSpeaker: null, // start dormant
|
||||
noRepliedThisCycle: new Set(),
|
||||
lastChangedAt: Date.now(),
|
||||
waitingForHuman: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
|
||||
);
|
||||
function blockedPendingCounts(): Map<string, number> {
|
||||
if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map<string, number>();
|
||||
return _G._tmBlockedPendingCounts as Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given accountId is allowed to speak.
|
||||
* Shared initialization lock: prevents multiple concurrent VM contexts from
|
||||
* simultaneously initializing the same channel's speaker list.
|
||||
* Used by both before_model_resolve and message_received hooks.
|
||||
*/
|
||||
export function checkTurn(channelId: string, accountId: string): {
|
||||
allowed: boolean;
|
||||
currentSpeaker: string | null;
|
||||
reason: string;
|
||||
} {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) {
|
||||
return { allowed: true, currentSpeaker: null, reason: "no_turn_state" };
|
||||
}
|
||||
|
||||
// Waiting for human → block all agents
|
||||
if (state.waitingForHuman) {
|
||||
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
|
||||
}
|
||||
|
||||
// Not in turn order (human or unknown) → always allowed
|
||||
if (!state.turnOrder.includes(accountId)) {
|
||||
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
|
||||
}
|
||||
|
||||
// Dormant → not allowed (will be activated by onNewMessage)
|
||||
if (state.currentSpeaker === null) {
|
||||
return { allowed: false, currentSpeaker: null, reason: "dormant" };
|
||||
}
|
||||
|
||||
// Check timeout → auto-advance
|
||||
if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) {
|
||||
advanceTurn(channelId);
|
||||
// Re-check after advance
|
||||
const updated = channelTurns.get(channelId)!;
|
||||
if (updated.currentSpeaker === accountId) {
|
||||
return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" };
|
||||
}
|
||||
return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" };
|
||||
}
|
||||
|
||||
if (accountId === state.currentSpeaker) {
|
||||
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" };
|
||||
}
|
||||
|
||||
return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" };
|
||||
export function getInitializingChannels(): Set<string> {
|
||||
if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
|
||||
return _G._tmInitializingChannels as Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new message arrives in the channel.
|
||||
* Handles reactivation from dormant state and human-triggered resets.
|
||||
*
|
||||
* NOTE: For human messages with @mentions, call setMentionOverride() instead.
|
||||
*
|
||||
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
|
||||
* @param isHuman - whether the sender is in the humanList
|
||||
* Maximum blocked-pending entries tracked per agent per channel.
|
||||
* Caps the drain time: in a busy channel many messages can arrive during a non-speaker
|
||||
* turn, each incrementing the counter. Without a cap the counter grows unboundedly.
|
||||
* Also applied retroactively in markTurnStarted to recover from accumulated debt.
|
||||
*/
|
||||
export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) return;
|
||||
const MAX_BLOCKED_PENDING = 3;
|
||||
|
||||
if (isHuman) {
|
||||
// Human message: clear wait-for-human, restore original order if overridden, activate from first
|
||||
state.waitingForHuman = false;
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = state.turnOrder[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
export function markTurnStarted(channelId: string, agentId: string): void {
|
||||
pendingTurns().add(`${channelId}:${agentId}`);
|
||||
// Cap existing blocked-pending at MAX to recover from accumulated debt
|
||||
// (can occur when many messages arrive during a long non-speaker period).
|
||||
const bpc = blockedPendingCounts();
|
||||
const key = `${channelId}:${agentId}`;
|
||||
const current = bpc.get(key) ?? 0;
|
||||
if (current > MAX_BLOCKED_PENDING) bpc.set(key, MAX_BLOCKED_PENDING);
|
||||
}
|
||||
|
||||
if (state.waitingForHuman) {
|
||||
// Waiting for human — ignore non-human messages
|
||||
return;
|
||||
}
|
||||
export function isTurnPending(channelId: string, agentId: string): boolean {
|
||||
return pendingTurns().has(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
if (state.currentSpeaker !== null) {
|
||||
// Already active, no change needed from incoming message
|
||||
return;
|
||||
}
|
||||
|
||||
// Dormant state + non-human message → reactivate
|
||||
if (senderAccountId && state.turnOrder.includes(senderAccountId)) {
|
||||
// Sender is in turn order → next after sender
|
||||
const idx = state.turnOrder.indexOf(senderAccountId);
|
||||
const nextIdx = (idx + 1) % state.turnOrder.length;
|
||||
state.currentSpeaker = state.turnOrder[nextIdx];
|
||||
} else {
|
||||
// Sender not in turn order → start from first
|
||||
state.currentSpeaker = state.turnOrder[0];
|
||||
}
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
export function clearTurnPending(channelId: string, agentId: string): void {
|
||||
pendingTurns().delete(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original turn order if an override is active.
|
||||
* Counts NO_REPLY completions currently in-flight for an agent that was
|
||||
* blocked (non-speaker or init-suppressed). These completions take ~10s to
|
||||
* arrive (history-building overhead) and may arrive after markTurnStarted,
|
||||
* causing false empty-turn detection. We count them and skip one per agent_end
|
||||
* until the count reaches zero, at which point the next agent_end is real.
|
||||
*/
|
||||
function restoreOriginalOrder(state: ChannelTurnState): void {
|
||||
if (state.savedTurnOrder) {
|
||||
state.turnOrder = state.savedTurnOrder;
|
||||
state.savedTurnOrder = undefined;
|
||||
state.overrideFirstAgent = undefined;
|
||||
}
|
||||
|
||||
export function incrementBlockedPending(channelId: string, agentId: string): void {
|
||||
const bpc = blockedPendingCounts();
|
||||
const key = `${channelId}:${agentId}`;
|
||||
const current = bpc.get(key) ?? 0;
|
||||
if (current < MAX_BLOCKED_PENDING) bpc.set(key, current + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a temporary mention override for the turn order.
|
||||
* When a human @mentions specific agents, only those agents speak (in their
|
||||
* relative order from the current turn order). After the cycle returns to the
|
||||
* first agent, the original order is restored.
|
||||
*
|
||||
* @param channelId - Discord channel ID
|
||||
* @param mentionedAccountIds - accountIds of @mentioned agents, ordered by
|
||||
* their position in the current turn order
|
||||
* @returns true if override was set, false if no valid agents
|
||||
*/
|
||||
export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || mentionedAccountIds.length === 0) return false;
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` +
|
||||
`mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` +
|
||||
`order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
// Restore any existing override first
|
||||
restoreOriginalOrder(state);
|
||||
|
||||
// Filter to agents actually in the turn order
|
||||
const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id));
|
||||
if (validIds.length === 0) {
|
||||
console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Order by their position in the current turn order
|
||||
validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b));
|
||||
|
||||
// Save original and apply override
|
||||
state.savedTurnOrder = [...state.turnOrder];
|
||||
state.turnOrder = validIds;
|
||||
state.overrideFirstAgent = validIds[0];
|
||||
state.currentSpeaker = validIds[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` +
|
||||
`overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` +
|
||||
`savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
/** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */
|
||||
export function consumeBlockedPending(channelId: string, agentId: string): boolean {
|
||||
const bpc = blockedPendingCounts();
|
||||
const key = `${channelId}:${agentId}`;
|
||||
const count = bpc.get(key) ?? 0;
|
||||
if (count <= 0) return false;
|
||||
bpc.set(key, count - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mention override is currently active.
|
||||
*/
|
||||
export function hasMentionOverride(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.savedTurnOrder;
|
||||
export function resetBlockedPending(channelId: string, agentId: string): void {
|
||||
blockedPendingCounts().delete(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
function getState(channelId: string): ChannelTurnState | undefined {
|
||||
return channelStates().get(channelId);
|
||||
}
|
||||
|
||||
function ensureState(channelId: string): ChannelTurnState {
|
||||
const cs = channelStates();
|
||||
let s = cs.get(channelId);
|
||||
if (!s) {
|
||||
s = {
|
||||
speakerList: [],
|
||||
currentIndex: 0,
|
||||
emptyThisCycle: new Set(),
|
||||
completedThisCycle: new Set(),
|
||||
dormant: false,
|
||||
anchorMessageId: new Map(),
|
||||
};
|
||||
cs.set(channelId, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Replace the speaker list (called at cycle boundaries and on init). */
|
||||
export function setSpeakerList(channelId: string, speakers: SpeakerEntry[]): void {
|
||||
const s = ensureState(channelId);
|
||||
s.speakerList = speakers;
|
||||
s.currentIndex = 0;
|
||||
}
|
||||
|
||||
/** Get the currently active speaker, or null if dormant / list empty. */
|
||||
export function getCurrentSpeaker(channelId: string): SpeakerEntry | null {
|
||||
const s = getState(channelId);
|
||||
if (!s || s.dormant || s.speakerList.length === 0) return null;
|
||||
return s.speakerList[s.currentIndex] ?? null;
|
||||
}
|
||||
|
||||
/** Check if a given agentId is the current speaker. */
|
||||
export function isCurrentSpeaker(channelId: string, agentId: string): boolean {
|
||||
const speaker = getCurrentSpeaker(channelId);
|
||||
return speaker?.agentId === agentId;
|
||||
}
|
||||
|
||||
/** Record the Discord anchor message ID for an agent's upcoming turn. */
|
||||
export function setAnchor(channelId: string, agentId: string, messageId: string): void {
|
||||
const s = ensureState(channelId);
|
||||
s.anchorMessageId.set(agentId, messageId);
|
||||
}
|
||||
|
||||
export function getAnchor(channelId: string, agentId: string): string | undefined {
|
||||
return getState(channelId)?.anchorMessageId.get(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the channel to "waiting for human" state.
|
||||
* All agents will be routed to no-reply until a human sends a message.
|
||||
* Advance the speaker after a turn completes.
|
||||
* Returns the new current speaker (or null if dormant).
|
||||
*
|
||||
* @param isEmpty - whether the completed turn was an empty turn
|
||||
* @param rebuildFn - async function that fetches current Discord members and
|
||||
* returns a new SpeakerEntry[]. Called at cycle boundaries.
|
||||
* @param previousLastAgentId - for shuffle mode: the last speaker of the
|
||||
* previous cycle (cannot become the new first speaker).
|
||||
*/
|
||||
export function setWaitingForHuman(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return;
|
||||
state.waitingForHuman = true;
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
}
|
||||
export async function advanceSpeaker(
|
||||
channelId: string,
|
||||
agentId: string,
|
||||
isEmpty: boolean,
|
||||
rebuildFn: () => Promise<SpeakerEntry[]>,
|
||||
previousLastAgentId?: string,
|
||||
): Promise<{ next: SpeakerEntry | null; enteredDormant: boolean }> {
|
||||
const s = ensureState(channelId);
|
||||
|
||||
/**
|
||||
* Check if the channel is waiting for a human reply.
|
||||
*/
|
||||
export function isWaitingForHuman(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.waitingForHuman;
|
||||
}
|
||||
// Record this turn
|
||||
s.completedThisCycle.add(agentId);
|
||||
if (isEmpty) s.emptyThisCycle.add(agentId);
|
||||
|
||||
/**
|
||||
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
|
||||
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
|
||||
* @returns the new currentSpeaker (or null if dormant)
|
||||
*/
|
||||
export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return null;
|
||||
if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore
|
||||
const wasLastInCycle = s.currentIndex >= s.speakerList.length - 1;
|
||||
|
||||
if (wasNoReply) {
|
||||
state.noRepliedThisCycle.add(accountId);
|
||||
|
||||
// Check if ALL agents have NO_REPLY'd this cycle
|
||||
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
|
||||
if (allNoReplied) {
|
||||
// If override active, restore original order before going dormant
|
||||
restoreOriginalOrder(state);
|
||||
// Go dormant
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Successful speech resets the cycle counter
|
||||
state.noRepliedThisCycle = new Set();
|
||||
if (!wasLastInCycle) {
|
||||
// Middle of cycle — just advance pointer
|
||||
s.currentIndex++;
|
||||
s.dormant = false;
|
||||
return { next: s.speakerList[s.currentIndex] ?? null, enteredDormant: false };
|
||||
}
|
||||
|
||||
const next = advanceTurn(channelId);
|
||||
// === 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));
|
||||
|
||||
// Check if override cycle completed (returned to first agent)
|
||||
if (state.overrideFirstAgent && next === state.overrideFirstAgent) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return null; // go dormant after override cycle completes
|
||||
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 };
|
||||
}
|
||||
|
||||
return next;
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to next speaker in order.
|
||||
* Wake the channel from dormant.
|
||||
* Returns the new first speaker.
|
||||
*/
|
||||
export function advanceTurn(channelId: string): string | null {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
if (state.currentSpeaker === null) return null;
|
||||
export function isDormant(channelId: string): boolean {
|
||||
return getState(channelId)?.dormant ?? false;
|
||||
}
|
||||
|
||||
const idx = state.turnOrder.indexOf(state.currentSpeaker);
|
||||
const nextIdx = (idx + 1) % state.turnOrder.length;
|
||||
|
||||
// Skip agents that already NO_REPLY'd this cycle
|
||||
let attempts = 0;
|
||||
let candidateIdx = nextIdx;
|
||||
while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) {
|
||||
candidateIdx = (candidateIdx + 1) % state.turnOrder.length;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= state.turnOrder.length) {
|
||||
// All have NO_REPLY'd
|
||||
state.currentSpeaker = null;
|
||||
state.lastChangedAt = Date.now();
|
||||
return null;
|
||||
}
|
||||
|
||||
state.currentSpeaker = state.turnOrder[candidateIdx];
|
||||
state.lastChangedAt = Date.now();
|
||||
return state.currentSpeaker;
|
||||
export function hasSpeakers(channelId: string): boolean {
|
||||
const s = getState(channelId);
|
||||
return (s?.speakerList.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reset: go dormant.
|
||||
* Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
|
||||
*/
|
||||
export function resetTurn(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (state) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.waitingForHuman = false;
|
||||
state.lastChangedAt = Date.now();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug info.
|
||||
*/
|
||||
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return { channelId, hasTurnState: false };
|
||||
export function getDebugInfo(channelId: string) {
|
||||
const s = getState(channelId);
|
||||
if (!s) return { exists: false };
|
||||
return {
|
||||
channelId,
|
||||
hasTurnState: true,
|
||||
turnOrder: state.turnOrder,
|
||||
currentSpeaker: state.currentSpeaker,
|
||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||
lastChangedAt: state.lastChangedAt,
|
||||
dormant: state.currentSpeaker === null,
|
||||
waitingForHuman: state.waitingForHuman,
|
||||
hasOverride: !!state.savedTurnOrder,
|
||||
overrideFirstAgent: state.overrideFirstAgent || null,
|
||||
savedTurnOrder: state.savedTurnOrder || null,
|
||||
exists: true,
|
||||
speakerList: s.speakerList.map((sp) => sp.agentId),
|
||||
currentIndex: s.currentIndex,
|
||||
currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null,
|
||||
dormant: s.dormant,
|
||||
emptyThisCycle: [...s.emptyThisCycle],
|
||||
completedThisCycle: [...s.completedThisCycle],
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a channel's turn state entirely (e.g. when archived). */
|
||||
export function clearChannel(channelId: string): void {
|
||||
channelStates().delete(channelId);
|
||||
}
|
||||
|
||||
294
plugin/web/control-page.ts
Normal file
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 "[dirigent] building/starting no-reply API container"
|
||||
docker compose up -d --build dirigent-no-reply-api
|
||||
|
||||
echo "[dirigent] health check"
|
||||
curl -sS http://127.0.0.1:8787/health
|
||||
|
||||
echo "[dirigent] done"
|
||||
@@ -2,7 +2,75 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
// === Skill merge utilities ===
|
||||
function extractGuildTable(skillMdContent) {
|
||||
const tableMatch = skillMdContent.match(/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/);
|
||||
if (!tableMatch) return null;
|
||||
const lines = tableMatch[0].split("\n");
|
||||
const dataRows = [];
|
||||
for (const line of lines) {
|
||||
if (line.includes("guild-id") && line.includes("description")) continue;
|
||||
if (/^\|[-\s|]+\|$/.test(line)) continue;
|
||||
const match = line.match(/^\| \s*(\d+) \s*\| \s*(.+?) \s*\|$/);
|
||||
if (match) dataRows.push({ guildId: match[1].trim(), description: match[2].trim() });
|
||||
}
|
||||
return dataRows;
|
||||
}
|
||||
|
||||
function buildGuildTable(rows) {
|
||||
if (rows.length === 0) return "| guild-id | description |\n|----------|-------------|";
|
||||
const header = "| guild-id | description |\n|----------|-------------|";
|
||||
const dataLines = rows.map(r => `| ${r.guildId} | ${r.description} |`).join("\n");
|
||||
return `${header}\n${dataLines}`;
|
||||
}
|
||||
|
||||
function mergeGuildTables(existingRows, newRows) {
|
||||
const seen = new Set();
|
||||
const merged = [];
|
||||
for (const row of existingRows) {
|
||||
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
|
||||
}
|
||||
for (const row of newRows) {
|
||||
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function installSkillWithMerge(skillName, pluginSkillDir, openClawSkillsDir) {
|
||||
const targetSkillDir = path.join(openClawSkillsDir, skillName);
|
||||
const sourceSkillMd = path.join(pluginSkillDir, "SKILL.md");
|
||||
|
||||
if (fs.existsSync(targetSkillDir)) {
|
||||
const existingSkillMd = path.join(targetSkillDir, "SKILL.md");
|
||||
if (fs.existsSync(existingSkillMd) && fs.existsSync(sourceSkillMd)) {
|
||||
const existingContent = fs.readFileSync(existingSkillMd, "utf8");
|
||||
const newContent = fs.readFileSync(sourceSkillMd, "utf8");
|
||||
const existingRows = extractGuildTable(existingContent) || [];
|
||||
const newRows = extractGuildTable(newContent) || [];
|
||||
|
||||
if (existingRows.length > 0 || newRows.length > 0) {
|
||||
const mergedRows = mergeGuildTables(existingRows, newRows);
|
||||
const mergedTable = buildGuildTable(mergedRows);
|
||||
const finalContent = newContent.replace(
|
||||
/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/,
|
||||
mergedTable
|
||||
);
|
||||
fs.rmSync(targetSkillDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(targetSkillDir, { recursive: true });
|
||||
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetSkillDir, "SKILL.md"), finalContent, "utf8");
|
||||
return { merged: true, rowCount: mergedRows.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(openClawSkillsDir, { recursive: true });
|
||||
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true, force: true });
|
||||
return { merged: false };
|
||||
}
|
||||
// === End skill merge utilities ===
|
||||
|
||||
const VALID_MODES = new Set(["--install", "--uninstall", "--update"]);
|
||||
let modeArg = null;
|
||||
@@ -11,46 +79,24 @@ let argNoReplyPort = 8787;
|
||||
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
const arg = process.argv[i];
|
||||
if (VALID_MODES.has(arg)) {
|
||||
modeArg = arg;
|
||||
} else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) {
|
||||
argOpenClawDir = process.argv[++i];
|
||||
} else if (arg.startsWith("--openclaw-profile-path=")) {
|
||||
argOpenClawDir = arg.split("=").slice(1).join("=");
|
||||
} else if (arg === "--no-reply-port" && i + 1 < process.argv.length) {
|
||||
argNoReplyPort = Number(process.argv[++i]);
|
||||
} else if (arg.startsWith("--no-reply-port=")) {
|
||||
argNoReplyPort = Number(arg.split("=").slice(1).join("="));
|
||||
}
|
||||
if (VALID_MODES.has(arg)) modeArg = arg;
|
||||
else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) argOpenClawDir = process.argv[++i];
|
||||
else if (arg.startsWith("--openclaw-profile-path=")) argOpenClawDir = arg.split("=").slice(1).join("=");
|
||||
else if (arg === "--no-reply-port" && i + 1 < process.argv.length) argNoReplyPort = Number(process.argv[++i]);
|
||||
else if (arg.startsWith("--no-reply-port=")) argNoReplyPort = Number(arg.split("=").slice(1).join("="));
|
||||
}
|
||||
|
||||
if (!modeArg) {
|
||||
fail("Usage: node scripts/install.mjs --install|--uninstall|--update [--openclaw-profile-path <path>] [--no-reply-port <port>]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(argNoReplyPort) || argNoReplyPort < 1 || argNoReplyPort > 65535) {
|
||||
fail("invalid --no-reply-port (1-65535)");
|
||||
console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path <path>] [--no-reply-port <port>]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const mode = modeArg.slice(2);
|
||||
|
||||
const C = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m",
|
||||
};
|
||||
|
||||
const C = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m" };
|
||||
function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; }
|
||||
function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); }
|
||||
function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); }
|
||||
function ok(msg) { console.log(color(`\t✓ ${msg}`, "green")); }
|
||||
function warn(msg) { console.log(color(`\t⚠ ${msg}`, "yellow")); }
|
||||
function fail(msg) { console.log(color(`\t✗ ${msg}`, "red")); }
|
||||
|
||||
function resolveOpenClawDir() {
|
||||
if (argOpenClawDir) {
|
||||
@@ -58,218 +104,116 @@ function resolveOpenClawDir() {
|
||||
if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`);
|
||||
return dir;
|
||||
}
|
||||
if (process.env.OPENCLAW_DIR) {
|
||||
const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
warn(`OPENCLAW_DIR not found: ${dir}, fallback to ~/.openclaw`);
|
||||
}
|
||||
const fallback = path.join(os.homedir(), ".openclaw");
|
||||
if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const OPENCLAW_DIR = resolveOpenClawDir();
|
||||
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json");
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
fail(`config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
|
||||
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
|
||||
const NO_REPLY_INSTALL_DIR = path.join(PLUGIN_INSTALL_DIR, "no-reply-api");
|
||||
const SKILLS_DIR = path.join(OPENCLAW_DIR, "skills");
|
||||
const PLUGIN_SKILLS_DIR = path.join(REPO_ROOT, "skills");
|
||||
|
||||
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent";
|
||||
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
|
||||
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort);
|
||||
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`;
|
||||
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/no-reply/v1`;
|
||||
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
|
||||
const LIST_MODE = process.env.LIST_MODE || "human-list";
|
||||
const HUMAN_LIST_JSON = process.env.HUMAN_LIST_JSON || "[]";
|
||||
const AGENT_LIST_JSON = process.env.AGENT_LIST_JSON || "[]";
|
||||
const CHANNEL_POLICIES_FILE = process.env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json");
|
||||
const CHANNEL_POLICIES_JSON = process.env.CHANNEL_POLICIES_JSON || "{}";
|
||||
const END_SYMBOLS_JSON = process.env.END_SYMBOLS_JSON || '["🔚"]';
|
||||
const SCHEDULING_IDENTIFIER = process.env.SCHEDULING_IDENTIFIER || "➡️";
|
||||
|
||||
function runOpenclaw(args, allowFail = false) {
|
||||
try {
|
||||
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
|
||||
} catch (e) {
|
||||
if (allowFail) return null;
|
||||
throw e;
|
||||
}
|
||||
try { return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); }
|
||||
catch (e) { if (allowFail) return null; throw e; }
|
||||
}
|
||||
|
||||
function getJson(pathKey) {
|
||||
const out = runOpenclaw(["config", "get", pathKey, "--json"], true);
|
||||
if (!out || out === "undefined") return undefined;
|
||||
try { return JSON.parse(out); } catch { return undefined; }
|
||||
}
|
||||
|
||||
function setJson(pathKey, value) {
|
||||
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergePreservingExisting(base, updates) {
|
||||
if (!isPlainObject(updates)) return updates;
|
||||
const out = isPlainObject(base) ? { ...base } : {};
|
||||
for (const [key, nextValue] of Object.entries(updates)) {
|
||||
const currentValue = out[key];
|
||||
if (nextValue === undefined) continue;
|
||||
if (isPlainObject(nextValue)) {
|
||||
out[key] = mergePreservingExisting(currentValue, nextValue);
|
||||
continue;
|
||||
}
|
||||
if (nextValue === null) {
|
||||
if (currentValue === undefined) out[key] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof nextValue === "string") {
|
||||
if (nextValue === "" && currentValue !== undefined) continue;
|
||||
out[key] = nextValue;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(nextValue)) {
|
||||
if (nextValue.length === 0 && Array.isArray(currentValue) && currentValue.length > 0) continue;
|
||||
out[key] = nextValue;
|
||||
continue;
|
||||
}
|
||||
out[key] = nextValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function unsetPath(pathKey) {
|
||||
runOpenclaw(["config", "unset", pathKey], true);
|
||||
}
|
||||
|
||||
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); }
|
||||
function syncDirRecursive(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.cpSync(src, dest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function isRegistered() {
|
||||
const entry = getJson("plugins.entries.dirigent");
|
||||
return !!(entry && typeof entry === "object");
|
||||
}
|
||||
|
||||
if (mode === "update") {
|
||||
title("Update");
|
||||
const branch = process.env.DIRIGENT_GIT_BRANCH || "latest";
|
||||
step(1, 2, `update source branch=${branch}`);
|
||||
execFileSync("git", ["fetch", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["checkout", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["pull", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
ok("source updated");
|
||||
|
||||
step(2, 2, "run install after update");
|
||||
const script = path.join(REPO_ROOT, "scripts", "install.mjs");
|
||||
const args = [script, "--install", "--openclaw-profile-path", OPENCLAW_DIR, "--no-reply-port", String(NO_REPLY_PORT)];
|
||||
const ret = spawnSync(process.execPath, args, { cwd: REPO_ROOT, stdio: "inherit", env: process.env });
|
||||
process.exit(ret.status ?? 1);
|
||||
}
|
||||
|
||||
if (mode === "install") {
|
||||
title("Install");
|
||||
step(1, 6, `environment: ${OPENCLAW_DIR}`);
|
||||
title("Install Dirigent with Skills");
|
||||
|
||||
if (isRegistered()) {
|
||||
warn("plugins.entries.dirigent exists; reinstalling in-place");
|
||||
}
|
||||
|
||||
step(2, 6, "build dist assets");
|
||||
step(1, 7, "build dist assets");
|
||||
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
||||
const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api");
|
||||
const sidecarSrc = path.resolve(REPO_ROOT, "services");
|
||||
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
|
||||
const distNoReply = path.resolve(REPO_ROOT, "dist", "dirigent", "no-reply-api");
|
||||
fs.rmSync(distPlugin, { recursive: true, force: true });
|
||||
syncDirRecursive(pluginSrc, distPlugin);
|
||||
syncDirRecursive(noReplySrc, distNoReply);
|
||||
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
|
||||
ok("dist assets built");
|
||||
|
||||
step(3, 6, `install files -> ${PLUGIN_INSTALL_DIR}`);
|
||||
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
|
||||
syncDirRecursive(distNoReply, NO_REPLY_INSTALL_DIR);
|
||||
ok("plugin files installed");
|
||||
|
||||
// cleanup old layout from previous versions
|
||||
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
|
||||
if (fs.existsSync(oldTopLevelNoReply)) {
|
||||
fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
|
||||
ok(`removed legacy path: ${oldTopLevelNoReply}`);
|
||||
step(3, 7, "install skills with merge");
|
||||
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
|
||||
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => {
|
||||
const full = path.join(PLUGIN_SKILLS_DIR, d);
|
||||
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, "SKILL.md"));
|
||||
});
|
||||
for (const skillName of skills) {
|
||||
const pluginSkillDir = path.join(PLUGIN_SKILLS_DIR, skillName);
|
||||
const result = installSkillWithMerge(skillName, pluginSkillDir, SKILLS_DIR);
|
||||
if (result.merged) {
|
||||
ok(`skill ${skillName} installed (merged ${result.rowCount} guild entries)`);
|
||||
} else {
|
||||
ok(`skill ${skillName} installed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
|
||||
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
|
||||
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
|
||||
ok(`init channel policies file: ${CHANNEL_POLICIES_FILE}`);
|
||||
step(4, 7, "configure plugin entry");
|
||||
// Plugin load path — safe to read/write (not sensitive)
|
||||
const loadPaths = getJson("plugins.load.paths") || [];
|
||||
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) {
|
||||
loadPaths.push(PLUGIN_INSTALL_DIR);
|
||||
setJson("plugins.load.paths", loadPaths);
|
||||
}
|
||||
// For each config field: only write the default if the field has no value.
|
||||
// Sensitive fields (e.g. moderatorBotToken) are never touched — user sets them manually.
|
||||
// `getJson` returns undefined if the field is unset; __OPENCLAW_REDACTED__ counts as "set".
|
||||
function setIfMissing(pathKey, defaultVal) {
|
||||
const existing = getJson(pathKey);
|
||||
if (existing === undefined || existing === null) setJson(pathKey, defaultVal);
|
||||
}
|
||||
setIfMissing("plugins.entries.dirigent.enabled", true);
|
||||
const cp = "plugins.entries.dirigent.config";
|
||||
setIfMissing(`${cp}.scheduleIdentifier`, "➡️");
|
||||
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
|
||||
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
|
||||
setIfMissing(`${cp}.sideCarPort`, NO_REPLY_PORT);
|
||||
// moderatorBotToken: intentionally not touched — set manually via:
|
||||
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
|
||||
ok("plugin configured");
|
||||
|
||||
step(4, 6, "configure plugin entry/path");
|
||||
const plugins = getJson("plugins") || {};
|
||||
const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
|
||||
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR);
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = loadPaths;
|
||||
|
||||
plugins.entries = plugins.entries || {};
|
||||
const existingDirigentEntry = isPlainObject(plugins.entries.dirigent) ? plugins.entries.dirigent : {};
|
||||
const desiredDirigentEntry = {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
discordOnly: true,
|
||||
listMode: LIST_MODE,
|
||||
humanList: JSON.parse(HUMAN_LIST_JSON),
|
||||
agentList: JSON.parse(AGENT_LIST_JSON),
|
||||
channelPoliciesFile: CHANNEL_POLICIES_FILE,
|
||||
endSymbols: JSON.parse(END_SYMBOLS_JSON),
|
||||
schedulingIdentifier: SCHEDULING_IDENTIFIER,
|
||||
noReplyProvider: NO_REPLY_PROVIDER_ID,
|
||||
noReplyModel: NO_REPLY_MODEL_ID,
|
||||
noReplyPort: NO_REPLY_PORT,
|
||||
},
|
||||
};
|
||||
plugins.entries.dirigent = mergePreservingExisting(existingDirigentEntry, desiredDirigentEntry);
|
||||
setJson("plugins", plugins);
|
||||
|
||||
step(5, 6, "configure no-reply provider");
|
||||
step(5, 7, "configure no-reply provider");
|
||||
const providers = getJson("models.providers") || {};
|
||||
providers[NO_REPLY_PROVIDER_ID] = {
|
||||
baseUrl: NO_REPLY_BASE_URL,
|
||||
apiKey: NO_REPLY_API_KEY,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NO_REPLY_MODEL_ID,
|
||||
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
baseUrl: NO_REPLY_BASE_URL, apiKey: NO_REPLY_API_KEY, api: "openai-completions",
|
||||
models: [{ id: NO_REPLY_MODEL_ID, name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192 }],
|
||||
};
|
||||
setJson("models.providers", providers);
|
||||
ok("provider configured");
|
||||
|
||||
// Add no-reply model to agents.defaults.models allowlist
|
||||
step(6, 7, "add no-reply model to allowlist");
|
||||
const agentsDefaultsModels = getJson("agents.defaults.models") || {};
|
||||
agentsDefaultsModels[`${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`] = {};
|
||||
setJson("agents.defaults.models", agentsDefaultsModels);
|
||||
ok("model allowlisted");
|
||||
|
||||
step(6, 6, "enable plugin in allowlist");
|
||||
step(7, 7, "enable plugin in allowlist");
|
||||
const allow = getJson("plugins.allow") || [];
|
||||
if (!allow.includes("dirigent")) {
|
||||
allow.push("dirigent");
|
||||
setJson("plugins.allow", allow);
|
||||
}
|
||||
|
||||
if (!allow.includes("dirigent")) { allow.push("dirigent"); setJson("plugins.allow", allow); }
|
||||
ok(`installed (no-reply port: ${NO_REPLY_PORT})`);
|
||||
console.log("↻ restart gateway: openclaw gateway restart");
|
||||
process.exit(0);
|
||||
@@ -277,43 +221,38 @@ if (mode === "install") {
|
||||
|
||||
if (mode === "uninstall") {
|
||||
title("Uninstall");
|
||||
step(1, 5, `environment: ${OPENCLAW_DIR}`);
|
||||
|
||||
step(2, 5, "remove allowlist + plugin entry");
|
||||
step(1, 4, "remove allowlist + plugin entry");
|
||||
const allow = getJson("plugins.allow") || [];
|
||||
const idx = allow.indexOf("dirigent");
|
||||
if (idx >= 0) {
|
||||
allow.splice(idx, 1);
|
||||
setJson("plugins.allow", allow);
|
||||
ok("removed from plugins.allow");
|
||||
}
|
||||
if (idx >= 0) { allow.splice(idx, 1); setJson("plugins.allow", allow); ok("removed from plugins.allow"); }
|
||||
runOpenclaw(["config", "unset", "plugins.entries.dirigent"], true);
|
||||
ok("removed plugin entry");
|
||||
|
||||
unsetPath("plugins.entries.dirigent");
|
||||
ok("removed plugins.entries.dirigent");
|
||||
|
||||
step(3, 5, "remove plugin load path");
|
||||
step(2, 4, "remove plugin load path");
|
||||
const plugins = getJson("plugins") || {};
|
||||
const paths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR);
|
||||
plugins.load = plugins.load || {}; plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR);
|
||||
setJson("plugins", plugins);
|
||||
ok("removed plugin path from plugins.load.paths");
|
||||
ok("removed load path");
|
||||
|
||||
step(4, 5, "remove no-reply provider");
|
||||
const providers = getJson("models.providers") || {};
|
||||
delete providers[NO_REPLY_PROVIDER_ID];
|
||||
setJson("models.providers", providers);
|
||||
ok(`removed provider ${NO_REPLY_PROVIDER_ID}`);
|
||||
|
||||
step(5, 5, "remove installed files");
|
||||
step(3, 4, "remove installed files");
|
||||
if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true });
|
||||
if (fs.existsSync(NO_REPLY_INSTALL_DIR)) fs.rmSync(NO_REPLY_INSTALL_DIR, { recursive: true, force: true });
|
||||
const legacyNoReply = path.join(PLUGINS_DIR, "dirigent-no-reply-api");
|
||||
if (fs.existsSync(legacyNoReply)) fs.rmSync(legacyNoReply, { recursive: true, force: true });
|
||||
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
|
||||
if (fs.existsSync(oldTopLevelNoReply)) fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
|
||||
ok("removed installed files");
|
||||
ok("removed plugin files");
|
||||
|
||||
step(4, 4, "remove skills");
|
||||
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
|
||||
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => fs.statSync(path.join(PLUGIN_SKILLS_DIR, d)).isDirectory());
|
||||
for (const skillName of skills) {
|
||||
const targetSkillDir = path.join(SKILLS_DIR, skillName);
|
||||
if (fs.existsSync(targetSkillDir)) {
|
||||
fs.rmSync(targetSkillDir, { recursive: true, force: true });
|
||||
ok(`removed skill ${skillName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("↻ restart gateway: openclaw gateway restart");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Unknown mode:", mode);
|
||||
process.exit(2);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const pluginDir = path.join(root, "plugin");
|
||||
const outDir = path.join(root, "dist", "dirigent");
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "moderator-presence.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
|
||||
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
|
||||
}
|
||||
|
||||
console.log(`packaged plugin to ${outDir}`);
|
||||
@@ -1,32 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8787}"
|
||||
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
||||
|
||||
AUTH_HEADER=()
|
||||
if [[ -n "$AUTH_TOKEN" ]]; then
|
||||
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
||||
fi
|
||||
# Smoke-tests the no-reply API endpoint exposed by the sidecar service.
|
||||
# The sidecar must already be running (it starts automatically with openclaw-gateway).
|
||||
# Default base URL matches the sidecar's no-reply prefix.
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8787/no-reply}"
|
||||
|
||||
echo "[1] health"
|
||||
curl -sS "${BASE_URL}/health" | sed -n '1,3p'
|
||||
curl -fsS "${BASE_URL}/health"
|
||||
echo ""
|
||||
|
||||
echo "[2] models"
|
||||
curl -sS "${BASE_URL}/v1/models" "${AUTH_HEADER[@]}" | sed -n '1,8p'
|
||||
curl -fsS "${BASE_URL}/v1/models" | head -c 200
|
||||
echo ""
|
||||
|
||||
echo "[3] chat/completions"
|
||||
curl -sS -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
curl -fsS -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \
|
||||
| sed -n '1,20p'
|
||||
-d '{"model":"no-reply","messages":[{"role":"user","content":"hello"}]}' \
|
||||
| head -c 300
|
||||
echo ""
|
||||
|
||||
echo "[4] responses"
|
||||
curl -sS -X POST "${BASE_URL}/v1/responses" \
|
||||
curl -fsS -X POST "${BASE_URL}/v1/responses" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
-d '{"model":"dirigent-no-reply-v1","input":"hello"}' \
|
||||
| sed -n '1,20p'
|
||||
-d '{"model":"no-reply","input":"hello"}' \
|
||||
| head -c 300
|
||||
echo ""
|
||||
|
||||
echo "smoke ok"
|
||||
|
||||
211
scripts/test-features.mjs
Normal file
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