feat: rewrite plugin as v2 with globalThis-based turn management
Complete rewrite of the Dirigent plugin turn management system to work correctly with OpenClaw's VM-context-per-session architecture: - All turn state stored on globalThis (persists across VM context hot-reloads) - Hooks registered unconditionally on every api instance; event-level dedup (runId Set for agent_end, WeakSet for before_model_resolve) prevents double-processing - Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag - Shared initializingChannels lock prevents concurrent channel init across VM contexts in message_received and before_model_resolve - New ChannelStore and IdentityRegistry replace old policy/session-state modules - Added agent_end hook with tail-match polling for Discord delivery confirmation - Added web control page, padded-cell auto-scan, discussion tool support - Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service, session-state, turn-bootstrap, policy/store, rules, decision-input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.
|
||||
@@ -1 +0,0 @@
|
||||
export * from './channel-resolver.ts';
|
||||
@@ -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;
|
||||
}
|
||||
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,153 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
import { setChannelShuffling, getChannelShuffling } from "../core/channel-modes.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 turn-shuffling [on|off] - Enable/disable turn order shuffling\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 }) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-shuffling") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
|
||||
const arg = parts[1]?.toLowerCase();
|
||||
if (arg === "on") {
|
||||
setChannelShuffling(channelId, true);
|
||||
return { text: JSON.stringify({ ok: true, channelId, shuffling: true }) };
|
||||
} else if (arg === "off") {
|
||||
setChannelShuffling(channelId, false);
|
||||
return { text: JSON.stringify({ ok: true, channelId, shuffling: false }) };
|
||||
} else if (!arg) {
|
||||
const isShuffling = getChannelShuffling(channelId);
|
||||
return { text: JSON.stringify({ ok: true, channelId, shuffling: isShuffling }) };
|
||||
} else {
|
||||
return { text: "Invalid argument. Use: /dirigent turn-shuffling [on|off]", isError: 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>();
|
||||
if (identityRegistry) {
|
||||
const discordToAgent = identityRegistry.buildDiscordToAgentMap();
|
||||
for (const uid of visibleUserIds) {
|
||||
const aid = userToAccount.get(uid);
|
||||
const aid = discordToAgent.get(uid);
|
||||
if (aid) out.add(aid);
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
const channelStates = new Map();
|
||||
|
||||
export function getChannelState(channelId) {
|
||||
if (!channelStates.has(channelId)) {
|
||||
channelStates.set(channelId, {
|
||||
mode: "normal",
|
||||
shuffling: false,
|
||||
});
|
||||
}
|
||||
return channelStates.get(channelId);
|
||||
}
|
||||
|
||||
export function enterMultiMessageMode(channelId) {
|
||||
const state = getChannelState(channelId);
|
||||
state.mode = "multi-message";
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
|
||||
export function exitMultiMessageMode(channelId) {
|
||||
const state = getChannelState(channelId);
|
||||
state.mode = "normal";
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
|
||||
export function isMultiMessageMode(channelId) {
|
||||
return getChannelState(channelId).mode === "multi-message";
|
||||
}
|
||||
|
||||
export function setChannelShuffling(channelId, enabled) {
|
||||
const state = getChannelState(channelId);
|
||||
state.shuffling = enabled;
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
|
||||
export function getChannelShuffling(channelId) {
|
||||
return getChannelState(channelId).shuffling;
|
||||
}
|
||||
|
||||
export function markLastShuffled(channelId) {
|
||||
const state = getChannelState(channelId);
|
||||
state.lastShuffledAt = Date.now();
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ChannelRuntimeMode, ChannelRuntimeState } from "../rules.js";
|
||||
|
||||
export type ChannelMode = ChannelRuntimeMode;
|
||||
export type ChannelModesState = ChannelRuntimeState;
|
||||
|
||||
const channelStates = new Map<string, ChannelModesState>();
|
||||
|
||||
export function getChannelState(channelId: string): ChannelModesState {
|
||||
if (!channelStates.has(channelId)) {
|
||||
channelStates.set(channelId, {
|
||||
mode: "normal",
|
||||
shuffling: false,
|
||||
});
|
||||
}
|
||||
return channelStates.get(channelId)!;
|
||||
}
|
||||
|
||||
export function enterMultiMessageMode(channelId: string): void {
|
||||
const state = getChannelState(channelId);
|
||||
state.mode = "multi-message";
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
|
||||
export function exitMultiMessageMode(channelId: string): void {
|
||||
const state = getChannelState(channelId);
|
||||
state.mode = "normal";
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
|
||||
export function isMultiMessageMode(channelId: string): boolean {
|
||||
return getChannelState(channelId).mode === "multi-message";
|
||||
}
|
||||
|
||||
export function setChannelShuffling(channelId: string, enabled: boolean): void {
|
||||
const state = getChannelState(channelId);
|
||||
state.shuffling = enabled;
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
|
||||
export function getChannelShuffling(channelId: string): boolean {
|
||||
return getChannelState(channelId).shuffling;
|
||||
}
|
||||
|
||||
export function markLastShuffled(channelId: string): void {
|
||||
const state = getChannelState(channelId);
|
||||
state.lastShuffledAt = Date.now();
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './discussion-messages.ts';
|
||||
@@ -1,77 +0,0 @@
|
||||
export function buildDiscussionKickoffMessage(discussGuide: string): string {
|
||||
return [
|
||||
"[Discussion Started]",
|
||||
"",
|
||||
"This channel was created for a temporary agent discussion.",
|
||||
"",
|
||||
"Goal:",
|
||||
discussGuide,
|
||||
"",
|
||||
"Instructions:",
|
||||
"1. Discuss only the topic above.",
|
||||
"2. Work toward a concrete conclusion.",
|
||||
"3. When the initiator decides the goal has been achieved, the initiator must:",
|
||||
" - write a summary document to a file",
|
||||
" - call the tool: discuss-callback(summaryPath)",
|
||||
" - provide the summary document path",
|
||||
"",
|
||||
"Completion rule:",
|
||||
"Only the discussion initiator may finish this discussion.",
|
||||
"",
|
||||
"After callback:",
|
||||
"- this channel will be closed",
|
||||
"- further discussion messages will be ignored",
|
||||
"- this channel will remain only for archive/reference",
|
||||
"- the original work channel will be notified with the summary file path",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildDiscussionIdleReminderMessage(): string {
|
||||
return [
|
||||
"[Discussion Idle]",
|
||||
"",
|
||||
"No agent responded in the latest discussion round.",
|
||||
"If the discussion goal has already been achieved, the initiator should now:",
|
||||
"1. write the discussion summary to a file in the workspace",
|
||||
"2. call discuss-callback with the summary file path",
|
||||
"",
|
||||
"This reminder does not mean the discussion was automatically summarized or closed.",
|
||||
"If more discussion is still needed, continue the discussion in this channel.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildDiscussionClosedMessage(): string {
|
||||
return [
|
||||
"[Channel Closed]",
|
||||
"",
|
||||
"This discussion channel has been closed.",
|
||||
"It is now kept for archive/reference only.",
|
||||
"Further discussion in this channel is ignored.",
|
||||
"If follow-up work is needed, continue it from the origin work channel instead.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const DISCUSSION_RESULT_READY_HEADER = "[Discussion Result Ready]";
|
||||
|
||||
export function buildDiscussionOriginCallbackMessage(summaryPath: string, discussionChannelId: string): string {
|
||||
return [
|
||||
DISCUSSION_RESULT_READY_HEADER,
|
||||
"",
|
||||
"A temporary discussion has completed.",
|
||||
"",
|
||||
"Summary file:",
|
||||
summaryPath,
|
||||
"",
|
||||
"Source discussion channel:",
|
||||
`<#${discussionChannelId}>`,
|
||||
"",
|
||||
"Status:",
|
||||
"completed",
|
||||
"",
|
||||
"Continue the original task using the summary file above.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function isDiscussionOriginCallbackMessage(content: string): boolean {
|
||||
return content.includes(DISCUSSION_RESULT_READY_HEADER);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { closeDiscussion, createDiscussion, getDiscussion, isDiscussionClosed, markDiscussionIdleReminderSent, type DiscussionMetadata } from "./discussion-state.js";
|
||||
import {
|
||||
buildDiscussionClosedMessage,
|
||||
buildDiscussionIdleReminderMessage,
|
||||
buildDiscussionKickoffMessage,
|
||||
buildDiscussionOriginCallbackMessage,
|
||||
} from "./discussion-messages.js";
|
||||
import { sendModeratorMessage } from "./moderator-discord.js";
|
||||
|
||||
type DiscussionServiceDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
moderatorBotToken?: string;
|
||||
moderatorUserId?: string;
|
||||
workspaceRoot?: string;
|
||||
forceNoReplyForSession: (sessionKey: string) => void;
|
||||
getDiscussionSessionKeys?: (channelId: string) => string[];
|
||||
};
|
||||
|
||||
export function createDiscussionService(deps: DiscussionServiceDeps) {
|
||||
const defaultWorkspaceRoot = path.resolve(deps.workspaceRoot || process.cwd());
|
||||
|
||||
async function initDiscussion(params: {
|
||||
discussionChannelId: string;
|
||||
originChannelId: string;
|
||||
initiatorAgentId: string;
|
||||
initiatorSessionId: string;
|
||||
initiatorWorkspaceRoot?: string;
|
||||
discussGuide: string;
|
||||
}): Promise<DiscussionMetadata> {
|
||||
const metadata = createDiscussion({
|
||||
mode: "discussion",
|
||||
discussionChannelId: params.discussionChannelId,
|
||||
originChannelId: params.originChannelId,
|
||||
initiatorAgentId: params.initiatorAgentId,
|
||||
initiatorSessionId: params.initiatorSessionId,
|
||||
initiatorWorkspaceRoot: params.initiatorWorkspaceRoot,
|
||||
discussGuide: params.discussGuide,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (deps.moderatorBotToken) {
|
||||
const result = await sendModeratorMessage(
|
||||
deps.moderatorBotToken,
|
||||
params.discussionChannelId,
|
||||
buildDiscussionKickoffMessage(params.discussGuide),
|
||||
deps.api.logger,
|
||||
);
|
||||
if (!result.ok) {
|
||||
deps.api.logger.warn(`dirigent: discussion kickoff message failed channel=${params.discussionChannelId} error=${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function maybeSendIdleReminder(channelId: string): Promise<void> {
|
||||
const metadata = getDiscussion(channelId);
|
||||
if (!metadata || metadata.status !== "active" || metadata.idleReminderSent) return;
|
||||
markDiscussionIdleReminderSent(channelId);
|
||||
if (deps.moderatorBotToken) {
|
||||
const result = await sendModeratorMessage(
|
||||
deps.moderatorBotToken,
|
||||
channelId,
|
||||
buildDiscussionIdleReminderMessage(),
|
||||
deps.api.logger,
|
||||
);
|
||||
if (!result.ok) {
|
||||
deps.api.logger.warn(`dirigent: discussion idle reminder failed channel=${channelId} error=${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSummaryPath(summaryPath: string, workspaceRoot?: string): string {
|
||||
if (!summaryPath || !summaryPath.trim()) throw new Error("summaryPath is required");
|
||||
|
||||
const effectiveWorkspaceRoot = path.resolve(workspaceRoot || defaultWorkspaceRoot);
|
||||
const resolved = path.resolve(effectiveWorkspaceRoot, summaryPath);
|
||||
const relative = path.relative(effectiveWorkspaceRoot, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error("summaryPath must stay inside the initiator workspace");
|
||||
}
|
||||
|
||||
const real = fs.realpathSync.native(resolved);
|
||||
const realWorkspace = fs.realpathSync.native(effectiveWorkspaceRoot);
|
||||
const realRelative = path.relative(realWorkspace, real);
|
||||
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
|
||||
throw new Error("summaryPath resolves outside the initiator workspace");
|
||||
}
|
||||
|
||||
const stat = fs.statSync(real);
|
||||
if (!stat.isFile()) throw new Error("summaryPath must point to a file");
|
||||
return real;
|
||||
}
|
||||
|
||||
async function handleCallback(params: {
|
||||
channelId: string;
|
||||
summaryPath: string;
|
||||
callerAgentId?: string;
|
||||
callerSessionKey?: string;
|
||||
}): Promise<{ ok: true; summaryPath: string; discussion: DiscussionMetadata }> {
|
||||
const metadata = getDiscussion(params.channelId);
|
||||
if (!metadata) throw new Error("current channel is not a discussion channel");
|
||||
if (metadata.status !== "active" || isDiscussionClosed(params.channelId)) throw new Error("discussion is already closed");
|
||||
if (!params.callerSessionKey || params.callerSessionKey !== metadata.initiatorSessionId) {
|
||||
throw new Error("only the discussion initiator session may call discuss-callback");
|
||||
}
|
||||
if (params.callerAgentId && params.callerAgentId !== metadata.initiatorAgentId) {
|
||||
throw new Error("only the discussion initiator agent may call discuss-callback");
|
||||
}
|
||||
|
||||
const realPath = validateSummaryPath(params.summaryPath, metadata.initiatorWorkspaceRoot);
|
||||
const closed = closeDiscussion(params.channelId, realPath);
|
||||
if (!closed) throw new Error("failed to close discussion");
|
||||
|
||||
const discussionSessionKeys = new Set<string>([
|
||||
metadata.initiatorSessionId,
|
||||
...(deps.getDiscussionSessionKeys?.(metadata.discussionChannelId) || []),
|
||||
]);
|
||||
for (const sessionKey of discussionSessionKeys) {
|
||||
if (sessionKey) deps.forceNoReplyForSession(sessionKey);
|
||||
}
|
||||
|
||||
if (deps.moderatorBotToken) {
|
||||
const result = await sendModeratorMessage(
|
||||
deps.moderatorBotToken,
|
||||
metadata.originChannelId,
|
||||
buildDiscussionOriginCallbackMessage(realPath, metadata.discussionChannelId),
|
||||
deps.api.logger,
|
||||
);
|
||||
if (!result.ok) {
|
||||
deps.api.logger.warn(
|
||||
`dirigent: discussion origin callback notification failed originChannel=${metadata.originChannelId} error=${result.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, summaryPath: realPath, discussion: closed };
|
||||
}
|
||||
|
||||
async function maybeReplyClosedChannel(channelId: string, senderId?: string): Promise<boolean> {
|
||||
const metadata = getDiscussion(channelId);
|
||||
if (!metadata || metadata.status !== "closed") return false;
|
||||
if (deps.moderatorUserId && senderId && senderId === deps.moderatorUserId) return true;
|
||||
if (!deps.moderatorBotToken) return true;
|
||||
const result = await sendModeratorMessage(deps.moderatorBotToken, channelId, buildDiscussionClosedMessage(), deps.api.logger);
|
||||
if (!result.ok) {
|
||||
deps.api.logger.warn(`dirigent: discussion closed reply failed channel=${channelId} error=${result.error}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
initDiscussion,
|
||||
getDiscussion,
|
||||
isClosedDiscussion(channelId: string): boolean {
|
||||
return isDiscussionClosed(channelId);
|
||||
},
|
||||
maybeSendIdleReminder,
|
||||
maybeReplyClosedChannel,
|
||||
handleCallback,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './discussion-state.ts';
|
||||
@@ -1,56 +0,0 @@
|
||||
export type DiscussionStatus = "active" | "closed";
|
||||
|
||||
export type DiscussionMetadata = {
|
||||
mode: "discussion";
|
||||
discussionChannelId: string;
|
||||
originChannelId: string;
|
||||
initiatorAgentId: string;
|
||||
initiatorSessionId: string;
|
||||
initiatorWorkspaceRoot?: string;
|
||||
discussGuide: string;
|
||||
status: DiscussionStatus;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
summaryPath?: string;
|
||||
idleReminderSent?: boolean;
|
||||
};
|
||||
|
||||
const discussionByChannelId = new Map<string, DiscussionMetadata>();
|
||||
|
||||
export function createDiscussion(metadata: DiscussionMetadata): DiscussionMetadata {
|
||||
discussionByChannelId.set(metadata.discussionChannelId, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function getDiscussion(channelId: string): DiscussionMetadata | undefined {
|
||||
return discussionByChannelId.get(channelId);
|
||||
}
|
||||
|
||||
export function isDiscussionChannel(channelId: string): boolean {
|
||||
return discussionByChannelId.has(channelId);
|
||||
}
|
||||
|
||||
export function isDiscussionClosed(channelId: string): boolean {
|
||||
return discussionByChannelId.get(channelId)?.status === "closed";
|
||||
}
|
||||
|
||||
export function markDiscussionIdleReminderSent(channelId: string): void {
|
||||
const rec = discussionByChannelId.get(channelId);
|
||||
if (!rec) return;
|
||||
rec.idleReminderSent = true;
|
||||
}
|
||||
|
||||
export function clearDiscussionIdleReminderSent(channelId: string): void {
|
||||
const rec = discussionByChannelId.get(channelId);
|
||||
if (!rec) return;
|
||||
rec.idleReminderSent = false;
|
||||
}
|
||||
|
||||
export function closeDiscussion(channelId: string, summaryPath: string): DiscussionMetadata | undefined {
|
||||
const rec = discussionByChannelId.get(channelId);
|
||||
if (!rec) return undefined;
|
||||
rec.status = "closed";
|
||||
rec.summaryPath = summaryPath;
|
||||
rec.completedAt = new Date().toISOString();
|
||||
return 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 +0,0 @@
|
||||
export * from './moderator-discord.ts';
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
@@ -28,7 +30,7 @@ export async function sendModeratorMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (msg: string) => void; warn: (msg: string) => void },
|
||||
logger: Logger,
|
||||
): Promise<ModeratorMessageResult> {
|
||||
try {
|
||||
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
||||
@@ -63,3 +65,175 @@ export async function sendModeratorMessage(
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a message then immediately delete it (used for schedule_identifier trigger). */
|
||||
export async function sendAndDelete(
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const result = await sendModeratorMessage(token, channelId, content, logger);
|
||||
if (result.ok && result.messageId) {
|
||||
// Small delay to ensure Discord has processed the message before deletion
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await deleteMessage(token, channelId, result.messageId, logger);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 a message from agentDiscordUserId with id > anchorId
|
||||
* ends with the tail fingerprint.
|
||||
*
|
||||
* @returns the matching messageId, or undefined on timeout.
|
||||
*/
|
||||
export async function pollForTailMatch(opts: {
|
||||
token: string;
|
||||
channelId: string;
|
||||
anchorId: string;
|
||||
agentDiscordUserId: string;
|
||||
tailFingerprint: string;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
/** Callback checked each poll; if true, polling is aborted (interrupted). */
|
||||
isInterrupted?: () => boolean;
|
||||
}): Promise<{ matched: boolean; interrupted: boolean }> {
|
||||
const {
|
||||
token, channelId, anchorId, agentDiscordUserId,
|
||||
tailFingerprint, timeoutMs = 15000, pollIntervalMs = 800,
|
||||
isInterrupted = () => false,
|
||||
} = opts;
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (isInterrupted()) return { matched: false, interrupted: true };
|
||||
|
||||
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 candidates = msgs.filter(
|
||||
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
// Most recent is first in Discord's response
|
||||
const latest = candidates[0];
|
||||
if (latest.content.endsWith(tailFingerprint)) {
|
||||
return { matched: true, interrupted: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore transient errors, keep polling
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
|
||||
return { matched: false, interrupted: false };
|
||||
}
|
||||
|
||||
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
|
||||
export async function createDiscordChannel(opts: {
|
||||
token: string;
|
||||
guildId: string;
|
||||
name: string;
|
||||
/** Permission overwrites: [{id, type (0=role/1=member), allow, deny}] */
|
||||
permissionOverwrites?: Array<{ id: string; type: number; allow?: string; deny?: string }>;
|
||||
logger: Logger;
|
||||
}): Promise<string> {
|
||||
const { token, guildId, name, permissionOverwrites = [], logger } = opts;
|
||||
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, type: 0, permission_overwrites: permissionOverwrites }),
|
||||
});
|
||||
const json = (await r.json()) as Record<string, unknown>;
|
||||
if (!r.ok) {
|
||||
const err = `Discord channel create failed (${r.status}): ${JSON.stringify(json)}`;
|
||||
logger.warn(`dirigent: ${err}`);
|
||||
throw new Error(err);
|
||||
}
|
||||
const channelId = json.id as string;
|
||||
logger.info(`dirigent: created Discord channel ${name} id=${channelId} in guild=${guildId}`);
|
||||
return channelId;
|
||||
}
|
||||
|
||||
/** Fetch guilds where the moderator bot has admin permissions. */
|
||||
export async function fetchAdminGuilds(token: string): Promise<Array<{ id: string; name: string }>> {
|
||||
const r = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const guilds = (await r.json()) as Array<{ id: string; name: string; permissions: string }>;
|
||||
const ADMIN = 8n;
|
||||
return guilds.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN);
|
||||
}
|
||||
|
||||
/** Fetch private group channels in a guild visible to the moderator bot. */
|
||||
export async function fetchGuildChannels(
|
||||
token: string,
|
||||
guildId: string,
|
||||
): Promise<Array<{ id: string; name: string; type: number }>> {
|
||||
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const channels = (await r.json()) as Array<{ id: string; name: string; type: number }>;
|
||||
// type 0 = GUILD_TEXT; filter to text channels only (group private channels are type 0)
|
||||
return channels.filter((c) => c.type === 0);
|
||||
}
|
||||
|
||||
/** Get bot's own Discord user ID from token. */
|
||||
export function getBotUserIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
59
plugin/core/padded-cell.ts
Normal file
59
plugin/core/padded-cell.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { IdentityRegistry } from "./identity-registry.js";
|
||||
|
||||
type EgoData = {
|
||||
columns?: string[];
|
||||
agentScope?: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan padded-cell's ego.json and upsert agent Discord IDs into the identity registry.
|
||||
* Only runs if ego.json contains the "discord-id" column — otherwise treated as absent.
|
||||
*
|
||||
* @returns number of entries upserted, or -1 if padded-cell is not detected.
|
||||
*/
|
||||
export function scanPaddedCell(
|
||||
registry: IdentityRegistry,
|
||||
openclawDir: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
): number {
|
||||
const egoPath = path.join(openclawDir, "ego.json");
|
||||
|
||||
if (!fs.existsSync(egoPath)) {
|
||||
logger.info("dirigent: padded-cell ego.json not found — skipping auto-registration");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let ego: EgoData;
|
||||
try {
|
||||
ego = JSON.parse(fs.readFileSync(egoPath, "utf8"));
|
||||
} catch (e) {
|
||||
logger.warn(`dirigent: failed to parse ego.json: ${String(e)}`);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!Array.isArray(ego.columns) || !ego.columns.includes("discord-id")) {
|
||||
logger.info('dirigent: ego.json does not have "discord-id" column — padded-cell not configured for Discord, skipping');
|
||||
return -1;
|
||||
}
|
||||
|
||||
const agentScope = ego.agentScope ?? {};
|
||||
let count = 0;
|
||||
|
||||
for (const [agentId, fields] of Object.entries(agentScope)) {
|
||||
const discordUserId = fields["discord-id"];
|
||||
if (!discordUserId || typeof discordUserId !== "string") continue;
|
||||
|
||||
const existing = registry.findByAgentId(agentId);
|
||||
registry.upsert({
|
||||
agentId,
|
||||
discordUserId,
|
||||
agentName: existing?.agentName ?? agentId,
|
||||
});
|
||||
count++;
|
||||
}
|
||||
|
||||
logger.info(`dirigent: padded-cell scan complete — upserted ${count} identity entries`);
|
||||
return count;
|
||||
}
|
||||
@@ -1,44 +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 const forceNoReplySessions = new Set<string>();
|
||||
export const discussionChannelSessions = new Map<string, Set<string>>();
|
||||
|
||||
export function recordDiscussionSession(channelId: string, sessionKey: string): void {
|
||||
if (!channelId || !sessionKey) return;
|
||||
const current = discussionChannelSessions.get(channelId) || new Set<string>();
|
||||
current.add(sessionKey);
|
||||
discussionChannelSessions.set(channelId, current);
|
||||
}
|
||||
|
||||
export function getDiscussionSessionKeys(channelId: string): string[] {
|
||||
return [...(discussionChannelSessions.get(channelId) || 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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
221
plugin/hooks/agent-end.ts
Normal file
221
plugin/hooks/agent-end.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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,
|
||||
hasSpeakers,
|
||||
getDebugInfo,
|
||||
isTurnPending,
|
||||
clearTurnPending,
|
||||
consumeBlockedPending,
|
||||
type SpeakerEntry,
|
||||
} from "../turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
import { pollForTailMatch, sendAndDelete } from "../core/moderator-discord.js";
|
||||
|
||||
const TAIL_LENGTH = 40;
|
||||
const TAIL_MATCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
/** 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();
|
||||
return t === "" || /^NO$/i.test(t) || /^NO_REPLY$/i.test(t);
|
||||
}
|
||||
|
||||
export type AgentEndDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
/** 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, 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 sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
|
||||
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;
|
||||
if (!isCurrentSpeaker(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}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Consume a blocked-pending slot if any exists. These are NO_REPLY completions
|
||||
// from before_model_resolve blocking events (non-speaker or init-suppressed) that
|
||||
// fire late — after the agent became the current speaker — due to history-building
|
||||
// overhead (~10s). We skip them until the counter is exhausted, at which point
|
||||
// the next agent_end is the real LLM response.
|
||||
if (consumeBlockedPending(channelId, agentId)) {
|
||||
api.logger.info(`dirigent: agent_end skipping blocked-pending NO_REPLY agentId=${agentId} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTurnPending(channelId, agentId);
|
||||
|
||||
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);
|
||||
|
||||
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 via tail-match polling
|
||||
const identity = identityRegistry.findByAgentId(agentId);
|
||||
if (identity && moderatorBotToken) {
|
||||
const anchorId = getAnchor(channelId, agentId) ?? "0";
|
||||
const tail = [...finalText].slice(-TAIL_LENGTH).join("");
|
||||
|
||||
const { matched, interrupted } = await pollForTailMatch({
|
||||
token: moderatorBotToken,
|
||||
channelId,
|
||||
anchorId,
|
||||
agentDiscordUserId: identity.discordUserId,
|
||||
tailFingerprint: tail,
|
||||
timeoutMs: TAIL_MATCH_TIMEOUT_MS,
|
||||
isInterrupted: () => interruptedChannels.has(channelId),
|
||||
});
|
||||
|
||||
if (interrupted) {
|
||||
api.logger.info(`dirigent: tail-match interrupted channel=${channelId} — wake-from-dormant`);
|
||||
const first = wakeFromDormant(channelId);
|
||||
if (first) await triggerNextSpeaker(channelId, first);
|
||||
return;
|
||||
}
|
||||
if (!matched) {
|
||||
api.logger.warn(`dirigent: tail-match timeout channel=${channelId} agentId=${agentId} — advancing anyway`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
const { next, enteredDormant } = await advanceSpeaker(
|
||||
channelId,
|
||||
agentId,
|
||||
empty,
|
||||
() => buildSpeakerList(channelId),
|
||||
previousLastAgentId,
|
||||
);
|
||||
|
||||
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,222 +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;
|
||||
isMultiMessageMode: (channelId: string) => boolean;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<unknown>;
|
||||
discussionService?: {
|
||||
maybeSendIdleReminder: (channelId: string) => Promise<void>;
|
||||
getDiscussion: (channelId: string) => { status: string } | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
isMultiMessageMode,
|
||||
sendModeratorMessage,
|
||||
discussionService,
|
||||
} = 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 (discussionService?.getDiscussion(channelId)?.status === "active") {
|
||||
void discussionService.maybeSendIdleReminder(channelId).catch((err) => {
|
||||
api.logger.warn(`dirigent: idle reminder failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
if (shouldDebugLog(live, channelId)) {
|
||||
api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (live.moderatorBotToken) {
|
||||
if (isMultiMessageMode(channelId)) {
|
||||
// In multi-message mode, send the prompt marker instead of scheduling identifier
|
||||
const promptMarker = live.multiMessagePromptMarker || "⤵️";
|
||||
void sendModeratorMessage(live.moderatorBotToken, channelId, promptMarker, api.logger).catch((err) => {
|
||||
api.logger.warn(`dirigent: before_message_write multi-message prompt marker failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
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,176 +1,141 @@
|
||||
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, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
||||
import { getLatestMessageId, sendAndDelete } 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>;
|
||||
recordDiscussionSession?: (channelId: string, sessionKey: string) => void;
|
||||
forceNoReplySessions: Set<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;
|
||||
isMultiMessageMode: (channelId: string) => boolean;
|
||||
discussionService?: {
|
||||
isClosedDiscussion: (channelId: string) => boolean;
|
||||
};
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
noReplyModel: string;
|
||||
noReplyProvider: string;
|
||||
scheduleIdentifier: string;
|
||||
};
|
||||
|
||||
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
recordDiscussionSession,
|
||||
forceNoReplySessions,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
isMultiMessageMode,
|
||||
discussionService,
|
||||
} = 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, noReplyModel, noReplyProvider, scheduleIdentifier } = deps;
|
||||
|
||||
const NO_REPLY = { model: noReplyModel, provider: noReplyProvider, noReply: true } as const;
|
||||
|
||||
/** 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;
|
||||
|
||||
if (forceNoReplySessions.has(key)) {
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
// Only handle Discord group channel sessions
|
||||
const channelId = parseDiscordChannelId(sessionKey);
|
||||
if (!channelId) return;
|
||||
|
||||
const mode = channelStore.getMode(channelId);
|
||||
|
||||
// dead mode: suppress all responses
|
||||
if (mode === "report" || mode === "dead" as string) return NO_REPLY;
|
||||
|
||||
// disabled modes: let agents respond freely
|
||||
if (mode === "none" || mode === "work") return;
|
||||
|
||||
// discussion / chat: 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}`);
|
||||
return NO_REPLY;
|
||||
}
|
||||
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);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
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 (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)}`,
|
||||
);
|
||||
// If this agent is NOT the first speaker, trigger first speaker and suppress this one
|
||||
if (first.agentId !== agentId && moderatorBotToken) {
|
||||
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
|
||||
return NO_REPLY;
|
||||
}
|
||||
|
||||
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);
|
||||
recordDiscussionSession?.(derived.channelId, key);
|
||||
if (discussionService?.isClosedDiscussion(derived.channelId)) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(`dirigent: before_model_resolve forcing no-reply for closed discussion channel=${derived.channelId} session=${key}`);
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
// If this agent IS the first speaker, fall through to normal turn logic
|
||||
} else {
|
||||
// No registered agents visible — let everyone respond freely
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMultiMessageMode(derived.channelId)) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(`dirigent: before_model_resolve forcing no-reply for multi-message mode channel=${derived.channelId} session=${key}`);
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
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}`,
|
||||
);
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
|
||||
return;
|
||||
} finally {
|
||||
initializingChannels.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
// If channel is dormant: suppress all agents
|
||||
if (isDormant(channelId)) return NO_REPLY;
|
||||
|
||||
if (!isCurrentSpeaker(channelId, agentId)) {
|
||||
api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`);
|
||||
incrementBlockedPending(channelId, agentId);
|
||||
return NO_REPLY;
|
||||
}
|
||||
sessionAllowed.set(key, true);
|
||||
|
||||
// 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) return;
|
||||
|
||||
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;
|
||||
// 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,141 +1,128 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId } from "../channel-resolver.js";
|
||||
import { isDiscussionOriginCallbackMessage } from "../core/discussion-messages.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 { sendAndDelete, sendModeratorMessage } 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>;
|
||||
enterMultiMessageMode: (channelId: string) => void;
|
||||
exitMultiMessageMode: (channelId: string) => void;
|
||||
discussionService?: {
|
||||
maybeReplyClosedChannel: (channelId: string, senderId?: string) => Promise<boolean>;
|
||||
};
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
interruptTailMatch: InterruptFn;
|
||||
};
|
||||
|
||||
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
shouldDebugLog,
|
||||
debugCtxSummary,
|
||||
ensureTurnOrder,
|
||||
getModeratorUserId,
|
||||
recordChannelAccount,
|
||||
extractMentionedUserIds,
|
||||
buildUserIdToAccountIdMap,
|
||||
enterMultiMessageMode,
|
||||
exitMultiMessageMode,
|
||||
discussionService,
|
||||
} = deps;
|
||||
export function registerMessageReceivedHook(deps: Deps): void {
|
||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps;
|
||||
|
||||
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 session key or event metadata
|
||||
let channelId: string | undefined;
|
||||
if (typeof c.sessionKey === "string") {
|
||||
channelId = parseDiscordChannelId(c.sessionKey);
|
||||
}
|
||||
|
||||
if (preChannelId) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
|
||||
const from =
|
||||
(typeof metadata?.senderId === "string" && metadata.senderId) ||
|
||||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
|
||||
|
||||
const moderatorUserId = getModeratorUserId(livePre);
|
||||
if (discussionService) {
|
||||
const closedHandled = await discussionService.maybeReplyClosedChannel(preChannelId, from);
|
||||
if (closedHandled) return;
|
||||
if (!channelId) {
|
||||
// Try from event metadata (conversation_info channel_id field)
|
||||
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 messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || "";
|
||||
const isModeratorOriginCallback = !!(moderatorUserId && from === moderatorUserId && isDiscussionOriginCallbackMessage(messageContent));
|
||||
const mode = channelStore.getMode(channelId);
|
||||
|
||||
if (moderatorUserId && from === moderatorUserId && !isModeratorOriginCallback) {
|
||||
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;
|
||||
// dead: suppress routing entirely (OpenClaw handles no-route automatically,
|
||||
// but we handle archived auto-reply here)
|
||||
if (mode === "report") return;
|
||||
|
||||
if (senderAccountId && senderAccountId !== "default") {
|
||||
const isNew = recordChannelAccount(api, preChannelId, senderAccountId);
|
||||
if (isNew) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
|
||||
// archived: auto-reply via moderator
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
const startMarker = livePre.multiMessageStartMarker || "↗️";
|
||||
const endMarker = livePre.multiMessageEndMarker || "↙️";
|
||||
if (mode === "none" || mode === "work") return;
|
||||
|
||||
if (messageContent.includes(startMarker)) {
|
||||
enterMultiMessageMode(preChannelId);
|
||||
api.logger.info(`dirigent: entered multi-message mode channel=${preChannelId}`);
|
||||
} else if (messageContent.includes(endMarker)) {
|
||||
exitMultiMessageMode(preChannelId);
|
||||
api.logger.info(`dirigent: exited multi-message mode channel=${preChannelId}`);
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
} else {
|
||||
const mentionedUserIds = extractMentionedUserIds(messageContent);
|
||||
// chat / discussion (active): initialize speaker list on first message if needed
|
||||
const initializingChannels = getInitializingChannels();
|
||||
if (!hasSpeakers(channelId) && moderatorBotToken) {
|
||||
// Guard against concurrent initialization from multiple VM contexts
|
||||
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 (mentionedUserIds.length > 0) {
|
||||
const userIdMap = buildUserIdToAccountIdMap(api);
|
||||
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid);
|
||||
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 sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
initializingChannels.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
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)}`,
|
||||
// chat / discussion (active): check if this is an external message
|
||||
// that should interrupt an in-progress tail-match or wake dormant
|
||||
|
||||
const senderId = String(
|
||||
(e.metadata as Record<string, unknown>)?.senderId ??
|
||||
(e.metadata as Record<string, unknown>)?.sender_id ??
|
||||
e.from ?? "",
|
||||
);
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, false);
|
||||
}
|
||||
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`,
|
||||
);
|
||||
// Identify the sender: is it the current speaker's Discord account?
|
||||
const currentSpeakerIsThisSender = (() => {
|
||||
if (!senderId) return false;
|
||||
const entry = identityRegistry.findByDiscordUserId(senderId);
|
||||
if (!entry) return false;
|
||||
return isCurrentSpeaker(channelId!, entry.agentId);
|
||||
})();
|
||||
|
||||
if (!currentSpeakerIsThisSender) {
|
||||
// Non-current-speaker posted — interrupt any tail-match in progress
|
||||
interruptTailMatch(channelId);
|
||||
api.logger.info(`dirigent: message_received 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}>${scheduleIdentifier}`;
|
||||
await sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
|
||||
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,135 +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<unknown>;
|
||||
discussionService?: {
|
||||
isClosedDiscussion: (channelId: string) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function registerMessageSentHook(deps: MessageSentDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
discussionService,
|
||||
} = 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) {
|
||||
// Check if this is a closed discussion channel
|
||||
if (discussionService?.isClosedDiscussion(channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: message_sent skipping turn advance for closed discussion channel=${channelId} from=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
345
plugin/index.ts
345
plugin/index.ts
@@ -1,240 +1,213 @@
|
||||
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 { 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 { registerAddGuildCommand } from "./commands/add-guild-command.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 { IdentityRegistry } from "./core/identity-registry.js";
|
||||
import { ChannelStore } from "./core/channel-store.js";
|
||||
import { scanPaddedCell } from "./core/padded-cell.js";
|
||||
import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js";
|
||||
import { createDiscussionService } from "./core/discussion-service.js";
|
||||
import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode } from "./core/channel-modes.js";
|
||||
import {
|
||||
DECISION_TTL_MS,
|
||||
forceNoReplySessions,
|
||||
getDiscussionSessionKeys,
|
||||
pruneDecisionMap,
|
||||
recordDiscussionSession,
|
||||
sessionAccountId,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
sessionTurnHandled,
|
||||
} from "./core/session-state.js";
|
||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||
import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
|
||||
import { registerAgentEndHook } from "./hooks/agent-end.js";
|
||||
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
||||
import { registerDirigentTools } from "./tools/register-tools.js";
|
||||
import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js";
|
||||
import { registerAddGuildCommand } from "./commands/add-guild-command.js";
|
||||
import { registerControlPage } from "./web/control-page.js";
|
||||
import { sendModeratorMessage, sendAndDelete } from "./core/moderator-discord.js";
|
||||
import { setSpeakerList } from "./turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
type PluginConfig = {
|
||||
moderatorBotToken?: string;
|
||||
noReplyProvider?: string;
|
||||
noReplyModel?: string;
|
||||
noReplyPort?: number;
|
||||
scheduleIdentifier?: string;
|
||||
identityFilePath?: string;
|
||||
channelStoreFilePath?: string;
|
||||
};
|
||||
|
||||
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: "👤",
|
||||
multiMessageStartMarker: "↗️",
|
||||
multiMessageEndMarker: "↙️",
|
||||
multiMessagePromptMarker: "⤵️",
|
||||
...(api.pluginConfig || {}),
|
||||
} as NormalizedDirigentConfig;
|
||||
moderatorBotToken: cfg.moderatorBotToken ?? "",
|
||||
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
|
||||
noReplyModel: cfg.noReplyModel ?? "no-reply",
|
||||
noReplyPort: Number(cfg.noReplyPort ?? 8787),
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
return instruction;
|
||||
/**
|
||||
* 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 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.`;
|
||||
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);
|
||||
let paddedCellDetected = false;
|
||||
|
||||
// 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)}`);
|
||||
|
||||
// Additional debug: list what's in the plugin directory
|
||||
try {
|
||||
const entries = fs.readdirSync(pluginDir);
|
||||
api.logger.info(`dirigent: plugin dir (${pluginDir}) entries: ${JSON.stringify(entries)}`);
|
||||
} catch (e) {
|
||||
api.logger.warn(`dirigent: cannot read plugin dir: ${String(e)}`);
|
||||
function hasPaddedCell(): boolean {
|
||||
return paddedCellDetected;
|
||||
}
|
||||
|
||||
startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787));
|
||||
api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`);
|
||||
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();
|
||||
|
||||
api.on("gateway_start", () => {
|
||||
const live = normalizeConfig(api);
|
||||
|
||||
startNoReplyApi(api.logger, pluginDir, live.noReplyPort);
|
||||
|
||||
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.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
||||
}
|
||||
|
||||
tryAutoScanPaddedCell();
|
||||
});
|
||||
|
||||
api.on("gateway_stop", () => {
|
||||
stopNoReplyApi(api.logger);
|
||||
stopModeratorPresence();
|
||||
api.logger.info("dirigent: gateway stopping, services shut down");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Hooks (registered on every api instance — event-level dedup handles duplicates) ──
|
||||
registerBeforeModelResolveHook({
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
noReplyModel: config.noReplyModel,
|
||||
noReplyProvider: config.noReplyProvider,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
});
|
||||
|
||||
const discussionService = createDiscussionService({
|
||||
const interruptTailMatch = registerAgentEndHook({
|
||||
api,
|
||||
moderatorBotToken: baseConfig.moderatorBotToken,
|
||||
moderatorUserId: getModeratorUserId(baseConfig),
|
||||
workspaceRoot: process.cwd(),
|
||||
forceNoReplyForSession: (sessionKey: string) => {
|
||||
if (sessionKey) forceNoReplySessions.add(sessionKey);
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
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);
|
||||
},
|
||||
getDiscussionSessionKeys,
|
||||
});
|
||||
|
||||
// Register tools
|
||||
registerDirigentTools({
|
||||
api,
|
||||
baseConfig,
|
||||
pickDefined,
|
||||
discussionService,
|
||||
});
|
||||
|
||||
// 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,
|
||||
enterMultiMessageMode,
|
||||
exitMultiMessageMode,
|
||||
discussionService,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
interruptTailMatch,
|
||||
});
|
||||
|
||||
registerBeforeModelResolveHook({
|
||||
// ── Tools ──────────────────────────────────────────────────────────────
|
||||
registerDirigentTools({
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
recordDiscussionSession,
|
||||
forceNoReplySessions,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
isMultiMessageMode,
|
||||
discussionService,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
onDiscussionCreate: async ({ channelId, guildId, 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 sendAndDelete(
|
||||
live.moderatorBotToken,
|
||||
channelId,
|
||||
`<@${first.discordUserId}>${live.scheduleIdentifier}`,
|
||||
api.logger,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
registerBeforePromptBuildHook({
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
buildEndMarkerInstruction,
|
||||
buildSchedulingIdentifierInstruction,
|
||||
buildAgentIdentity,
|
||||
});
|
||||
|
||||
// Register slash commands for Discord
|
||||
registerDirigentCommand({
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
persistPolicies,
|
||||
ensurePolicyStateLoaded,
|
||||
});
|
||||
|
||||
// Register add-guild command
|
||||
// ── Commands ───────────────────────────────────────────────────────────
|
||||
registerSetChannelModeCommand({ api, channelStore });
|
||||
registerAddGuildCommand(api);
|
||||
|
||||
// Handle NO_REPLY detection before message write
|
||||
registerBeforeMessageWriteHook({
|
||||
// ── Control page ───────────────────────────────────────────────────────
|
||||
registerControlPage({
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
isMultiMessageMode,
|
||||
sendModeratorMessage,
|
||||
discussionService,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
openclawDir,
|
||||
hasPaddedCell,
|
||||
});
|
||||
|
||||
// Turn advance: when an agent sends a message, check if it signals end of turn
|
||||
registerMessageSentHook({
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
discussionService,
|
||||
});
|
||||
api.logger.info("dirigent: plugin registered (v2)");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './rules.ts';
|
||||
153
plugin/rules.ts
153
plugin/rules.ts
@@ -1,153 +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;
|
||||
/** Human-visible marker that enters multi-message mode for a channel (default: ↗️) */
|
||||
multiMessageStartMarker?: string;
|
||||
/** Human-visible marker that exits multi-message mode for a channel (default: ↙️) */
|
||||
multiMessageEndMarker?: string;
|
||||
/** Moderator marker sent after each human message while multi-message mode is active (default: ⤵️) */
|
||||
multiMessagePromptMarker?: string;
|
||||
noReplyProvider: string;
|
||||
noReplyModel: string;
|
||||
noReplyPort?: number;
|
||||
/** Discord bot token for the moderator bot (used for turn handoff messages) */
|
||||
moderatorBotToken?: string;
|
||||
};
|
||||
|
||||
export type ChannelRuntimeMode = "normal" | "multi-message";
|
||||
|
||||
export type ChannelRuntimeState = {
|
||||
mode: ChannelRuntimeMode;
|
||||
shuffling: boolean;
|
||||
lastShuffledAt?: number;
|
||||
};
|
||||
|
||||
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,233 +1,349 @@
|
||||
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>;
|
||||
discussionService?: {
|
||||
initDiscussion: (params: {
|
||||
discussionChannelId: string;
|
||||
originChannelId: string;
|
||||
initiatorAgentId: string;
|
||||
initiatorSessionId: string;
|
||||
initiatorWorkspaceRoot?: string;
|
||||
discussGuide: string;
|
||||
}) => Promise<unknown>;
|
||||
handleCallback: (params: {
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
/** Called by create-discussion-channel to initialize the discussion. */
|
||||
onDiscussionCreate?: (params: {
|
||||
channelId: string;
|
||||
summaryPath: string;
|
||||
callerAgentId?: string;
|
||||
callerSessionKey?: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
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 roleOrMemberType(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
||||
return 0;
|
||||
function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined {
|
||||
const m = sessionKey.match(/:discord:channel:(\d+)$/);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
export function registerDirigentTools(deps: ToolDeps): void {
|
||||
const { api, baseConfig, pickDefined, discussionService } = 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;
|
||||
// ───────────────────────────────────────────────
|
||||
// dirigent-register
|
||||
// ───────────────────────────────────────────────
|
||||
api.registerTool({
|
||||
name: "dirigent-register",
|
||||
description: "Register or update this agent's Discord user ID in Dirigent's identity registry.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
discordUserId: { type: "string", description: "The agent's Discord user ID" },
|
||||
agentName: { type: "string", description: "Display name (optional, defaults to agentId)" },
|
||||
},
|
||||
required: ["discordUserId"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
const agentId = ctx?.agentId;
|
||||
if (!agentId) return { content: [{ type: "text", text: "Cannot resolve agentId from session context" }], isError: true };
|
||||
const p = params as { discordUserId: string; agentName?: string };
|
||||
identityRegistry.upsert({
|
||||
agentId,
|
||||
discordUserId: p.discordUserId,
|
||||
agentName: p.agentName ?? agentId,
|
||||
});
|
||||
return { content: [{ type: "text", text: `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";
|
||||
callerCtx: { agentId?: string };
|
||||
}): 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",
|
||||
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"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
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",
|
||||
callerCtx: { agentId: ctx?.agentId },
|
||||
});
|
||||
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
||||
return { content: [{ type: "text", text: `Created chat channel: ${result.channelId}` }] };
|
||||
},
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// create-report-channel
|
||||
// ───────────────────────────────────────────────
|
||||
api.registerTool({
|
||||
name: "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"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
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",
|
||||
callerCtx: { agentId: ctx?.agentId },
|
||||
});
|
||||
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
||||
return { content: [{ type: "text", text: `Created report channel: ${result.channelId}` }] };
|
||||
},
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// create-work-channel
|
||||
// ───────────────────────────────────────────────
|
||||
api.registerTool({
|
||||
name: "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"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
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",
|
||||
callerCtx: { agentId: ctx?.agentId },
|
||||
});
|
||||
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
||||
return { content: [{ type: "text", text: `Created work channel: ${result.channelId}` }] };
|
||||
},
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// create-discussion-channel
|
||||
// ───────────────────────────────────────────────
|
||||
api.registerTool({
|
||||
name: "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"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
const p = params as {
|
||||
callbackGuildId: string;
|
||||
callbackChannelId: string;
|
||||
name: string;
|
||||
discussionGuide: string;
|
||||
participants: string[];
|
||||
};
|
||||
if (live.enableDiscordControlTool === false) {
|
||||
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
|
||||
const initiatorAgentId = ctx?.agentId;
|
||||
if (!initiatorAgentId) {
|
||||
return { content: [{ type: "text", text: "Cannot resolve initiator agentId from session" }], isError: true };
|
||||
}
|
||||
if (!moderatorBotToken) {
|
||||
return { content: [{ type: "text", text: "moderatorBotToken not configured" }], isError: true };
|
||||
}
|
||||
if (!onDiscussionCreate) {
|
||||
return { content: [{ type: "text", text: "Discussion service not available" }], 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;
|
||||
const botId = getBotUserIdFromToken(moderatorBotToken);
|
||||
const initiatorDiscordId = identityRegistry.findByAgentId(initiatorAgentId)?.discordUserId;
|
||||
const memberIds = [...new Set([
|
||||
...(initiatorDiscordId ? [initiatorDiscordId] : []),
|
||||
...p.participants,
|
||||
...(botId ? [botId] : []),
|
||||
])];
|
||||
|
||||
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 callbackChannelId = typeof params.callbackChannelId === "string" ? params.callbackChannelId.trim() : "";
|
||||
const discussGuide = typeof params.discussGuide === "string" ? params.discussGuide.trim() : "";
|
||||
if (callbackChannelId && !discussGuide) {
|
||||
return { content: [{ type: "text", text: "discussGuide is required when callbackChannelId is provided" }], 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 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" })),
|
||||
];
|
||||
|
||||
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 };
|
||||
|
||||
if (callbackChannelId && discussGuide && discussionService) {
|
||||
await discussionService.initDiscussion({
|
||||
discussionChannelId: String(resp.json?.id || ""),
|
||||
originChannelId: callbackChannelId,
|
||||
initiatorAgentId: String((params.__agentId as string | undefined) || ""),
|
||||
initiatorSessionId: String((params.__sessionKey as string | undefined) || ""),
|
||||
initiatorWorkspaceRoot: typeof params.__workspaceRoot === "string" ? params.__workspaceRoot : undefined,
|
||||
discussGuide,
|
||||
});
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json, discussionMode: !!callbackChannelId }, 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",
|
||||
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" },
|
||||
callbackChannelId: { type: "string" },
|
||||
discussGuide: { type: "string" },
|
||||
},
|
||||
required: ["action"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
const nextParams = {
|
||||
...(params as Record<string, unknown>),
|
||||
__agentId: ctx?.agentId,
|
||||
__sessionKey: ctx?.sessionKey,
|
||||
__workspaceRoot: ctx?.workspaceRoot,
|
||||
};
|
||||
return executeDiscordAction(params.action as DiscordControlAction, nextParams);
|
||||
},
|
||||
});
|
||||
|
||||
api.registerTool({
|
||||
name: "discuss-callback",
|
||||
description: "Close a discussion channel and notify the origin work channel with the discussion summary path",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
summaryPath: { type: "string" },
|
||||
},
|
||||
required: ["summaryPath"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
if (!discussionService) {
|
||||
return { content: [{ type: "text", text: "discussion service is not available" }], isError: true };
|
||||
}
|
||||
let channelId: string;
|
||||
try {
|
||||
const result = await discussionService.handleCallback({
|
||||
channelId: String(ctx?.channelId || ""),
|
||||
summaryPath: String((params as Record<string, unknown>).summaryPath || ""),
|
||||
callerAgentId: ctx?.agentId,
|
||||
callerSessionKey: ctx?.sessionKey,
|
||||
channelId = await createDiscordChannel({
|
||||
token: moderatorBotToken,
|
||||
guildId: p.callbackGuildId,
|
||||
name: p.name,
|
||||
permissionOverwrites: overwrites,
|
||||
logger: api.logger,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: "text", text: `discuss-callback failed: ${String(error)}` }], isError: true };
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text", text: `Failed to create channel: ${String(err)}` }], isError: true };
|
||||
}
|
||||
|
||||
try {
|
||||
channelStore.setLockedMode(channelId, "discussion", {
|
||||
initiatorAgentId,
|
||||
callbackGuildId: p.callbackGuildId,
|
||||
callbackChannelId: p.callbackChannelId,
|
||||
concluded: false,
|
||||
});
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text", text: `Failed to register channel: ${String(err)}` }], isError: true };
|
||||
}
|
||||
|
||||
await onDiscussionCreate({
|
||||
channelId,
|
||||
guildId: p.callbackGuildId,
|
||||
initiatorAgentId,
|
||||
callbackGuildId: p.callbackGuildId,
|
||||
callbackChannelId: p.callbackChannelId,
|
||||
discussionGuide: p.discussionGuide,
|
||||
participants: p.participants,
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: `Discussion channel created: ${channelId}` }] };
|
||||
},
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// discussion-complete
|
||||
// ───────────────────────────────────────────────
|
||||
api.registerTool({
|
||||
name: "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"],
|
||||
},
|
||||
handler: async (params, ctx) => {
|
||||
const p = params as { discussionChannelId: string; summary: string };
|
||||
const callerAgentId = ctx?.agentId;
|
||||
if (!callerAgentId) {
|
||||
return { content: [{ type: "text", text: "Cannot resolve agentId from session" }], isError: true };
|
||||
}
|
||||
|
||||
const rec = channelStore.getRecord(p.discussionChannelId);
|
||||
if (rec.mode !== "discussion") {
|
||||
return { content: [{ type: "text", text: `Channel ${p.discussionChannelId} is not a discussion channel` }], isError: true };
|
||||
}
|
||||
if (!rec.discussion) {
|
||||
return { content: [{ type: "text", text: "Discussion metadata not found" }], isError: true };
|
||||
}
|
||||
if (rec.discussion.initiatorAgentId !== callerAgentId) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Only the initiator (${rec.discussion.initiatorAgentId}) may call discussion-complete` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (!p.summary.includes("discussion-summary")) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Summary path must be under {workspace}/discussion-summary/" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
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 { content: [{ type: "text", text: `Discussion ${p.discussionChannelId} concluded. Summary posted to ${rec.discussion.callbackChannelId}.` }] };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './turn-manager.ts';
|
||||
@@ -1,455 +1,268 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import { getChannelShuffling, isMultiMessageMode, markLastShuffled } from "./core/channel-modes.js";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function reshuffleTurnOrder(channelId: string, currentOrder: string[], lastSpeaker?: string): string[] {
|
||||
const shufflingEnabled = getChannelShuffling(channelId);
|
||||
if (!shufflingEnabled) return currentOrder;
|
||||
|
||||
const shuffled = shuffleArray(currentOrder);
|
||||
|
||||
// If there's a last speaker and they're in the order, ensure they're not first
|
||||
if (lastSpeaker && shuffled.length > 1 && shuffled[0] === lastSpeaker) {
|
||||
// Find another speaker to swap with
|
||||
for (let i = 1; i < shuffled.length; i++) {
|
||||
if (shuffled[i] !== lastSpeaker) {
|
||||
[shuffled[0], shuffled[i]] = [shuffled[i], shuffled[0]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
// --- 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" };
|
||||
}
|
||||
export function getInitializingChannels(): Set<string> {
|
||||
if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
|
||||
return _G._tmInitializingChannels as Set<string>;
|
||||
}
|
||||
|
||||
// Waiting for human → block all agents
|
||||
if (state.waitingForHuman) {
|
||||
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
|
||||
}
|
||||
export function markTurnStarted(channelId: string, agentId: string): void {
|
||||
pendingTurns().add(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
// 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" };
|
||||
}
|
||||
export function isTurnPending(channelId: string, agentId: string): boolean {
|
||||
return pendingTurns().has(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
// 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 clearTurnPending(channelId: string, agentId: string): void {
|
||||
pendingTurns().delete(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Counts NO_REPLY completions currently in-flight for an agent that was
|
||||
* blocked (non-speaker or init-suppressed). These completions take ~10s to
|
||||
* arrive (history-building overhead) and may arrive after markTurnStarted,
|
||||
* causing false empty-turn detection. We count them and skip one per agent_end
|
||||
* until the count reaches zero, at which point the next agent_end is real.
|
||||
*/
|
||||
export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) return;
|
||||
|
||||
// Check for multi-message mode exit condition
|
||||
if (isMultiMessageMode(channelId) && isHuman) {
|
||||
// In multi-message mode, human messages don't trigger turn activation
|
||||
// We only exit multi-message mode if the end marker is detected in a higher-level hook
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (state.waitingForHuman) {
|
||||
// Waiting for human — ignore non-human messages
|
||||
return;
|
||||
}
|
||||
|
||||
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 incrementBlockedPending(channelId: string, agentId: string): void {
|
||||
const bpc = blockedPendingCounts();
|
||||
const key = `${channelId}:${agentId}`;
|
||||
bpc.set(key, (bpc.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original turn order if an override is active.
|
||||
*/
|
||||
function restoreOriginalOrder(state: ChannelTurnState): void {
|
||||
if (state.savedTurnOrder) {
|
||||
state.turnOrder = state.savedTurnOrder;
|
||||
state.savedTurnOrder = undefined;
|
||||
state.overrideFirstAgent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a temporary mention override for the turn order.
|
||||
* When a human @mentions specific agents, only those agents speak (in their
|
||||
* relative order from the current turn order). After the cycle returns to the
|
||||
* first agent, the original order is restored.
|
||||
*
|
||||
* @param channelId - Discord channel ID
|
||||
* @param mentionedAccountIds - accountIds of @mentioned agents, ordered by
|
||||
* their position in the current turn order
|
||||
* @returns true if override was set, false if no valid agents
|
||||
*/
|
||||
export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || mentionedAccountIds.length === 0) return false;
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` +
|
||||
`mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` +
|
||||
`order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
// Restore any existing override first
|
||||
restoreOriginalOrder(state);
|
||||
|
||||
// Filter to agents actually in the turn order
|
||||
const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id));
|
||||
if (validIds.length === 0) {
|
||||
console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Order by their position in the current turn order
|
||||
validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b));
|
||||
|
||||
// Save original and apply override
|
||||
state.savedTurnOrder = [...state.turnOrder];
|
||||
state.turnOrder = validIds;
|
||||
state.overrideFirstAgent = validIds[0];
|
||||
state.currentSpeaker = validIds[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` +
|
||||
`overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` +
|
||||
`savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
/** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */
|
||||
export function consumeBlockedPending(channelId: string, agentId: string): boolean {
|
||||
const bpc = blockedPendingCounts();
|
||||
const key = `${channelId}:${agentId}`;
|
||||
const count = bpc.get(key) ?? 0;
|
||||
if (count <= 0) return false;
|
||||
bpc.set(key, count - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mention override is currently active.
|
||||
*/
|
||||
export function hasMentionOverride(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.savedTurnOrder;
|
||||
export function resetBlockedPending(channelId: string, agentId: string): void {
|
||||
blockedPendingCounts().delete(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
function getState(channelId: string): ChannelTurnState | undefined {
|
||||
return channelStates().get(channelId);
|
||||
}
|
||||
|
||||
function ensureState(channelId: string): ChannelTurnState {
|
||||
const cs = channelStates();
|
||||
let s = cs.get(channelId);
|
||||
if (!s) {
|
||||
s = {
|
||||
speakerList: [],
|
||||
currentIndex: 0,
|
||||
emptyThisCycle: new Set(),
|
||||
completedThisCycle: new Set(),
|
||||
dormant: false,
|
||||
anchorMessageId: new Map(),
|
||||
};
|
||||
cs.set(channelId, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Replace the speaker list (called at cycle boundaries and on init). */
|
||||
export function setSpeakerList(channelId: string, speakers: SpeakerEntry[]): void {
|
||||
const s = ensureState(channelId);
|
||||
s.speakerList = speakers;
|
||||
s.currentIndex = 0;
|
||||
}
|
||||
|
||||
/** Get the currently active speaker, or null if dormant / list empty. */
|
||||
export function getCurrentSpeaker(channelId: string): SpeakerEntry | null {
|
||||
const s = getState(channelId);
|
||||
if (!s || s.dormant || s.speakerList.length === 0) return null;
|
||||
return s.speakerList[s.currentIndex] ?? null;
|
||||
}
|
||||
|
||||
/** Check if a given agentId is the current speaker. */
|
||||
export function isCurrentSpeaker(channelId: string, agentId: string): boolean {
|
||||
const speaker = getCurrentSpeaker(channelId);
|
||||
return speaker?.agentId === agentId;
|
||||
}
|
||||
|
||||
/** Record the Discord anchor message ID for an agent's upcoming turn. */
|
||||
export function setAnchor(channelId: string, agentId: string, messageId: string): void {
|
||||
const s = ensureState(channelId);
|
||||
s.anchorMessageId.set(agentId, messageId);
|
||||
}
|
||||
|
||||
export function getAnchor(channelId: string, agentId: string): string | undefined {
|
||||
return getState(channelId)?.anchorMessageId.get(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the channel to "waiting for human" state.
|
||||
* All agents will be routed to no-reply until a human sends a message.
|
||||
* Advance the speaker after a turn completes.
|
||||
* Returns the new current speaker (or null if dormant).
|
||||
*
|
||||
* @param isEmpty - whether the completed turn was an empty turn
|
||||
* @param rebuildFn - async function that fetches current Discord members and
|
||||
* returns a new SpeakerEntry[]. Called at cycle boundaries.
|
||||
* @param previousLastAgentId - for shuffle mode: the last speaker of the
|
||||
* previous cycle (cannot become the new first speaker).
|
||||
*/
|
||||
export function setWaitingForHuman(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return;
|
||||
state.waitingForHuman = true;
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
export async function advanceSpeaker(
|
||||
channelId: string,
|
||||
agentId: string,
|
||||
isEmpty: boolean,
|
||||
rebuildFn: () => Promise<SpeakerEntry[]>,
|
||||
previousLastAgentId?: string,
|
||||
): Promise<{ next: SpeakerEntry | null; enteredDormant: boolean }> {
|
||||
const s = ensureState(channelId);
|
||||
|
||||
// Record this turn
|
||||
s.completedThisCycle.add(agentId);
|
||||
if (isEmpty) s.emptyThisCycle.add(agentId);
|
||||
|
||||
const wasLastInCycle = s.currentIndex >= s.speakerList.length - 1;
|
||||
|
||||
if (!wasLastInCycle) {
|
||||
// Middle of cycle — just advance pointer
|
||||
s.currentIndex++;
|
||||
s.dormant = false;
|
||||
return { next: s.speakerList[s.currentIndex] ?? null, enteredDormant: false };
|
||||
}
|
||||
|
||||
// === Cycle boundary ===
|
||||
const newSpeakers = await rebuildFn();
|
||||
const previousAgentIds = new Set(s.speakerList.map((sp) => sp.agentId));
|
||||
const hasNewAgents = newSpeakers.some((sp) => !previousAgentIds.has(sp.agentId));
|
||||
|
||||
const allEmpty =
|
||||
s.completedThisCycle.size > 0 &&
|
||||
[...s.completedThisCycle].every((id) => s.emptyThisCycle.has(id));
|
||||
|
||||
// Reset cycle tracking
|
||||
s.emptyThisCycle = new Set();
|
||||
s.completedThisCycle = new Set();
|
||||
|
||||
if (allEmpty && !hasNewAgents) {
|
||||
// Enter dormant
|
||||
s.speakerList = newSpeakers;
|
||||
s.currentIndex = 0;
|
||||
s.dormant = true;
|
||||
return { next: null, enteredDormant: true };
|
||||
}
|
||||
|
||||
// Continue with updated list (apply shuffle if caller provides previousLastAgentId)
|
||||
s.speakerList = previousLastAgentId != null
|
||||
? shuffleList(newSpeakers, previousLastAgentId)
|
||||
: newSpeakers;
|
||||
s.currentIndex = 0;
|
||||
s.dormant = false;
|
||||
|
||||
return { next: s.speakerList[0] ?? null, enteredDormant: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the channel is waiting for a human reply.
|
||||
* Wake the channel from dormant.
|
||||
* Returns the new first speaker.
|
||||
*/
|
||||
export function isWaitingForHuman(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.waitingForHuman;
|
||||
export function wakeFromDormant(channelId: string): SpeakerEntry | null {
|
||||
const s = getState(channelId);
|
||||
if (!s) return null;
|
||||
s.dormant = false;
|
||||
s.currentIndex = 0;
|
||||
s.emptyThisCycle = new Set();
|
||||
s.completedThisCycle = new Set();
|
||||
return s.speakerList[0] ?? null;
|
||||
}
|
||||
|
||||
export function isDormant(channelId: string): boolean {
|
||||
return getState(channelId)?.dormant ?? false;
|
||||
}
|
||||
|
||||
export function hasSpeakers(channelId: string): boolean {
|
||||
const s = getState(channelId);
|
||||
return (s?.speakerList.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
|
||||
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
|
||||
* @returns the new currentSpeaker (or null if dormant)
|
||||
* Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
|
||||
*/
|
||||
export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return null;
|
||||
if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore
|
||||
|
||||
if (wasNoReply) {
|
||||
state.noRepliedThisCycle.add(accountId);
|
||||
|
||||
// Check if ALL agents have NO_REPLY'd this cycle
|
||||
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
|
||||
if (allNoReplied) {
|
||||
// If override active, restore original order before going dormant
|
||||
restoreOriginalOrder(state);
|
||||
// Go dormant
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return null;
|
||||
export function shuffleList(list: SpeakerEntry[], previousLastAgentId?: string): SpeakerEntry[] {
|
||||
if (list.length <= 1) return list;
|
||||
const arr = [...list];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
} else {
|
||||
// Successful speech resets the cycle counter
|
||||
state.noRepliedThisCycle = new Set();
|
||||
if (previousLastAgentId && arr[0].agentId === previousLastAgentId && arr.length > 1) {
|
||||
const swapIdx = 1 + Math.floor(Math.random() * (arr.length - 1));
|
||||
[arr[0], arr[swapIdx]] = [arr[swapIdx], arr[0]];
|
||||
}
|
||||
|
||||
const prevSpeaker = state.currentSpeaker;
|
||||
const next = advanceTurn(channelId);
|
||||
|
||||
// Check if override cycle completed (returned to first agent)
|
||||
if (state.overrideFirstAgent && next === state.overrideFirstAgent) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return null; // go dormant after override cycle completes
|
||||
}
|
||||
|
||||
// Check if we've completed a full cycle (all agents spoke once)
|
||||
// This happens when we're back to the first agent in the turn order
|
||||
const isFirstSpeakerAgain = next === state.turnOrder[0];
|
||||
if (!wasNoReply && !state.overrideFirstAgent && next && isFirstSpeakerAgain && state.noRepliedThisCycle.size === 0) {
|
||||
// Completed a full cycle without anyone NO_REPLYing - reshuffle if enabled
|
||||
const newOrder = reshuffleTurnOrder(channelId, state.turnOrder, prevSpeaker);
|
||||
if (newOrder !== state.turnOrder) {
|
||||
state.turnOrder = newOrder;
|
||||
markLastShuffled(channelId);
|
||||
console.log(`[dirigent][turn-debug] reshuffled turn order for channel=${channelId} newOrder=${JSON.stringify(newOrder)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to next speaker in order.
|
||||
*/
|
||||
export function advanceTurn(channelId: string): string | null {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) return null;
|
||||
|
||||
if (state.currentSpeaker === null) return null;
|
||||
|
||||
const idx = state.turnOrder.indexOf(state.currentSpeaker);
|
||||
const nextIdx = (idx + 1) % state.turnOrder.length;
|
||||
|
||||
// Skip agents that already NO_REPLY'd this cycle
|
||||
let attempts = 0;
|
||||
let candidateIdx = nextIdx;
|
||||
while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) {
|
||||
candidateIdx = (candidateIdx + 1) % state.turnOrder.length;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= state.turnOrder.length) {
|
||||
// All have NO_REPLY'd
|
||||
state.currentSpeaker = null;
|
||||
state.lastChangedAt = Date.now();
|
||||
return null;
|
||||
}
|
||||
|
||||
state.currentSpeaker = state.turnOrder[candidateIdx];
|
||||
state.lastChangedAt = Date.now();
|
||||
return state.currentSpeaker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reset: go dormant.
|
||||
*/
|
||||
export function resetTurn(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (state) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.waitingForHuman = false;
|
||||
state.lastChangedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug info.
|
||||
*/
|
||||
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return { channelId, hasTurnState: false };
|
||||
export function getDebugInfo(channelId: string) {
|
||||
const s = getState(channelId);
|
||||
if (!s) return { exists: false };
|
||||
return {
|
||||
channelId,
|
||||
hasTurnState: true,
|
||||
turnOrder: state.turnOrder,
|
||||
currentSpeaker: state.currentSpeaker,
|
||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||
lastChangedAt: state.lastChangedAt,
|
||||
dormant: state.currentSpeaker === null,
|
||||
waitingForHuman: state.waitingForHuman,
|
||||
hasOverride: !!state.savedTurnOrder,
|
||||
overrideFirstAgent: state.overrideFirstAgent || null,
|
||||
savedTurnOrder: state.savedTurnOrder || null,
|
||||
exists: true,
|
||||
speakerList: s.speakerList.map((sp) => sp.agentId),
|
||||
currentIndex: s.currentIndex,
|
||||
currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null,
|
||||
dormant: s.dormant,
|
||||
emptyThisCycle: [...s.emptyThisCycle],
|
||||
completedThisCycle: [...s.completedThisCycle],
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a channel's turn state entirely (e.g. when archived). */
|
||||
export function clearChannel(channelId: string): void {
|
||||
channelStates().delete(channelId);
|
||||
}
|
||||
|
||||
294
plugin/web/control-page.ts
Normal file
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: "gateway",
|
||||
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: "gateway",
|
||||
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: "gateway",
|
||||
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: "gateway",
|
||||
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: "gateway",
|
||||
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 }));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -133,21 +133,6 @@ function getJson(pathKey) {
|
||||
try { return JSON.parse(out); } catch { return undefined; }
|
||||
}
|
||||
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); }
|
||||
function isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
|
||||
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 syncDirRecursive(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.cpSync(src, dest, { recursive: true, force: true });
|
||||
@@ -187,24 +172,34 @@ if (mode === "install") {
|
||||
}
|
||||
|
||||
step(4, 7, "configure plugin entry");
|
||||
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 existingDirigent = isPlainObject(plugins.entries.dirigent) ? plugins.entries.dirigent : {};
|
||||
const desired = {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true, discordOnly: true, listMode: "human-list",
|
||||
humanList: [], agentList: [],
|
||||
channelPoliciesFile: path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"),
|
||||
endSymbols: ["🔚"], schedulingIdentifier: "➡️",
|
||||
noReplyProvider: NO_REPLY_PROVIDER_ID, noReplyModel: NO_REPLY_MODEL_ID, noReplyPort: NO_REPLY_PORT,
|
||||
},
|
||||
};
|
||||
plugins.entries.dirigent = mergePreservingExisting(existingDirigent, desired);
|
||||
setJson("plugins", plugins);
|
||||
// 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}.enabled`, true);
|
||||
setIfMissing(`${cp}.discordOnly`, true);
|
||||
setIfMissing(`${cp}.listMode`, "human-list");
|
||||
setIfMissing(`${cp}.humanList`, []);
|
||||
setIfMissing(`${cp}.agentList`, []);
|
||||
setIfMissing(`${cp}.channelPoliciesFile`, path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"));
|
||||
setIfMissing(`${cp}.endSymbols`, ["🔚"]);
|
||||
setIfMissing(`${cp}.schedulingIdentifier`, "➡️");
|
||||
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
|
||||
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
|
||||
setIfMissing(`${cp}.noReplyPort`, NO_REPLY_PORT);
|
||||
// moderatorBotToken: intentionally not touched — set manually via:
|
||||
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
|
||||
ok("plugin configured");
|
||||
|
||||
step(5, 7, "configure no-reply provider");
|
||||
|
||||
Reference in New Issue
Block a user