refactor #22

Merged
hzhang merged 33 commits from refactor into main 2026-04-10 07:49:57 +00:00
40 changed files with 2427 additions and 2753 deletions
Showing only changes of commit b5196e972c - Show all commits

393
DESIGN.md Normal file
View File

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

View File

@@ -1 +0,0 @@
export * from './channel-resolver.ts';

View File

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

View File

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

View File

@@ -1,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 };
},
});
}

View File

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

View File

@@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import 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_VIEW_CHANNEL = 1n << 10n;
const PERM_ADMINISTRATOR = 1n << 3n; 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; 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 root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {}; const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {}; const discord = (channels.discord as Record<string, unknown>) || {};
@@ -95,8 +102,15 @@ function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
return 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 []; if (!token) return [];
const ch = await discordRequest(token, "GET", `/channels/${channelId}`); 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 || "")) .map((m) => String(m?.user?.id || ""))
.filter(Boolean); .filter(Boolean);
const userToAccount = buildUserIdToAccountIdMap(api);
const out = new Set<string>(); const out = new Set<string>();
if (identityRegistry) {
const discordToAgent = identityRegistry.buildDiscordToAgentMap();
for (const uid of visibleUserIds) { for (const uid of visibleUserIds) {
const aid = userToAccount.get(uid); const aid = discordToAgent.get(uid);
if (aid) out.add(aid); if (aid) out.add(aid);
} }
}
return [...out]; return [...out];
} }

View File

@@ -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);
}

View File

@@ -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);
}

View File

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

View File

@@ -1 +0,0 @@
export * from './discussion-messages.ts';

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -1 +0,0 @@
export * from './discussion-state.ts';

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export * from './moderator-discord.ts';

View File

@@ -1,5 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
type Logger = { info: (m: string) => void; warn: (m: string) => void };
function userIdFromToken(token: string): string | undefined { function userIdFromToken(token: string): string | undefined {
try { try {
const segment = token.split(".")[0]; const segment = token.split(".")[0];
@@ -28,7 +30,7 @@ export async function sendModeratorMessage(
token: string, token: string,
channelId: string, channelId: string,
content: string, content: string,
logger: { info: (msg: string) => void; warn: (msg: string) => void }, logger: Logger,
): Promise<ModeratorMessageResult> { ): Promise<ModeratorMessageResult> {
try { try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { 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 }; 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;
}
}

View File

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

View File

@@ -1,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);
}
}

View File

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

View File

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

View File

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

221
plugin/hooks/agent-end.ts Normal file
View 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;
}

View File

@@ -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)}`);
}
});
}

View File

@@ -1,176 +1,141 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js"; import type { ChannelStore } from "../core/channel-store.js";
import { checkTurn } from "../turn-manager.js"; import type { IdentityRegistry } from "../core/identity-registry.js";
import { deriveDecisionInputFromPrompt } from "../decision-input.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 = { /** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
enableDebugLogs?: boolean; export function parseDiscordChannelId(sessionKey: string): string | undefined {
debugLogChannelIds?: string[]; const m = sessionKey.match(/:discord:channel:(\d+)$/);
}; return m?.[1];
}
type DecisionRecord = { type Deps = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
type BeforeModelResolveDeps = {
api: OpenClawPluginApi; api: OpenClawPluginApi;
baseConfig: DirigentConfig; channelStore: ChannelStore;
sessionDecision: Map<string, DecisionRecord>; identityRegistry: IdentityRegistry;
sessionAllowed: Map<string, boolean>; moderatorBotToken: string | undefined;
sessionChannelId: Map<string, string>; noReplyModel: string;
sessionAccountId: Map<string, string>; noReplyProvider: string;
recordDiscussionSession?: (channelId: string, sessionKey: string) => void; scheduleIdentifier: string;
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;
};
}; };
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void { /**
const { * Process-level deduplication for before_model_resolve events.
api, * Uses a WeakSet keyed on the event object — works when OpenClaw passes
baseConfig, * the same event reference to all stacked handlers (hot-reload scenario).
sessionDecision, * Stored on globalThis so it persists across module reloads.
sessionAllowed, */
sessionChannelId, const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents";
sessionAccountId, if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
recordDiscussionSession, (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] = new WeakSet<object>();
forceNoReplySessions, }
policyState, const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
DECISION_TTL_MS,
ensurePolicyStateLoaded, export function registerBeforeModelResolveHook(deps: Deps): void {
resolveAccountId, const { api, channelStore, identityRegistry, moderatorBotToken, noReplyModel, noReplyProvider, scheduleIdentifier } = deps;
pruneDecisionMap,
shouldDebugLog, const NO_REPLY = { model: noReplyModel, provider: noReplyProvider, noReply: true } as const;
ensureTurnOrder,
isMultiMessageMode, /** Shared init lock — see turn-manager.ts getInitializingChannels(). */
discussionService, const initializingChannels = getInitializingChannels();
} = deps;
api.on("before_model_resolve", async (event, ctx) => { api.on("before_model_resolve", async (event, ctx) => {
const key = ctx.sessionKey; // Deduplicate: if another handler instance already processed this event
if (!key) return; // 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; const sessionKey = ctx.sessionKey;
ensurePolicyStateLoaded(api, live); if (!sessionKey) return;
if (forceNoReplySessions.has(key)) { // Only handle Discord group channel sessions
return { const channelId = parseDiscordChannelId(sessionKey);
model: ctx.model, if (!channelId) return;
provider: ctx.provider,
noReply: true, 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) { // If this agent is NOT the first speaker, trigger first speaker and suppress this one
api.logger.info( if (first.agentId !== agentId && moderatorBotToken) {
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
`promptPreview=${prompt.slice(0, 300)}`, return NO_REPLY;
);
} }
// If this agent IS the first speaker, fall through to normal turn logic
const derived = deriveDecisionInputFromPrompt({ } else {
prompt, // No registered agents visible — let everyone respond freely
messageProvider: ctx.messageProvider, return;
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,
};
} }
} catch (err) {
if (isMultiMessageMode(derived.channelId)) { api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
sessionAllowed.set(key, false); return;
api.logger.info(`dirigent: before_model_resolve forcing no-reply for multi-message mode channel=${derived.channelId} session=${key}`); } finally {
return { initializingChannels.delete(channelId);
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}`,
);
} }
} }
if (derived.channelId) { // If channel is dormant: suppress all agents
await ensureTurnOrder(api, derived.channelId); if (isDormant(channelId)) return NO_REPLY;
const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) { if (!isCurrentSpeaker(channelId, agentId)) {
const turnCheck = checkTurn(derived.channelId, accountId); api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`);
if (!turnCheck.allowed) { incrementBlockedPending(channelId, agentId);
sessionAllowed.set(key, false); return NO_REPLY;
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,
};
} }
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; // Verify agent has a known Discord user ID (needed for tail-match later)
const identity = identityRegistry.findByAgentId(agentId);
const out: Record<string, unknown> = { noReply: true }; if (!identity) {
if (rec.decision.provider) out.provider = rec.decision.provider; api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`);
if (rec.decision.model) out.model = rec.decision.model; }
return out;
}); });
} }

View File

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

View File

@@ -1,141 +1,128 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js"; import type { ChannelStore } from "../core/channel-store.js";
import { extractDiscordChannelId } from "../channel-resolver.js"; import type { IdentityRegistry } from "../core/identity-registry.js";
import { isDiscussionOriginCallbackMessage } from "../core/discussion-messages.js"; import { parseDiscordChannelId } from "./before-model-resolve.js";
import type { DirigentConfig } from "../rules.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 = { type Deps = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
type MessageReceivedDeps = {
api: OpenClawPluginApi; api: OpenClawPluginApi;
baseConfig: DirigentConfig; channelStore: ChannelStore;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; identityRegistry: IdentityRegistry;
debugCtxSummary: (ctx: Record<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>; moderatorBotToken: string | undefined;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void; scheduleIdentifier: string;
getModeratorUserId: (cfg: DirigentConfig) => string | undefined; interruptTailMatch: InterruptFn;
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>;
};
}; };
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { export function registerMessageReceivedHook(deps: Deps): void {
const { const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps;
api,
baseConfig,
shouldDebugLog,
debugCtxSummary,
ensureTurnOrder,
getModeratorUserId,
recordChannelAccount,
extractMentionedUserIds,
buildUserIdToAccountIdMap,
enterMultiMessageMode,
exitMultiMessageMode,
discussionService,
} = deps;
api.on("message_received", async (event, ctx) => { api.on("message_received", async (event, ctx) => {
try { try {
const c = (ctx || {}) as Record<string, unknown>; const e = event as Record<string, unknown>;
const e = (event || {}) as Record<string, unknown>; const c = ctx as Record<string, unknown>;
const preChannelId = extractDiscordChannelId(c, e);
const livePre = baseConfig as DirigentConfig & DebugConfig; // Extract Discord channel ID from session key or event metadata
if (shouldDebugLog(livePre, preChannelId)) { let channelId: string | undefined;
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); if (typeof c.sessionKey === "string") {
channelId = parseDiscordChannelId(c.sessionKey);
} }
if (!channelId) {
if (preChannelId) { // Try from event metadata (conversation_info channel_id field)
await ensureTurnOrder(api, preChannelId); const metadata = e.metadata as Record<string, unknown> | undefined;
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined; const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
const from = const raw = String(convInfo?.channel_id ?? metadata?.channelId ?? "");
(typeof metadata?.senderId === "string" && metadata.senderId) || if (/^\d+$/.test(raw)) channelId = raw;
(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) return;
const messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || ""; const mode = channelStore.getMode(channelId);
const isModeratorOriginCallback = !!(moderatorUserId && from === moderatorUserId && isDiscussionOriginCallbackMessage(messageContent));
if (moderatorUserId && from === moderatorUserId && !isModeratorOriginCallback) { // dead: suppress routing entirely (OpenClaw handles no-route automatically,
if (shouldDebugLog(livePre, preChannelId)) { // but we handle archived auto-reply here)
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`); if (mode === "report") return;
}
} else {
const humanList = livePre.humanList || livePre.bypassUserIds || [];
const isHuman = humanList.includes(from);
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
if (senderAccountId && senderAccountId !== "default") { // archived: auto-reply via moderator
const isNew = recordChannelAccount(api, preChannelId, senderAccountId); if (mode === "discussion") {
if (isNew) { const rec = channelStore.getRecord(channelId);
await ensureTurnOrder(api, preChannelId); if (rec.discussion?.concluded && moderatorBotToken) {
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); await sendModeratorMessage(
moderatorBotToken, channelId,
"This discussion is closed and no longer active.",
api.logger,
).catch(() => undefined);
return;
} }
} }
if (isHuman) { if (mode === "none" || mode === "work") return;
const startMarker = livePre.multiMessageStartMarker || "↗️";
const endMarker = livePre.multiMessageEndMarker || "↙️";
if (messageContent.includes(startMarker)) { // chat / discussion (active): initialize speaker list on first message if needed
enterMultiMessageMode(preChannelId); const initializingChannels = getInitializingChannels();
api.logger.info(`dirigent: entered multi-message mode channel=${preChannelId}`); if (!hasSpeakers(channelId) && moderatorBotToken) {
} else if (messageContent.includes(endMarker)) { // Guard against concurrent initialization from multiple VM contexts
exitMultiMessageMode(preChannelId); if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: exited multi-message mode channel=${preChannelId}`); api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
onNewMessage(preChannelId, senderAccountId, isHuman); return;
} else { }
const mentionedUserIds = extractMentionedUserIds(messageContent); 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) { if (speakers.length > 0) {
const userIdMap = buildUserIdToAccountIdMap(api); setSpeakerList(channelId, speakers);
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid); 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) { // chat / discussion (active): check if this is an external message
await ensureTurnOrder(api, preChannelId); // that should interrupt an in-progress tail-match or wake dormant
const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds);
if (overrideSet) { const senderId = String(
api.logger.info( (e.metadata as Record<string, unknown>)?.senderId ??
`dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`, (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)) { // Identify the sender: is it the current speaker's Discord account?
api.logger.info( const currentSpeakerIsThisSender = (() => {
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`, 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) { } catch (err) {
api.logger.warn(`dirigent: message hook failed: ${String(err)}`); api.logger.warn(`dirigent: message_received hook error: ${String(err)}`);
} }
}); });
} }

View File

@@ -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)}`);
}
});
}

View File

@@ -1,240 +1,213 @@
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { DirigentConfig } from "./rules.js"; import { IdentityRegistry } from "./core/identity-registry.js";
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; import { ChannelStore } from "./core/channel-store.js";
import { registerMessageReceivedHook } from "./hooks/message-received.js"; import { scanPaddedCell } from "./core/padded-cell.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 { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js"; import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js";
import { createDiscussionService } from "./core/discussion-service.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode } from "./core/channel-modes.js"; import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
import { import { registerAgentEndHook } from "./hooks/agent-end.js";
DECISION_TTL_MS, import { registerMessageReceivedHook } from "./hooks/message-received.js";
forceNoReplySessions, import { registerDirigentTools } from "./tools/register-tools.js";
getDiscussionSessionKeys, import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js";
pruneDecisionMap, import { registerAddGuildCommand } from "./commands/add-guild-command.js";
recordDiscussionSession, import { registerControlPage } from "./web/control-page.js";
sessionAccountId, import { sendModeratorMessage, sendAndDelete } from "./core/moderator-discord.js";
sessionAllowed, import { setSpeakerList } from "./turn-manager.js";
sessionChannelId, import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
sessionDecision,
sessionInjected,
sessionTurnHandled,
} from "./core/session-state.js";
type DebugConfig = { type PluginConfig = {
enableDebugLogs?: boolean; moderatorBotToken?: string;
debugLogChannelIds?: string[]; noReplyProvider?: string;
noReplyModel?: string;
noReplyPort?: number;
scheduleIdentifier?: string;
identityFilePath?: string;
channelStoreFilePath?: string;
}; };
type NormalizedDirigentConfig = DirigentConfig & { function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
enableDiscordControlTool: boolean; const cfg = (api.pluginConfig ?? {}) as PluginConfig;
enableDirigentPolicyTool: boolean;
};
function normalizePluginConfig(api: OpenClawPluginApi): NormalizedDirigentConfig {
return { return {
enableDiscordControlTool: true, moderatorBotToken: cfg.moderatorBotToken ?? "",
enableDirigentPolicyTool: true, noReplyProvider: cfg.noReplyProvider ?? "dirigent",
enableDebugLogs: false, noReplyModel: cfg.noReplyModel ?? "no-reply",
debugLogChannelIds: [], noReplyPort: Number(cfg.noReplyPort ?? 8787),
noReplyPort: 8787, scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️",
schedulingIdentifier: "➡️", identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"),
waitIdentifier: "👤", channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"),
multiMessageStartMarker: "↗️", };
multiMessageEndMarker: "↙️",
multiMessagePromptMarker: "⤵️",
...(api.pluginConfig || {}),
} as NormalizedDirigentConfig;
} }
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string { /**
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; * Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once
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}.`; * when the gateway process starts/stops, not per agent session. We guard these on
if (isGroupChat) { * globalThis so only the first register() call adds the lifecycle handlers.
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.`; * Agent-session events (before_model_resolve, agent_end, message_received) are
} * delivered via the api instance that belongs to each individual agent session.
return instruction; * 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 { function markGatewayLifecycleRegistered(): void {
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.`; _G[_GATEWAY_LIFECYCLE_KEY] = true;
} }
export default { export default {
id: "dirigent", id: "dirigent",
name: "Dirigent", name: "Dirigent",
register(api: OpenClawPluginApi) { register(api: OpenClawPluginApi) {
const baseConfig = normalizePluginConfig(api); const config = normalizeConfig(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 pluginDir = path.dirname(new URL(import.meta.url).pathname); 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 const identityRegistry = new IdentityRegistry(config.identityFilePath);
api.on("gateway_start", () => { const channelStore = new ChannelStore(config.channelStoreFilePath);
api.logger.info(`dirigent: gateway_start event received`);
const live = normalizePluginConfig(api); let paddedCellDetected = false;
// Check no-reply-api server file exists function hasPaddedCell(): boolean {
const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs"); return paddedCellDetected;
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)}`);
} }
startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787)); function tryAutoScanPaddedCell(): void {
api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`); 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) { if (live.moderatorBotToken) {
api.logger.info("dirigent: starting moderator bot presence...");
startModeratorPresence(live.moderatorBotToken, api.logger); startModeratorPresence(live.moderatorBotToken, api.logger);
api.logger.info("dirigent: moderator bot presence started"); api.logger.info("dirigent: moderator bot presence started");
} else { } 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", () => { api.on("gateway_stop", () => {
stopNoReplyApi(api.logger); stopNoReplyApi(api.logger);
stopModeratorPresence(); 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, api,
moderatorBotToken: baseConfig.moderatorBotToken, channelStore,
moderatorUserId: getModeratorUserId(baseConfig), identityRegistry,
workspaceRoot: process.cwd(), moderatorBotToken: config.moderatorBotToken || undefined,
forceNoReplyForSession: (sessionKey: string) => { scheduleIdentifier: config.scheduleIdentifier,
if (sessionKey) forceNoReplySessions.add(sessionKey); 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({ registerMessageReceivedHook({
api, api,
baseConfig, channelStore,
shouldDebugLog, identityRegistry,
debugCtxSummary, moderatorBotToken: config.moderatorBotToken || undefined,
ensureTurnOrder, scheduleIdentifier: config.scheduleIdentifier,
getModeratorUserId, interruptTailMatch,
recordChannelAccount,
extractMentionedUserIds,
buildUserIdToAccountIdMap,
enterMultiMessageMode,
exitMultiMessageMode,
discussionService,
}); });
registerBeforeModelResolveHook({ // ── Tools ──────────────────────────────────────────────────────────────
registerDirigentTools({
api, api,
baseConfig, channelStore,
sessionDecision, identityRegistry,
sessionAllowed, moderatorBotToken: config.moderatorBotToken || undefined,
sessionChannelId, scheduleIdentifier: config.scheduleIdentifier,
sessionAccountId, onDiscussionCreate: async ({ channelId, guildId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => {
recordDiscussionSession, const live = normalizeConfig(api);
forceNoReplySessions, if (!live.moderatorBotToken) return;
policyState,
DECISION_TTL_MS, // Post discussion-guide to wake participants
ensurePolicyStateLoaded, await sendModeratorMessage(live.moderatorBotToken, channelId, discussionGuide, api.logger)
resolveAccountId, .catch(() => undefined);
pruneDecisionMap,
shouldDebugLog, // Initialize speaker list
ensureTurnOrder, const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
isMultiMessageMode, const speakers = agentIds
discussionService, .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({ // ── Commands ───────────────────────────────────────────────────────────
api, registerSetChannelModeCommand({ api, channelStore });
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
registerAddGuildCommand(api); registerAddGuildCommand(api);
// Handle NO_REPLY detection before message write // ── Control page ───────────────────────────────────────────────────────
registerBeforeMessageWriteHook({ registerControlPage({
api, api,
baseConfig, channelStore,
policyState, identityRegistry,
sessionAllowed, moderatorBotToken: config.moderatorBotToken || undefined,
sessionChannelId, openclawDir,
sessionAccountId, hasPaddedCell,
sessionTurnHandled,
ensurePolicyStateLoaded,
shouldDebugLog,
ensureTurnOrder,
resolveDiscordUserId,
isMultiMessageMode,
sendModeratorMessage,
discussionService,
}); });
// Turn advance: when an agent sends a message, check if it signals end of turn api.logger.info("dirigent: plugin registered (v2)");
registerMessageSentHook({
api,
baseConfig,
policyState,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded,
resolveDiscordUserId,
sendModeratorMessage,
discussionService,
});
}, },
}; };

View File

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

View File

@@ -1 +0,0 @@
export * from './rules.ts';

View File

@@ -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" };
}

View File

@@ -1,233 +1,349 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { DirigentConfig } from "../rules.js"; import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update"; import { createDiscordChannel, getBotUserIdFromToken } from "../core/moderator-discord.js";
import { setSpeakerList } from "../turn-manager.js";
type ToolDeps = { type ToolDeps = {
api: OpenClawPluginApi; api: OpenClawPluginApi;
baseConfig: DirigentConfig; channelStore: ChannelStore;
pickDefined: (obj: Record<string, unknown>) => Record<string, unknown>; identityRegistry: IdentityRegistry;
discussionService?: { moderatorBotToken: string | undefined;
initDiscussion: (params: { scheduleIdentifier: string;
discussionChannelId: string; /** Called by create-discussion-channel to initialize the discussion. */
originChannelId: string; onDiscussionCreate?: (params: {
initiatorAgentId: string;
initiatorSessionId: string;
initiatorWorkspaceRoot?: string;
discussGuide: string;
}) => Promise<unknown>;
handleCallback: (params: {
channelId: string; channelId: string;
summaryPath: string; guildId: string;
callerAgentId?: string; initiatorAgentId: string;
callerSessionKey?: string; callbackGuildId: string;
}) => Promise<unknown>; callbackChannelId: string;
}; discussionGuide: string;
participants: string[];
}) => Promise<void>;
}; };
function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null { function getGuildIdFromSessionKey(sessionKey: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {}; // sessionKey doesn't encode guild — it's not available directly.
const channels = (root.channels as Record<string, unknown>) || {}; // Guild is passed explicitly by the agent.
const discord = (channels.discord as Record<string, unknown>) || {}; return undefined;
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;
} }
async function discordRequest(token: string, method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; text: string; json: any }> { function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined {
const r = await fetch(`https://discord.com/api/v10${path}`, { const m = sessionKey.match(/:discord:channel:(\d+)$/);
method, return m?.[1];
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;
} }
export function registerDirigentTools(deps: ToolDeps): void { 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 & { // dirigent-register
enableDiscordControlTool?: boolean; // ───────────────────────────────────────────────
discordControlAccountId?: string; 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) { const initiatorAgentId = ctx?.agentId;
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; 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); const botId = getBotUserIdFromToken(moderatorBotToken);
if (!selected) { const initiatorDiscordId = identityRegistry.findByAgentId(initiatorAgentId)?.discordUserId;
return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true }; const memberIds = [...new Set([
} ...(initiatorDiscordId ? [initiatorDiscordId] : []),
const token = selected.token; ...p.participants,
...(botId ? [botId] : []),
])];
if (action === "channel-private-create") { const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [
const guildId = String(params.guildId || "").trim(); { id: p.callbackGuildId, type: 0, deny: "1024" },
const name = String(params.name || "").trim(); ...memberIds.map((id) => ({ id, type: 1, allow: "1024" })),
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 body = pickDefined({ let channelId: string;
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 };
}
try { try {
const result = await discussionService.handleCallback({ channelId = await createDiscordChannel({
channelId: String(ctx?.channelId || ""), token: moderatorBotToken,
summaryPath: String((params as Record<string, unknown>).summaryPath || ""), guildId: p.callbackGuildId,
callerAgentId: ctx?.agentId, name: p.name,
callerSessionKey: ctx?.sessionKey, permissionOverwrites: overwrites,
logger: api.logger,
}); });
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (err) {
} catch (error) { return { content: [{ type: "text", text: `Failed to create channel: ${String(err)}` }], isError: true };
return { content: [{ type: "text", text: `discuss-callback failed: ${String(error)}` }], 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}.` }] };
}, },
}); });
} }

View File

@@ -1 +0,0 @@
export * from './turn-manager.ts';

View File

@@ -1,455 +1,268 @@
/** /**
* Turn-based speaking manager for group channels. * Turn Manager (v2)
* *
* Rules: * Per-channel state machine governing who speaks when.
* - Humans (humanList) are never in the turn order * Called from before_model_resolve (check turn) and agent_end (advance turn).
* - 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
*/ */
import { getChannelShuffling, isMultiMessageMode, markLastShuffled } from "./core/channel-modes.js"; export type SpeakerEntry = {
agentId: string;
export type ChannelTurnState = { discordUserId: string;
/** 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;
}; };
const channelTurns = new Map<string, ChannelTurnState>(); type ChannelTurnState = {
speakerList: SpeakerEntry[];
/** Turn timeout: if the current speaker hasn't responded, auto-advance */ currentIndex: number;
const TURN_TIMEOUT_MS = 60_000; /** Tracks which agents sent empty turns in the current cycle. */
emptyThisCycle: Set<string>;
// --- helpers --- /** Tracks which agents completed a turn at all this cycle. */
completedThisCycle: Set<string>;
function shuffleArray<T>(arr: T[]): T[] { dormant: boolean;
const a = [...arr]; /** Discord message ID recorded at before_model_resolve, used as poll anchor. */
for (let i = a.length - 1; i > 0; i--) { anchorMessageId: Map<string, string>; // agentId → messageId
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 ---
/** /**
* Initialize or update the turn order for a channel. * All mutable state is stored on globalThis so it persists across VM-context
* Called with the list of bot accountIds (already filtered, humans excluded). * 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 _G = globalThis as Record<string, unknown>;
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
console.log( function channelStates(): Map<string, ChannelTurnState> {
`[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` + if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map<string, ChannelTurnState>();
`oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` + return _G._tmChannelStates as Map<string, ChannelTurnState>;
`oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`,
);
const nextOrder = shuffleArray(botAccountIds);
// Mention override active: update only the saved base order.
// Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered.
if (existing.savedTurnOrder) {
existing.savedTurnOrder = nextOrder;
existing.lastChangedAt = Date.now();
console.log(
`[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` +
`savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` +
`keptCurrent=${existing.currentSpeaker}`,
);
return;
} }
// Non-mention flow: preserve previous behavior (re-init to dormant). function pendingTurns(): Set<string> {
channelTurns.set(channelId, { if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set<string>();
turnOrder: nextOrder, return _G._tmPendingTurns as Set<string>;
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( function blockedPendingCounts(): Map<string, number> {
`[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`, if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map<string, number>();
); return _G._tmBlockedPendingCounts as Map<string, number>;
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`,
);
} }
/** /**
* 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): { export function getInitializingChannels(): Set<string> {
allowed: boolean; if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
currentSpeaker: string | null; return _G._tmInitializingChannels as Set<string>;
reason: string;
} {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) {
return { allowed: true, currentSpeaker: null, reason: "no_turn_state" };
} }
// Waiting for human → block all agents export function markTurnStarted(channelId: string, agentId: string): void {
if (state.waitingForHuman) { pendingTurns().add(`${channelId}:${agentId}`);
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
} }
// Not in turn order (human or unknown) → always allowed export function isTurnPending(channelId: string, agentId: string): boolean {
if (!state.turnOrder.includes(accountId)) { return pendingTurns().has(`${channelId}:${agentId}`);
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
} }
// Dormant → not allowed (will be activated by onNewMessage) export function clearTurnPending(channelId: string, agentId: string): void {
if (state.currentSpeaker === null) { pendingTurns().delete(`${channelId}:${agentId}`);
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" };
} }
/** /**
* Called when a new message arrives in the channel. * Counts NO_REPLY completions currently in-flight for an agent that was
* Handles reactivation from dormant state and human-triggered resets. * blocked (non-speaker or init-suppressed). These completions take ~10s to
* * arrive (history-building overhead) and may arrive after markTurnStarted,
* NOTE: For human messages with @mentions, call setMentionOverride() instead. * 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.
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
* @param isHuman - whether the sender is in the humanList
*/ */
export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void { export function incrementBlockedPending(channelId: string, agentId: string): void {
const state = channelTurns.get(channelId); const bpc = blockedPendingCounts();
if (!state || state.turnOrder.length === 0) return; const key = `${channelId}:${agentId}`;
bpc.set(key, (bpc.get(key) ?? 0) + 1);
// 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) { /** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */
// Human message: clear wait-for-human, restore original order if overridden, activate from first export function consumeBlockedPending(channelId: string, agentId: string): boolean {
state.waitingForHuman = false; const bpc = blockedPendingCounts();
restoreOriginalOrder(state); const key = `${channelId}:${agentId}`;
state.currentSpeaker = state.turnOrder[0]; const count = bpc.get(key) ?? 0;
state.noRepliedThisCycle = new Set(); if (count <= 0) return false;
state.lastChangedAt = Date.now(); bpc.set(key, count - 1);
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();
}
/**
* 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)}`,
);
return true; return true;
} }
/** export function resetBlockedPending(channelId: string, agentId: string): void {
* Check if a mention override is currently active. blockedPendingCounts().delete(`${channelId}:${agentId}`);
*/ }
export function hasMentionOverride(channelId: string): boolean {
const state = channelTurns.get(channelId); function getState(channelId: string): ChannelTurnState | undefined {
return !!state?.savedTurnOrder; 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. * Advance the speaker after a turn completes.
* All agents will be routed to no-reply until a human sends a message. * 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 { export async function advanceSpeaker(
const state = channelTurns.get(channelId); channelId: string,
if (!state) return; agentId: string,
state.waitingForHuman = true; isEmpty: boolean,
state.currentSpeaker = null; rebuildFn: () => Promise<SpeakerEntry[]>,
state.noRepliedThisCycle = new Set(); previousLastAgentId?: string,
state.lastChangedAt = Date.now(); ): 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 { export function wakeFromDormant(channelId: string): SpeakerEntry | null {
const state = channelTurns.get(channelId); const s = getState(channelId);
return !!state?.waitingForHuman; 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. * Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
* @returns the new currentSpeaker (or null if dormant)
*/ */
export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null { export function shuffleList(list: SpeakerEntry[], previousLastAgentId?: string): SpeakerEntry[] {
const state = channelTurns.get(channelId); if (list.length <= 1) return list;
if (!state) return null; const arr = [...list];
if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
if (wasNoReply) { [arr[i], arr[j]] = [arr[j], arr[i]];
state.noRepliedThisCycle.add(accountId);
// Check if ALL agents have NO_REPLY'd this cycle
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
if (allNoReplied) {
// If override active, restore original order before going dormant
restoreOriginalOrder(state);
// Go dormant
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return null;
} }
} else { if (previousLastAgentId && arr[0].agentId === previousLastAgentId && arr.length > 1) {
// Successful speech resets the cycle counter const swapIdx = 1 + Math.floor(Math.random() * (arr.length - 1));
state.noRepliedThisCycle = new Set(); [arr[0], arr[swapIdx]] = [arr[swapIdx], arr[0]];
}
return arr;
} }
const prevSpeaker = state.currentSpeaker; export function getDebugInfo(channelId: string) {
const next = advanceTurn(channelId); const s = getState(channelId);
if (!s) return { exists: false };
// 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;
}
/**
* 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 };
return { return {
channelId, exists: true,
hasTurnState: true, speakerList: s.speakerList.map((sp) => sp.agentId),
turnOrder: state.turnOrder, currentIndex: s.currentIndex,
currentSpeaker: state.currentSpeaker, currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null,
noRepliedThisCycle: [...state.noRepliedThisCycle], dormant: s.dormant,
lastChangedAt: state.lastChangedAt, emptyThisCycle: [...s.emptyThisCycle],
dormant: state.currentSpeaker === null, completedThisCycle: [...s.completedThisCycle],
waitingForHuman: state.waitingForHuman,
hasOverride: !!state.savedTurnOrder,
overrideFirstAgent: state.overrideFirstAgent || null,
savedTurnOrder: state.savedTurnOrder || null,
}; };
} }
/** Remove a channel's turn state entirely (e.g. when archived). */
export function clearChannel(channelId: string): void {
channelStates().delete(channelId);
}

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

@@ -0,0 +1,294 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore, ChannelMode } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { fetchAdminGuilds, fetchGuildChannels } from "../core/moderator-discord.js";
import { scanPaddedCell } from "../core/padded-cell.js";
import path from "node:path";
import os from "node:os";
const SWITCHABLE_MODES: ChannelMode[] = ["none", "chat", "report"];
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
function html(strings: TemplateStringsArray, ...values: unknown[]): string {
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
}
function escapeHtml(s: unknown): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function modeBadge(mode: ChannelMode): string {
const colors: Record<ChannelMode, string> = {
none: "#888", chat: "#5865f2", report: "#57f287",
work: "#fee75c", discussion: "#eb459e",
};
return `<span style="background:${colors[mode]};color:#fff;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">${escapeHtml(mode)}</span>`;
}
function buildPage(content: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dirigent</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:24px}
h1{font-size:1.6rem;margin-bottom:4px;color:#fff}
.subtitle{color:#888;font-size:0.85rem;margin-bottom:24px}
h2{font-size:1.1rem;margin:24px 0 12px;color:#ccc;border-bottom:1px solid #333;padding-bottom:8px}
table{width:100%;border-collapse:collapse;margin-bottom:16px;font-size:0.9rem}
th{text-align:left;padding:8px 12px;background:#252540;color:#aaa;font-weight:600;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em}
td{padding:8px 12px;border-top:1px solid #2a2a4a}
tr:hover td{background:#1e1e3a}
input,select{background:#252540;border:1px solid #444;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:0.9rem}
input:focus,select:focus{outline:none;border-color:#5865f2}
button{background:#5865f2;color:#fff;border:none;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}
button:hover{background:#4752c4}
button.danger{background:#ed4245}
button.danger:hover{background:#c03537}
button.secondary{background:#36393f}
button.secondary:hover{background:#2f3136}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.guild-section{background:#16213e;border:1px solid #2a2a4a;border-radius:8px;margin-bottom:16px;overflow:hidden}
.guild-header{padding:12px 16px;background:#1f2d50;display:flex;align-items:center;gap:10px;font-weight:600}
.guild-name{font-size:1rem;color:#fff}
.guild-id{font-size:0.75rem;color:#888;font-family:monospace}
.msg{padding:8px 12px;border-radius:4px;margin:8px 0;font-size:0.85rem}
.msg.ok{background:#1a4a2a;border:1px solid #2d7a3a;color:#57f287}
.msg.err{background:#4a1a1a;border:1px solid #7a2d2d;color:#ed4245}
.spinner{display:none}
</style>
</head>
<body>
<h1>Dirigent</h1>
<p class="subtitle">OpenClaw multi-agent turn management</p>
${content}
<script>
async function apiCall(endpoint, method, body) {
const resp = await fetch(endpoint, {
method: method || 'GET',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
return resp.json();
}
function showMsg(el, text, isErr) {
el.className = 'msg ' + (isErr ? 'err' : 'ok');
el.textContent = text;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
</script>
</body>
</html>`;
}
export function registerControlPage(deps: {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
openclawDir: string;
hasPaddedCell: () => boolean;
}): void {
const { api, channelStore, identityRegistry, moderatorBotToken, openclawDir, hasPaddedCell } = deps;
// ── Main page ──────────────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent",
auth: "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 &amp; Channel Configuration</h2>
<div id="channel-msg" class="msg" style="display:none"></div>
${guildHtml}
<script>
async function addIdentity() {
const discordUserId = document.getElementById('new-discord-id').value.trim();
const agentId = document.getElementById('new-agent-id').value.trim();
const agentName = document.getElementById('new-agent-name').value.trim();
if (!discordUserId || !agentId) return alert('Discord user ID and Agent ID are required');
const r = await apiCall('/dirigent/api/identity', 'POST', { discordUserId, agentId, agentName: agentName || agentId });
showMsg(document.getElementById('identity-msg'), r.ok ? 'Added.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function removeIdentity(agentId) {
if (!confirm('Remove identity for ' + agentId + '?')) return;
const r = await apiCall('/dirigent/api/identity/' + encodeURIComponent(agentId), 'DELETE');
showMsg(document.getElementById('identity-msg'), r.ok ? 'Removed.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function setMode(channelId, mode) {
const r = await apiCall('/dirigent/api/channel-mode', 'POST', { channelId, mode });
showMsg(document.getElementById('channel-msg'), r.ok ? 'Mode updated.' : r.error, !r.ok);
}
async function rescanPaddedCell() {
const r = await apiCall('/dirigent/api/rescan-padded-cell', 'POST');
showMsg(document.getElementById('identity-msg'), r.ok ? ('Scanned: ' + r.count + ' entries.') : r.error, !r.ok);
if (r.ok) location.reload();
}
</script>`;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildPage(content));
},
});
// ── API: add identity ──────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/identity",
auth: "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 }));
},
});
}

View File

@@ -133,21 +133,6 @@ function getJson(pathKey) {
try { return JSON.parse(out); } catch { return undefined; } try { return JSON.parse(out); } catch { return undefined; }
} }
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); } 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) { function syncDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true }); fs.mkdirSync(dest, { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true }); fs.cpSync(src, dest, { recursive: true, force: true });
@@ -187,24 +172,34 @@ if (mode === "install") {
} }
step(4, 7, "configure plugin entry"); step(4, 7, "configure plugin entry");
const plugins = getJson("plugins") || {}; // Plugin load path — safe to read/write (not sensitive)
const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : []; const loadPaths = getJson("plugins.load.paths") || [];
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR); if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) {
plugins.load = plugins.load || {}; plugins.load.paths = loadPaths; loadPaths.push(PLUGIN_INSTALL_DIR);
plugins.entries = plugins.entries || {}; setJson("plugins.load.paths", loadPaths);
const existingDirigent = isPlainObject(plugins.entries.dirigent) ? plugins.entries.dirigent : {}; }
const desired = { // For each config field: only write the default if the field has no value.
enabled: true, // Sensitive fields (e.g. moderatorBotToken) are never touched — user sets them manually.
config: { // `getJson` returns undefined if the field is unset; __OPENCLAW_REDACTED__ counts as "set".
enabled: true, discordOnly: true, listMode: "human-list", function setIfMissing(pathKey, defaultVal) {
humanList: [], agentList: [], const existing = getJson(pathKey);
channelPoliciesFile: path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"), if (existing === undefined || existing === null) setJson(pathKey, defaultVal);
endSymbols: ["🔚"], schedulingIdentifier: "➡️", }
noReplyProvider: NO_REPLY_PROVIDER_ID, noReplyModel: NO_REPLY_MODEL_ID, noReplyPort: NO_REPLY_PORT, setIfMissing("plugins.entries.dirigent.enabled", true);
}, const cp = "plugins.entries.dirigent.config";
}; setIfMissing(`${cp}.enabled`, true);
plugins.entries.dirigent = mergePreservingExisting(existingDirigent, desired); setIfMissing(`${cp}.discordOnly`, true);
setJson("plugins", plugins); 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"); ok("plugin configured");
step(5, 7, "configure no-reply provider"); step(5, 7, "configure no-reply provider");