diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..bab56a6 --- /dev/null +++ b/DESIGN.md @@ -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 +``` + +- 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. diff --git a/plugin/channel-resolver.js b/plugin/channel-resolver.js deleted file mode 100644 index 9445e26..0000000 --- a/plugin/channel-resolver.js +++ /dev/null @@ -1 +0,0 @@ -export * from './channel-resolver.ts'; diff --git a/plugin/channel-resolver.ts b/plugin/channel-resolver.ts deleted file mode 100644 index c795b1b..0000000 --- a/plugin/channel-resolver.ts +++ /dev/null @@ -1,73 +0,0 @@ -export function extractDiscordChannelId(ctx: Record, event?: Record): string | undefined { - const candidates: unknown[] = [ - ctx.conversationId, - ctx.OriginatingTo, - event?.to, - (event?.metadata as Record)?.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 | 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) : undefined; - } catch { - return undefined; - } -} - -export function extractDiscordChannelIdFromConversationMetadata(conv: Record): 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; -} diff --git a/plugin/commands/command-utils.ts b/plugin/commands/command-utils.ts new file mode 100644 index 0000000..657055f --- /dev/null +++ b/plugin/commands/command-utils.ts @@ -0,0 +1,13 @@ +/** Extract Discord channel ID from slash command context. */ +export function parseDiscordChannelIdFromCommand(cmdCtx: Record): 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; +} diff --git a/plugin/commands/dirigent-command.ts b/plugin/commands/dirigent-command.ts deleted file mode 100644 index 856665e..0000000 --- a/plugin/commands/dirigent-command.ts +++ /dev/null @@ -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 }; - 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 \n` + - `/dirigent_policy set \n` + - `/dirigent_policy delete `, - }; - } - - 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 \n" + - "/dirigent_policy set \n" + - "/dirigent_policy delete ", - 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)[channelId]; - return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) }; - } - - if (op === "delete") { - delete (policyState.channelPolicies as Record)[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 ", isError: true }; - } - let parsed: Record; - try { - parsed = JSON.parse(jsonText); - } catch (e) { - return { text: `invalid policy-json: ${String(e)}`, isError: true }; - } - - const next: Record = {}; - 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)[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 }; - }, - }); -} diff --git a/plugin/commands/set-channel-mode-command.ts b/plugin/commands/set-channel-mode-command.ts new file mode 100644 index 0000000..32f8f45 --- /dev/null +++ b/plugin/commands/set-channel-mode-command.ts @@ -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(["none", "chat", "report"]); +const LOCKED_MODES = new Set(["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 \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}".` }; + }, + }); +} diff --git a/plugin/core/channel-members.ts b/plugin/core/channel-members.ts index fab4e50..70bfb19 100644 --- a/plugin/core/channel-members.ts +++ b/plugin/core/channel-members.ts @@ -1,5 +1,5 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { buildUserIdToAccountIdMap } from "./identity.js"; +import type { IdentityRegistry } from "./identity-registry.js"; const PERM_VIEW_CHANNEL = 1n << 10n; const PERM_ADMINISTRATOR = 1n << 3n; @@ -84,7 +84,14 @@ function canViewChannel(member: any, guildId: string, guildRoles: Map) || {}; + const moderatorToken = pluginCfg.moderatorBotToken; + if (typeof moderatorToken === "string" && moderatorToken) { + return moderatorToken; + } + // Fall back to any discord account token const root = (api.config as Record) || {}; const channels = (root.channels as Record) || {}; const discord = (channels.discord as Record) || {}; @@ -95,8 +102,15 @@ function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined { return undefined; } -export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise { - 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 { + const token = getDiscoveryToken(api); if (!token) return []; const ch = await discordRequest(token, "GET", `/channels/${channelId}`); @@ -131,11 +145,13 @@ export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, c .map((m) => String(m?.user?.id || "")) .filter(Boolean); - const userToAccount = buildUserIdToAccountIdMap(api); const out = new Set(); - for (const uid of visibleUserIds) { - const aid = userToAccount.get(uid); - if (aid) out.add(aid); + if (identityRegistry) { + const discordToAgent = identityRegistry.buildDiscordToAgentMap(); + for (const uid of visibleUserIds) { + const aid = discordToAgent.get(uid); + if (aid) out.add(aid); + } } return [...out]; } diff --git a/plugin/core/channel-modes.js b/plugin/core/channel-modes.js deleted file mode 100644 index 92c25d1..0000000 --- a/plugin/core/channel-modes.js +++ /dev/null @@ -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); -} diff --git a/plugin/core/channel-modes.ts b/plugin/core/channel-modes.ts deleted file mode 100644 index 620bc54..0000000 --- a/plugin/core/channel-modes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ChannelRuntimeMode, ChannelRuntimeState } from "../rules.js"; - -export type ChannelMode = ChannelRuntimeMode; -export type ChannelModesState = ChannelRuntimeState; - -const channelStates = new Map(); - -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); -} diff --git a/plugin/core/channel-store.ts b/plugin/core/channel-store.ts new file mode 100644 index 0000000..4dfdaa9 --- /dev/null +++ b/plugin/core/channel-store.ts @@ -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(["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 = {}; + 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 })); + } +} diff --git a/plugin/core/discussion-messages.js b/plugin/core/discussion-messages.js deleted file mode 100644 index 5fa6c5d..0000000 --- a/plugin/core/discussion-messages.js +++ /dev/null @@ -1 +0,0 @@ -export * from './discussion-messages.ts'; diff --git a/plugin/core/discussion-messages.ts b/plugin/core/discussion-messages.ts deleted file mode 100644 index caf161c..0000000 --- a/plugin/core/discussion-messages.ts +++ /dev/null @@ -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); -} diff --git a/plugin/core/discussion-service.ts b/plugin/core/discussion-service.ts deleted file mode 100644 index 8c89f7d..0000000 --- a/plugin/core/discussion-service.ts +++ /dev/null @@ -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 { - 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 { - 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([ - 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 { - 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, - }; -} diff --git a/plugin/core/discussion-state.js b/plugin/core/discussion-state.js deleted file mode 100644 index 046895f..0000000 --- a/plugin/core/discussion-state.js +++ /dev/null @@ -1 +0,0 @@ -export * from './discussion-state.ts'; diff --git a/plugin/core/discussion-state.ts b/plugin/core/discussion-state.ts deleted file mode 100644 index 0df1a91..0000000 --- a/plugin/core/discussion-state.ts +++ /dev/null @@ -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(); - -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; -} diff --git a/plugin/core/identity-registry.ts b/plugin/core/identity-registry.ts new file mode 100644 index 0000000..e57ecc6 --- /dev/null +++ b/plugin/core/identity-registry.ts @@ -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 { + this.load(); + const map = new Map(); + 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 { + this.load(); + const map = new Map(); + for (const e of this.entries) map.set(e.agentId, e.discordUserId); + return map; + } +} diff --git a/plugin/core/identity.ts b/plugin/core/identity.ts deleted file mode 100644 index 6b35d2b..0000000 --- a/plugin/core/identity.ts +++ /dev/null @@ -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) || {}; - const channels = (root.channels as Record) || {}; - const discord = (channels.discord as Record) || {}; - const accounts = (discord.accounts as Record>) || {}; - 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) || {}; - const bindings = root.bindings as Array> | undefined; - if (!Array.isArray(bindings)) return undefined; - for (const b of bindings) { - if (b.agentId === agentId) { - const match = b.match as Record | 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) || {}; - const bindings = root.bindings as Array> | undefined; - const agents = ((root.agents as Record)?.list as Array>) || []; - 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 | undefined; - if (match?.channel === "discord" && typeof match.accountId === "string") { - accountId = match.accountId; - break; - } - } - } - if (!accountId) return undefined; - - const agent = agents.find((a: Record) => 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 { - const root = (api.config as Record) || {}; - const channels = (root.channels as Record) || {}; - const discord = (channels.discord as Record) || {}; - const accounts = (discord.accounts as Record>) || {}; - const map = new Map(); - 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; -} diff --git a/plugin/core/mentions.ts b/plugin/core/mentions.ts index 6fa041e..29159b2 100644 --- a/plugin/core/mentions.ts +++ b/plugin/core/mentions.ts @@ -1,5 +1,3 @@ -import type { DirigentConfig } from "../rules.js"; - function userIdFromToken(token: string): string | undefined { try { const segment = token.split(".")[0]; @@ -25,7 +23,7 @@ export function extractMentionedUserIds(content: string): string[] { return ids; } -export function getModeratorUserId(config: DirigentConfig): string | undefined { - if (!config.moderatorBotToken) return undefined; - return userIdFromToken(config.moderatorBotToken); +export function getModeratorUserIdFromToken(token: string | undefined): string | undefined { + if (!token) return undefined; + return userIdFromToken(token); } diff --git a/plugin/core/moderator-discord.js b/plugin/core/moderator-discord.js deleted file mode 100644 index a0ba77d..0000000 --- a/plugin/core/moderator-discord.js +++ /dev/null @@ -1 +0,0 @@ -export * from './moderator-discord.ts'; diff --git a/plugin/core/moderator-discord.ts b/plugin/core/moderator-discord.ts index 10af355..42ca4d5 100644 --- a/plugin/core/moderator-discord.ts +++ b/plugin/core/moderator-discord.ts @@ -1,5 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +type Logger = { info: (m: string) => void; warn: (m: string) => void }; + function userIdFromToken(token: string): string | undefined { try { const segment = token.split(".")[0]; @@ -28,7 +30,7 @@ export async function sendModeratorMessage( token: string, channelId: string, content: string, - logger: { info: (msg: string) => void; warn: (msg: string) => void }, + logger: Logger, ): Promise { try { const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { @@ -63,3 +65,175 @@ export async function sendModeratorMessage( return { ok: false, channelId, error }; } } + +/** Delete a Discord message. */ +export async function deleteMessage( + token: string, + channelId: string, + messageId: string, + logger: Logger, +): Promise { + 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 { + 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 { + 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 { + 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; + 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> { + 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> { + 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; + } +} diff --git a/plugin/core/padded-cell.ts b/plugin/core/padded-cell.ts new file mode 100644 index 0000000..7eca75f --- /dev/null +++ b/plugin/core/padded-cell.ts @@ -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>; +}; + +/** + * 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; +} diff --git a/plugin/core/session-state.ts b/plugin/core/session-state.ts deleted file mode 100644 index 1a3a028..0000000 --- a/plugin/core/session-state.ts +++ /dev/null @@ -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(); -export const sessionAllowed = new Map(); -export const sessionInjected = new Set(); -export const sessionChannelId = new Map(); -export const sessionAccountId = new Map(); -export const sessionTurnHandled = new Set(); -export const forceNoReplySessions = new Set(); -export const discussionChannelSessions = new Map>(); - -export function recordDiscussionSession(channelId: string, sessionKey: string): void { - if (!channelId || !sessionKey) return; - const current = discussionChannelSessions.get(channelId) || new Set(); - current.add(sessionKey); - discussionChannelSessions.set(channelId, current); -} - -export function getDiscussionSessionKeys(channelId: string): string[] { - return [...(discussionChannelSessions.get(channelId) || new Set())]; -} - -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); - } -} diff --git a/plugin/core/turn-bootstrap.ts b/plugin/core/turn-bootstrap.ts deleted file mode 100644 index 3743882..0000000 --- a/plugin/core/turn-bootstrap.ts +++ /dev/null @@ -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>(); -const channelBootstrapTried = new Set(); -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; - 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) || {}; - const channels = (root.channels as Record) || {}; - const discord = (channels.discord as Record) || {}; - const accounts = (discord.accounts as Record>) || {}; - for (const rec of Object.values(accounts)) { - const chMap = (rec?.channels as Record> | undefined) || undefined; - if (!chMap) continue; - const direct = chMap[channelId]; - const prefixed = chMap[`channel:${channelId}`]; - const found = (direct || prefixed) as Record | 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 = {}; - 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) || {}; - const bindings = root.bindings as Array> | undefined; - if (!Array.isArray(bindings)) return []; - const ids: string[] = []; - for (const b of bindings) { - const match = b.match as Record | 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 { - 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); - } -} diff --git a/plugin/core/utils.ts b/plugin/core/utils.ts deleted file mode 100644 index a38760a..0000000 --- a/plugin/core/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -type DebugConfig = { - enableDebugLogs?: boolean; - debugLogChannelIds?: string[]; -}; - -export function pickDefined(input: Record): Record { - const out: Record = {}; - 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, event: Record) { - const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; - 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, - }; -} diff --git a/plugin/decision-input.ts b/plugin/decision-input.ts deleted file mode 100644 index dc176e5..0000000 --- a/plugin/decision-input.ts +++ /dev/null @@ -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; -}; - -export function deriveDecisionInputFromPrompt(params: { - prompt: string; - messageProvider?: string; - sessionKey?: string; - ctx?: Record; - event?: Record; -}): 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 }; -} diff --git a/plugin/hooks/agent-end.ts b/plugin/hooks/agent-end.ts new file mode 100644 index 0000000..48e0d22 --- /dev/null +++ b/plugin/hooks/agent-end.ts @@ -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)[_AGENT_END_DEDUP_KEY]) { + (globalThis as Record)[_AGENT_END_DEDUP_KEY] = new Set(); +} +const processedAgentEndRunIds: Set = (globalThis as Record)[_AGENT_END_DEDUP_KEY] as Set; + +/** 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 | 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; + 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; +}; + +/** 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(); + + function interrupt(channelId: string): void { + interruptedChannels.add(channelId); + setTimeout(() => interruptedChannels.delete(channelId), 5000); + } + + async function buildSpeakerList(channelId: string): Promise { + 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 { + 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).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).messages) + ? ((event as Record).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; +} diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts deleted file mode 100644 index 810ffe1..0000000 --- a/plugin/hooks/before-message-write.ts +++ /dev/null @@ -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 }; - sessionAllowed: Map; - sessionChannelId: Map; - sessionAccountId: Map; - sessionTurnHandled: Set; - ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; - shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; - ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | 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; - discussionService?: { - maybeSendIdleReminder: (channelId: string) => Promise; - 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).message as Record | 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[]).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).text === "string") { - content += (part as Record).text; - } - } - } - } - if (!content) { - content = ((event as Record).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); - - 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)}`); - } - }); -} diff --git a/plugin/hooks/before-model-resolve.ts b/plugin/hooks/before-model-resolve.ts index cda943a..aff57a2 100644 --- a/plugin/hooks/before-model-resolve.ts +++ b/plugin/hooks/before-model-resolve.ts @@ -1,176 +1,141 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js"; -import { checkTurn } from "../turn-manager.js"; -import { deriveDecisionInputFromPrompt } from "../decision-input.js"; +import type { ChannelStore } from "../core/channel-store.js"; +import type { IdentityRegistry } from "../core/identity-registry.js"; +import { isCurrentSpeaker, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js"; +import { getLatestMessageId, sendAndDelete } from "../core/moderator-discord.js"; +import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js"; -type DebugConfig = { - enableDebugLogs?: boolean; - debugLogChannelIds?: string[]; -}; +/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */ +export function parseDiscordChannelId(sessionKey: string): string | undefined { + const m = sessionKey.match(/:discord:channel:(\d+)$/); + return m?.[1]; +} -type DecisionRecord = { - decision: Decision; - createdAt: number; - needsRestore?: boolean; -}; - -type BeforeModelResolveDeps = { +type Deps = { api: OpenClawPluginApi; - baseConfig: DirigentConfig; - sessionDecision: Map; - sessionAllowed: Map; - sessionChannelId: Map; - sessionAccountId: Map; - recordDiscussionSession?: (channelId: string, sessionKey: string) => void; - forceNoReplySessions: Set; - policyState: { channelPolicies: Record }; - 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; - isMultiMessageMode: (channelId: string) => boolean; - discussionService?: { - isClosedDiscussion: (channelId: string) => boolean; - }; + channelStore: ChannelStore; + identityRegistry: IdentityRegistry; + moderatorBotToken: string | undefined; + noReplyModel: string; + noReplyProvider: string; + scheduleIdentifier: string; }; -export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void { - const { - api, - baseConfig, - sessionDecision, - sessionAllowed, - sessionChannelId, - sessionAccountId, - recordDiscussionSession, - forceNoReplySessions, - policyState, - DECISION_TTL_MS, - ensurePolicyStateLoaded, - resolveAccountId, - pruneDecisionMap, - shouldDebugLog, - ensureTurnOrder, - isMultiMessageMode, - discussionService, - } = deps; +/** + * Process-level deduplication for before_model_resolve events. + * Uses a WeakSet keyed on the event object — works when OpenClaw passes + * the same event reference to all stacked handlers (hot-reload scenario). + * Stored on globalThis so it persists across module reloads. + */ +const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents"; +if (!(globalThis as Record)[_BMR_DEDUP_KEY]) { + (globalThis as Record)[_BMR_DEDUP_KEY] = new WeakSet(); +} +const processedBeforeModelResolveEvents: WeakSet = (globalThis as Record)[_BMR_DEDUP_KEY] as WeakSet; + +export function registerBeforeModelResolveHook(deps: Deps): void { + const { api, channelStore, identityRegistry, moderatorBotToken, noReplyModel, noReplyProvider, scheduleIdentifier } = deps; + + const NO_REPLY = { model: noReplyModel, provider: noReplyProvider, noReply: true } as const; + + /** Shared init lock — see turn-manager.ts getInitializingChannels(). */ + const initializingChannels = getInitializingChannels(); api.on("before_model_resolve", async (event, ctx) => { - const key = ctx.sessionKey; - if (!key) return; + // Deduplicate: if another handler instance already processed this event + // object, skip. Prevents double-counting from hot-reload stacked handlers. + const eventObj = event as object; + if (processedBeforeModelResolveEvents.has(eventObj)) return; + processedBeforeModelResolveEvents.add(eventObj); - const live = baseConfig as DirigentConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); + const sessionKey = ctx.sessionKey; + if (!sessionKey) return; - if (forceNoReplySessions.has(key)) { - return { - model: ctx.model, - provider: ctx.provider, - noReply: true, - }; - } + // Only handle Discord group channel sessions + const channelId = parseDiscordChannelId(sessionKey); + if (!channelId) return; - const prompt = ((event as Record).prompt as string) || ""; + const mode = channelStore.getMode(channelId); - if (live.enableDebugLogs) { - api.logger.info( - `dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + - `promptPreview=${prompt.slice(0, 300)}`, - ); - } + // dead mode: suppress all responses + if (mode === "report" || mode === "dead" as string) return NO_REPLY; - const derived = deriveDecisionInputFromPrompt({ - prompt, - messageProvider: ctx.messageProvider, - sessionKey: key, - ctx: ctx as Record, - event: event as Record, - }); + // disabled modes: let agents respond freely + if (mode === "none" || mode === "work") return; - const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); - if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; + // discussion / chat: check turn + const agentId = ctx.agentId; + if (!agentId) return; - if (derived.channelId) { - sessionChannelId.set(key, derived.channelId); - recordDiscussionSession?.(derived.channelId, key); - if (discussionService?.isClosedDiscussion(derived.channelId)) { - sessionAllowed.set(key, false); - api.logger.info(`dirigent: before_model_resolve forcing no-reply for closed discussion channel=${derived.channelId} session=${key}`); - return { - model: ctx.model, - provider: ctx.provider, - noReply: true, - }; + // If 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; } - - if (isMultiMessageMode(derived.channelId)) { - sessionAllowed.set(key, false); - api.logger.info(`dirigent: before_model_resolve forcing no-reply for multi-message mode channel=${derived.channelId} session=${key}`); - return { - model: ctx.model, - provider: ctx.provider, - noReply: true, - }; - } - } - const resolvedAccountId = resolveAccountId(api, ctx.agentId || ""); - if (resolvedAccountId) { - sessionAccountId.set(key, resolvedAccountId); - } + 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); - 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, - 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).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + - `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, - ); - } - } + 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 (derived.channelId) { - await ensureTurnOrder(api, derived.channelId); - const accountId = resolveAccountId(api, ctx.agentId || ""); - if (accountId) { - const turnCheck = checkTurn(derived.channelId, accountId); - if (!turnCheck.allowed) { - sessionAllowed.set(key, false); - api.logger.info( - `dirigent: before_model_resolve blocking out-of-turn speaker session=${key} channel=${derived.channelId} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker}`, - ); - return { - model: ctx.model, - provider: ctx.provider, - noReply: true, - }; + // If this agent is NOT the first speaker, trigger first speaker and suppress this one + if (first.agentId !== agentId && moderatorBotToken) { + await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger); + return NO_REPLY; + } + // If this agent IS the first speaker, fall through to normal turn logic + } else { + // No registered agents visible — let everyone respond freely + return; } - sessionAllowed.set(key, true); + } catch (err) { + api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`); + return; + } finally { + initializingChannels.delete(channelId); } } - if (!rec.decision.shouldUseNoReply) return; + // If channel is dormant: suppress all agents + if (isDormant(channelId)) return NO_REPLY; - const out: Record = { noReply: true }; - if (rec.decision.provider) out.provider = rec.decision.provider; - if (rec.decision.model) out.model = rec.decision.model; - return out; + if (!isCurrentSpeaker(channelId, agentId)) { + api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`); + incrementBlockedPending(channelId, agentId); + return NO_REPLY; + } + + // Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions) + markTurnStarted(channelId, agentId); + + // Current speaker: record anchor message ID for tail-match polling + if (moderatorBotToken) { + try { + const anchorId = await getLatestMessageId(moderatorBotToken, channelId); + if (anchorId) { + setAnchor(channelId, agentId, anchorId); + api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`); + } + } catch (err) { + api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`); + } + } + + // Verify agent has a known Discord user ID (needed for tail-match later) + const identity = identityRegistry.findByAgentId(agentId); + if (!identity) { + api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`); + } }); } diff --git a/plugin/hooks/before-prompt-build.ts b/plugin/hooks/before-prompt-build.ts deleted file mode 100644 index 3f75a83..0000000 --- a/plugin/hooks/before-prompt-build.ts +++ /dev/null @@ -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; - sessionInjected: Set; - policyState: { channelPolicies: Record }; - 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).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt({ - prompt, - messageProvider: ctx.messageProvider, - sessionKey: key, - ctx: ctx as Record, - event: event as Record, - }); - - const decision = evaluateDecision({ - config: live, - channel: derived.channel, - channelId: derived.channelId, - channelPolicies: policyState.channelPolicies as Record, - 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).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `convChannelId=${String((derived.conv as Record).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).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt({ - prompt, - messageProvider: ctx.messageProvider, - sessionKey: key, - ctx: ctx as Record, - event: event as Record, - }); - const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record); - 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).prompt = `${prompt}\n\n${instruction}${identity}${schedulingInstruction}`; - sessionInjected.add(key); - }); -} diff --git a/plugin/hooks/message-received.ts b/plugin/hooks/message-received.ts index fb0184f..007b1db 100644 --- a/plugin/hooks/message-received.ts +++ b/plugin/hooks/message-received.ts @@ -1,141 +1,128 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js"; -import { extractDiscordChannelId } from "../channel-resolver.js"; -import { isDiscussionOriginCallbackMessage } from "../core/discussion-messages.js"; -import type { DirigentConfig } from "../rules.js"; +import type { ChannelStore } from "../core/channel-store.js"; +import type { IdentityRegistry } from "../core/identity-registry.js"; +import { parseDiscordChannelId } from "./before-model-resolve.js"; +import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js"; +import { sendAndDelete, sendModeratorMessage } from "../core/moderator-discord.js"; +import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js"; +import type { InterruptFn } from "./agent-end.js"; -type DebugConfig = { - enableDebugLogs?: boolean; - debugLogChannelIds?: string[]; -}; - -type MessageReceivedDeps = { +type Deps = { api: OpenClawPluginApi; - baseConfig: DirigentConfig; - shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; - debugCtxSummary: (ctx: Record, event: Record) => Record; - ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | void; - getModeratorUserId: (cfg: DirigentConfig) => string | undefined; - recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean; - extractMentionedUserIds: (content: string) => string[]; - buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map; - enterMultiMessageMode: (channelId: string) => void; - exitMultiMessageMode: (channelId: string) => void; - discussionService?: { - maybeReplyClosedChannel: (channelId: string, senderId?: string) => Promise; - }; + channelStore: ChannelStore; + identityRegistry: IdentityRegistry; + moderatorBotToken: string | undefined; + scheduleIdentifier: string; + interruptTailMatch: InterruptFn; }; -export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { - const { - api, - baseConfig, - shouldDebugLog, - debugCtxSummary, - ensureTurnOrder, - getModeratorUserId, - recordChannelAccount, - extractMentionedUserIds, - buildUserIdToAccountIdMap, - enterMultiMessageMode, - exitMultiMessageMode, - discussionService, - } = deps; +export function registerMessageReceivedHook(deps: Deps): void { + const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps; api.on("message_received", async (event, ctx) => { try { - const c = (ctx || {}) as Record; - const e = (event || {}) as Record; - const preChannelId = extractDiscordChannelId(c, e); - const livePre = baseConfig as DirigentConfig & DebugConfig; - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); + const e = event as Record; + const c = ctx as Record; + + // Extract Discord channel ID from session key or event metadata + let channelId: string | undefined; + if (typeof c.sessionKey === "string") { + channelId = parseDiscordChannelId(c.sessionKey); + } + if (!channelId) { + // Try from event metadata (conversation_info channel_id field) + const metadata = e.metadata as Record | undefined; + const convInfo = metadata?.conversation_info as Record | undefined; + const raw = String(convInfo?.channel_id ?? metadata?.channelId ?? ""); + if (/^\d+$/.test(raw)) channelId = raw; + } + if (!channelId) return; + + const mode = channelStore.getMode(channelId); + + // dead: suppress routing entirely (OpenClaw handles no-route automatically, + // but we handle archived auto-reply here) + if (mode === "report") return; + + // archived: auto-reply via moderator + if (mode === "discussion") { + const rec = channelStore.getRecord(channelId); + if (rec.discussion?.concluded && moderatorBotToken) { + await sendModeratorMessage( + moderatorBotToken, channelId, + "This discussion is closed and no longer active.", + api.logger, + ).catch(() => undefined); + return; + } } - if (preChannelId) { - await ensureTurnOrder(api, preChannelId); - const metadata = (e as Record).metadata as Record | undefined; - const from = - (typeof metadata?.senderId === "string" && metadata.senderId) || - (typeof (e as Record).from === "string" ? ((e as Record).from as string) : ""); + if (mode === "none" || mode === "work") return; - const moderatorUserId = getModeratorUserId(livePre); - if (discussionService) { - const closedHandled = await discussionService.maybeReplyClosedChannel(preChannelId, from); - if (closedHandled) return; + // chat / discussion (active): initialize speaker list on first message if needed + const initializingChannels = getInitializingChannels(); + if (!hasSpeakers(channelId) && moderatorBotToken) { + // Guard against concurrent initialization from multiple VM contexts + if (initializingChannels.has(channelId)) { + api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`); + return; } + initializingChannels.add(channelId); + try { + const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry); + const speakers: SpeakerEntry[] = agentIds + .map((aid) => { + const entry = identityRegistry.findByAgentId(aid); + return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null; + }) + .filter((s): s is SpeakerEntry => s !== null); - const messageContent = ((e as Record).content as string) || ((e as Record).text as string) || ""; - const isModeratorOriginCallback = !!(moderatorUserId && from === moderatorUserId && isDiscussionOriginCallbackMessage(messageContent)); - - if (moderatorUserId && from === moderatorUserId && !isModeratorOriginCallback) { - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`); + if (speakers.length > 0) { + setSpeakerList(channelId, speakers); + const first = speakers[0]; + api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`); + await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger); + return; } - } else { - const humanList = livePre.humanList || livePre.bypassUserIds || []; - const isHuman = humanList.includes(from); - const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; + } finally { + initializingChannels.delete(channelId); + } + } - if (senderAccountId && senderAccountId !== "default") { - const isNew = recordChannelAccount(api, preChannelId, senderAccountId); - if (isNew) { - await ensureTurnOrder(api, preChannelId); - api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); - } - } + // chat / discussion (active): check if this is an external message + // that should interrupt an in-progress tail-match or wake dormant - if (isHuman) { - const startMarker = livePre.multiMessageStartMarker || "↗️"; - const endMarker = livePre.multiMessageEndMarker || "↙️"; + const senderId = String( + (e.metadata as Record)?.senderId ?? + (e.metadata as Record)?.sender_id ?? + e.from ?? "", + ); - if (messageContent.includes(startMarker)) { - enterMultiMessageMode(preChannelId); - api.logger.info(`dirigent: entered multi-message mode channel=${preChannelId}`); - } else if (messageContent.includes(endMarker)) { - exitMultiMessageMode(preChannelId); - api.logger.info(`dirigent: exited multi-message mode channel=${preChannelId}`); - onNewMessage(preChannelId, senderAccountId, isHuman); - } else { - const mentionedUserIds = extractMentionedUserIds(messageContent); + // Identify the sender: is it the current speaker's Discord account? + const currentSpeakerIsThisSender = (() => { + if (!senderId) return false; + const entry = identityRegistry.findByDiscordUserId(senderId); + if (!entry) return false; + return isCurrentSpeaker(channelId!, entry.agentId); + })(); - if (mentionedUserIds.length > 0) { - const userIdMap = buildUserIdToAccountIdMap(api); - const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid); + 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}`); - if (mentionedAccountIds.length > 0) { - await ensureTurnOrder(api, preChannelId); - const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds); - if (overrideSet) { - api.logger.info( - `dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`, - ); - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`); - } - } else { - onNewMessage(preChannelId, senderAccountId, isHuman); - } - } else { - onNewMessage(preChannelId, senderAccountId, isHuman); - } - } else { - onNewMessage(preChannelId, senderAccountId, isHuman); - } - } - } else { - onNewMessage(preChannelId, senderAccountId, false); - } - - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info( - `dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`, - ); + // Wake from dormant if needed + if (isDormant(channelId) && moderatorBotToken) { + const first = wakeFromDormant(channelId); + if (first) { + const msg = `<@${first.discordUserId}>${scheduleIdentifier}`; + await sendAndDelete(moderatorBotToken, channelId, msg, api.logger); + api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`); } } } } catch (err) { - api.logger.warn(`dirigent: message hook failed: ${String(err)}`); + api.logger.warn(`dirigent: message_received hook error: ${String(err)}`); } }); } diff --git a/plugin/hooks/message-sent.ts b/plugin/hooks/message-sent.ts deleted file mode 100644 index 3980f47..0000000 --- a/plugin/hooks/message-sent.ts +++ /dev/null @@ -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 }; - sessionChannelId: Map; - sessionAccountId: Map; - sessionTurnHandled: Set; - 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; - 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; - const e = (event || {}) as Record; - - 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); - - 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)}`); - } - }); -} diff --git a/plugin/index.ts b/plugin/index.ts index 0146e9a..d8a8295 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,240 +1,213 @@ -import fs from "node:fs"; import path from "node:path"; +import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import type { DirigentConfig } from "./rules.js"; -import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; -import { registerMessageReceivedHook } from "./hooks/message-received.js"; -import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js"; -import { registerBeforePromptBuildHook } from "./hooks/before-prompt-build.js"; -import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js"; -import { registerMessageSentHook } from "./hooks/message-sent.js"; -import { registerDirigentCommand } from "./commands/dirigent-command.js"; -import { registerAddGuildCommand } from "./commands/add-guild-command.js"; -import { registerDirigentTools } from "./tools/register-tools.js"; -import { ensurePolicyStateLoaded, persistPolicies, policyState } from "./policy/store.js"; -import { buildAgentIdentity, buildUserIdToAccountIdMap, resolveAccountId } from "./core/identity.js"; -import { extractMentionedUserIds, getModeratorUserId } from "./core/mentions.js"; -import { ensureTurnOrder, recordChannelAccount } from "./core/turn-bootstrap.js"; -import { debugCtxSummary, pickDefined, shouldDebugLog } from "./core/utils.js"; -import { resolveDiscordUserId, sendModeratorMessage } from "./core/moderator-discord.js"; +import { IdentityRegistry } from "./core/identity-registry.js"; +import { ChannelStore } from "./core/channel-store.js"; +import { scanPaddedCell } from "./core/padded-cell.js"; import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js"; -import { createDiscussionService } from "./core/discussion-service.js"; -import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode } from "./core/channel-modes.js"; -import { - DECISION_TTL_MS, - forceNoReplySessions, - getDiscussionSessionKeys, - pruneDecisionMap, - recordDiscussionSession, - sessionAccountId, - sessionAllowed, - sessionChannelId, - sessionDecision, - sessionInjected, - sessionTurnHandled, -} from "./core/session-state.js"; +import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; +import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js"; +import { registerAgentEndHook } from "./hooks/agent-end.js"; +import { registerMessageReceivedHook } from "./hooks/message-received.js"; +import { registerDirigentTools } from "./tools/register-tools.js"; +import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js"; +import { registerAddGuildCommand } from "./commands/add-guild-command.js"; +import { registerControlPage } from "./web/control-page.js"; +import { sendModeratorMessage, sendAndDelete } from "./core/moderator-discord.js"; +import { setSpeakerList } from "./turn-manager.js"; +import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js"; -type DebugConfig = { - enableDebugLogs?: boolean; - debugLogChannelIds?: string[]; +type PluginConfig = { + moderatorBotToken?: string; + noReplyProvider?: string; + noReplyModel?: string; + noReplyPort?: number; + scheduleIdentifier?: string; + identityFilePath?: string; + channelStoreFilePath?: string; }; -type NormalizedDirigentConfig = DirigentConfig & { - enableDiscordControlTool: boolean; - enableDirigentPolicyTool: boolean; -}; - -function normalizePluginConfig(api: OpenClawPluginApi): NormalizedDirigentConfig { +function normalizeConfig(api: OpenClawPluginApi): Required { + const cfg = (api.pluginConfig ?? {}) as PluginConfig; return { - enableDiscordControlTool: true, - enableDirigentPolicyTool: true, - enableDebugLogs: false, - debugLogChannelIds: [], - noReplyPort: 8787, - schedulingIdentifier: "➡️", - waitIdentifier: "👤", - multiMessageStartMarker: "↗️", - multiMessageEndMarker: "↙️", - multiMessagePromptMarker: "⤵️", - ...(api.pluginConfig || {}), - } as NormalizedDirigentConfig; + moderatorBotToken: cfg.moderatorBotToken ?? "", + noReplyProvider: cfg.noReplyProvider ?? "dirigent", + noReplyModel: cfg.noReplyModel ?? "no-reply", + noReplyPort: Number(cfg.noReplyPort ?? 8787), + scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️", + identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"), + channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"), + }; } -function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string { - const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; - let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK, NO, or an empty response) must NOT include ${symbols}.`; - if (isGroupChat) { - instruction += `\n\nGroup chat rules: If this message is not relevant to you, does not need your response, or you have nothing valuable to add, reply with NO_REPLY. Do not speak just for the sake of speaking.`; - instruction += `\n\nWait for human reply: If you need a human to respond to your message, end with ${waitIdentifier} instead of ${symbols}. This pauses all agents until a human speaks. Use this sparingly — only when you are confident the human is actively participating in the discussion (has sent a message recently). Do NOT use it speculatively.`; - } - return instruction; +/** + * Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once + * when the gateway process starts/stops, not per agent session. We guard these on + * globalThis so only the first register() call adds the lifecycle handlers. + * + * Agent-session events (before_model_resolve, agent_end, message_received) are + * delivered via the api instance that belongs to each individual agent session. + * OpenClaw creates a new VM context (and calls register() again) for each hot-reload + * within a session. We register those handlers unconditionally — event-level dedup + * (WeakSet / runId Set, also stored on globalThis) prevents double-processing. + * + * All VM contexts share the real globalThis because they run in the same Node.js + * process as openclaw-gateway. + */ +const _G = globalThis as Record; +const _GATEWAY_LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered"; + +function isGatewayLifecycleRegistered(): boolean { + return !!_G[_GATEWAY_LIFECYCLE_KEY]; } -function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): string { - return `\n\nScheduling identifier: "${schedulingIdentifier}". This identifier itself is meaningless — it carries no semantic content. When you receive a message containing <@YOUR_USER_ID> followed by the scheduling identifier, check recent chat history and decide whether you have something to say. If not, reply NO_REPLY.`; +function markGatewayLifecycleRegistered(): void { + _G[_GATEWAY_LIFECYCLE_KEY] = true; } export default { id: "dirigent", name: "Dirigent", register(api: OpenClawPluginApi) { - const baseConfig = normalizePluginConfig(api); - ensurePolicyStateLoaded(api, baseConfig); - - // Resolve plugin directory for locating sibling modules (no-reply-api/) - // Note: api.resolvePath(".") returns cwd, not script directory. Use import.meta.url instead. + const config = normalizeConfig(api); const pluginDir = path.dirname(new URL(import.meta.url).pathname); - api.logger.info(`dirigent: pluginDir resolved from import.meta.url: ${pluginDir}`); + const openclawDir = path.join(os.homedir(), ".openclaw"); - // Gateway lifecycle: start/stop no-reply API and moderator bot with the gateway - api.on("gateway_start", () => { - api.logger.info(`dirigent: gateway_start event received`); + const identityRegistry = new IdentityRegistry(config.identityFilePath); + const channelStore = new ChannelStore(config.channelStoreFilePath); - const live = normalizePluginConfig(api); + let paddedCellDetected = false; - // Check no-reply-api server file exists - const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs"); - api.logger.info(`dirigent: checking no-reply-api server at ${serverPath}, exists=${fs.existsSync(serverPath)}`); + function hasPaddedCell(): boolean { + return paddedCellDetected; + } - // Additional debug: list what's in the plugin directory - try { - const entries = fs.readdirSync(pluginDir); - api.logger.info(`dirigent: plugin dir (${pluginDir}) entries: ${JSON.stringify(entries)}`); - } catch (e) { - api.logger.warn(`dirigent: cannot read plugin dir: ${String(e)}`); + function tryAutoScanPaddedCell(): void { + const count = scanPaddedCell(identityRegistry, openclawDir, api.logger); + paddedCellDetected = count >= 0; + if (paddedCellDetected) { + api.logger.info(`dirigent: padded-cell detected — ${count} identity entries auto-registered`); } + } - startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787)); - api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`); + // ── Gateway lifecycle (once per gateway process) ─────────────────────── + if (!isGatewayLifecycleRegistered()) { + markGatewayLifecycleRegistered(); - if (live.moderatorBotToken) { - api.logger.info("dirigent: starting moderator bot presence..."); - startModeratorPresence(live.moderatorBotToken, api.logger); - api.logger.info("dirigent: moderator bot presence started"); - } else { - api.logger.info("dirigent: moderator bot not starting - no moderatorBotToken in config"); - } - }); + api.on("gateway_start", () => { + const live = normalizeConfig(api); - api.on("gateway_stop", () => { - stopNoReplyApi(api.logger); - stopModeratorPresence(); - api.logger.info("dirigent: gateway stopping, services shut down"); - }); + startNoReplyApi(api.logger, pluginDir, live.noReplyPort); - const discussionService = createDiscussionService({ + if (live.moderatorBotToken) { + startModeratorPresence(live.moderatorBotToken, api.logger); + api.logger.info("dirigent: moderator bot presence started"); + } else { + api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled"); + } + + tryAutoScanPaddedCell(); + }); + + api.on("gateway_stop", () => { + stopNoReplyApi(api.logger); + stopModeratorPresence(); + }); + } + + // ── Hooks (registered on every api instance — event-level dedup handles duplicates) ── + registerBeforeModelResolveHook({ api, - moderatorBotToken: baseConfig.moderatorBotToken, - moderatorUserId: getModeratorUserId(baseConfig), - workspaceRoot: process.cwd(), - forceNoReplyForSession: (sessionKey: string) => { - if (sessionKey) forceNoReplySessions.add(sessionKey); + channelStore, + identityRegistry, + moderatorBotToken: config.moderatorBotToken || undefined, + noReplyModel: config.noReplyModel, + noReplyProvider: config.noReplyProvider, + scheduleIdentifier: config.scheduleIdentifier, + }); + + const interruptTailMatch = registerAgentEndHook({ + api, + channelStore, + identityRegistry, + moderatorBotToken: config.moderatorBotToken || undefined, + scheduleIdentifier: config.scheduleIdentifier, + onDiscussionDormant: async (channelId: string) => { + const live = normalizeConfig(api); + if (!live.moderatorBotToken) return; + const rec = channelStore.getRecord(channelId); + if (!rec.discussion || rec.discussion.concluded) return; + const initiatorEntry = identityRegistry.findByAgentId(rec.discussion.initiatorAgentId); + const mention = initiatorEntry ? `<@${initiatorEntry.discordUserId}>` : rec.discussion.initiatorAgentId; + await sendModeratorMessage( + live.moderatorBotToken, + channelId, + `${mention} Discussion is idle. Please summarize the results and call \`discussion-complete\`.`, + api.logger, + ).catch(() => undefined); }, - getDiscussionSessionKeys, }); - // Register tools - registerDirigentTools({ - api, - baseConfig, - pickDefined, - discussionService, - }); - - // Turn management is handled internally by the plugin (not exposed as tools). - // Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control. - registerMessageReceivedHook({ api, - baseConfig, - shouldDebugLog, - debugCtxSummary, - ensureTurnOrder, - getModeratorUserId, - recordChannelAccount, - extractMentionedUserIds, - buildUserIdToAccountIdMap, - enterMultiMessageMode, - exitMultiMessageMode, - discussionService, + channelStore, + identityRegistry, + moderatorBotToken: config.moderatorBotToken || undefined, + scheduleIdentifier: config.scheduleIdentifier, + interruptTailMatch, }); - registerBeforeModelResolveHook({ + // ── Tools ────────────────────────────────────────────────────────────── + registerDirigentTools({ api, - baseConfig, - sessionDecision, - sessionAllowed, - sessionChannelId, - sessionAccountId, - recordDiscussionSession, - forceNoReplySessions, - policyState, - DECISION_TTL_MS, - ensurePolicyStateLoaded, - resolveAccountId, - pruneDecisionMap, - shouldDebugLog, - ensureTurnOrder, - isMultiMessageMode, - discussionService, + channelStore, + identityRegistry, + moderatorBotToken: config.moderatorBotToken || undefined, + scheduleIdentifier: config.scheduleIdentifier, + onDiscussionCreate: async ({ channelId, guildId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => { + const live = normalizeConfig(api); + if (!live.moderatorBotToken) return; + + // Post discussion-guide to wake participants + await sendModeratorMessage(live.moderatorBotToken, channelId, discussionGuide, api.logger) + .catch(() => undefined); + + // Initialize speaker list + const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry); + const speakers = agentIds + .map((aid) => { + const entry = identityRegistry.findByAgentId(aid); + return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null; + }) + .filter((s): s is NonNullable => s !== null); + + if (speakers.length > 0) { + setSpeakerList(channelId, speakers); + const first = speakers[0]; + await sendAndDelete( + live.moderatorBotToken, + channelId, + `<@${first.discordUserId}>${live.scheduleIdentifier}`, + api.logger, + ).catch(() => undefined); + } + }, }); - registerBeforePromptBuildHook({ - api, - baseConfig, - sessionDecision, - sessionInjected, - policyState, - DECISION_TTL_MS, - ensurePolicyStateLoaded, - shouldDebugLog, - buildEndMarkerInstruction, - buildSchedulingIdentifierInstruction, - buildAgentIdentity, - }); - - // Register slash commands for Discord - registerDirigentCommand({ - api, - baseConfig, - policyState, - persistPolicies, - ensurePolicyStateLoaded, - }); - - // Register add-guild command + // ── Commands ─────────────────────────────────────────────────────────── + registerSetChannelModeCommand({ api, channelStore }); registerAddGuildCommand(api); - // Handle NO_REPLY detection before message write - registerBeforeMessageWriteHook({ + // ── Control page ─────────────────────────────────────────────────────── + registerControlPage({ api, - baseConfig, - policyState, - sessionAllowed, - sessionChannelId, - sessionAccountId, - sessionTurnHandled, - ensurePolicyStateLoaded, - shouldDebugLog, - ensureTurnOrder, - resolveDiscordUserId, - isMultiMessageMode, - sendModeratorMessage, - discussionService, + channelStore, + identityRegistry, + moderatorBotToken: config.moderatorBotToken || undefined, + openclawDir, + hasPaddedCell, }); - // Turn advance: when an agent sends a message, check if it signals end of turn - registerMessageSentHook({ - api, - baseConfig, - policyState, - sessionChannelId, - sessionAccountId, - sessionTurnHandled, - ensurePolicyStateLoaded, - resolveDiscordUserId, - sendModeratorMessage, - discussionService, - }); + api.logger.info("dirigent: plugin registered (v2)"); }, }; diff --git a/plugin/policy/store.ts b/plugin/policy/store.ts deleted file mode 100644 index 1b4479e..0000000 --- a/plugin/policy/store.ts +++ /dev/null @@ -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; -}; - -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; - 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}`); -} diff --git a/plugin/rules.js b/plugin/rules.js deleted file mode 100644 index c7e15e6..0000000 --- a/plugin/rules.js +++ /dev/null @@ -1 +0,0 @@ -export * from './rules.ts'; diff --git a/plugin/rules.ts b/plugin/rules.ts deleted file mode 100644 index 06c5036..0000000 --- a/plugin/rules.ts +++ /dev/null @@ -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) { - 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; - 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" }; -} diff --git a/plugin/tools/register-tools.ts b/plugin/tools/register-tools.ts index 4730bde..3dc1843 100644 --- a/plugin/tools/register-tools.ts +++ b/plugin/tools/register-tools.ts @@ -1,233 +1,349 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import type { DirigentConfig } from "../rules.js"; - -type DiscordControlAction = "channel-private-create" | "channel-private-update"; +import type { ChannelStore } from "../core/channel-store.js"; +import type { IdentityRegistry } from "../core/identity-registry.js"; +import { createDiscordChannel, getBotUserIdFromToken } from "../core/moderator-discord.js"; +import { setSpeakerList } from "../turn-manager.js"; type ToolDeps = { api: OpenClawPluginApi; - baseConfig: DirigentConfig; - pickDefined: (obj: Record) => Record; - discussionService?: { - initDiscussion: (params: { - discussionChannelId: string; - originChannelId: string; - initiatorAgentId: string; - initiatorSessionId: string; - initiatorWorkspaceRoot?: string; - discussGuide: string; - }) => Promise; - handleCallback: (params: { - channelId: string; - summaryPath: string; - callerAgentId?: string; - callerSessionKey?: string; - }) => Promise; - }; + channelStore: ChannelStore; + identityRegistry: IdentityRegistry; + moderatorBotToken: string | undefined; + scheduleIdentifier: string; + /** Called by create-discussion-channel to initialize the discussion. */ + onDiscussionCreate?: (params: { + channelId: string; + guildId: string; + initiatorAgentId: string; + callbackGuildId: string; + callbackChannelId: string; + discussionGuide: string; + participants: string[]; + }) => Promise; }; -function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null { - const root = (api.config as Record) || {}; - const channels = (root.channels as Record) || {}; - const discord = (channels.discord as Record) || {}; - const accounts = (discord.accounts as Record>) || {}; - - if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") { - return { accountId, token: accounts[accountId].token as string }; - } - for (const [aid, rec] of Object.entries(accounts)) { - if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token }; - } - return null; +function getGuildIdFromSessionKey(sessionKey: string): string | undefined { + // sessionKey doesn't encode guild — it's not available directly. + // Guild is passed explicitly by the agent. + return undefined; } -async function discordRequest(token: string, method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; text: string; json: any }> { - const r = await fetch(`https://discord.com/api/v10${path}`, { - method, - headers: { - Authorization: `Bot ${token}`, - "Content-Type": "application/json", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const text = await r.text(); - let json: any = null; - try { json = text ? JSON.parse(text) : null; } catch { json = null; } - return { ok: r.ok, status: r.status, text, json }; -} - -function roleOrMemberType(v: unknown): number { - if (typeof v === "number") return v; - if (typeof v === "string" && v.toLowerCase() === "member") return 1; - return 0; +function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined { + const m = sessionKey.match(/:discord:channel:(\d+)$/); + return m?.[1]; } export function registerDirigentTools(deps: ToolDeps): void { - const { api, baseConfig, pickDefined, discussionService } = deps; - - async function executeDiscordAction(action: DiscordControlAction, params: Record) { - const live = baseConfig as DirigentConfig & { - enableDiscordControlTool?: boolean; - discordControlAccountId?: string; - }; - if (live.enableDiscordControlTool === false) { - return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; - } - - const selected = parseAccountToken(api, (params.accountId as string | undefined) || live.discordControlAccountId); - if (!selected) { - return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true }; - } - const token = selected.token; - - if (action === "channel-private-create") { - const guildId = String(params.guildId || "").trim(); - const name = String(params.name || "").trim(); - if (!guildId || !name) return { content: [{ type: "text", text: "guildId and name are required" }], isError: true }; - - const 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({ - 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) }] }; - } + const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps; + // ─────────────────────────────────────────────── + // dirigent-register + // ─────────────────────────────────────────────── api.registerTool({ - name: "dirigent_discord_control", - description: "Create/update Discord private channels using the configured Discord bot token", + name: "dirigent-register", + description: "Register or update this agent's Discord user ID in Dirigent's identity registry.", parameters: { type: "object", additionalProperties: false, properties: { - action: { type: "string", enum: ["channel-private-create", "channel-private-update"] }, - accountId: { type: "string" }, - guildId: { type: "string" }, - channelId: { type: "string" }, - name: { type: "string" }, - type: { type: "number" }, - parentId: { type: "string" }, - topic: { type: "string" }, - position: { type: "number" }, - nsfw: { type: "boolean" }, - allowedUserIds: { type: "array", items: { type: "string" } }, - allowedRoleIds: { type: "array", items: { type: "string" } }, - allowMask: { type: "string" }, - denyEveryoneMask: { type: "string" }, - mode: { type: "string", enum: ["merge", "replace"] }, - addUserIds: { type: "array", items: { type: "string" } }, - addRoleIds: { type: "array", items: { type: "string" } }, - removeTargetIds: { type: "array", items: { type: "string" } }, - denyMask: { type: "string" }, - callbackChannelId: { type: "string" }, - discussGuide: { type: "string" }, + discordUserId: { type: "string", description: "The agent's Discord user ID" }, + agentName: { type: "string", description: "Display name (optional, defaults to agentId)" }, }, - required: ["action"], + required: ["discordUserId"], }, handler: async (params, ctx) => { - const nextParams = { - ...(params as Record), - __agentId: ctx?.agentId, - __sessionKey: ctx?.sessionKey, - __workspaceRoot: ctx?.workspaceRoot, - }; - return executeDiscordAction(params.action as DiscordControlAction, nextParams); + 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: "discuss-callback", - description: "Close a discussion channel and notify the origin work channel with the discussion summary path", + name: "create-chat-channel", + description: "Create a new private Discord channel in the specified guild with mode=chat.", parameters: { type: "object", additionalProperties: false, properties: { - summaryPath: { type: "string" }, + 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: ["summaryPath"], + required: ["guildId", "name"], }, handler: async (params, ctx) => { - if (!discussionService) { - return { content: [{ type: "text", text: "discussion service is not available" }], isError: true }; + 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[]; + }; + const initiatorAgentId = ctx?.agentId; + if (!initiatorAgentId) { + return { content: [{ type: "text", text: "Cannot resolve initiator agentId from session" }], isError: true }; } + if (!moderatorBotToken) { + return { content: [{ type: "text", text: "moderatorBotToken not configured" }], isError: true }; + } + if (!onDiscussionCreate) { + return { content: [{ type: "text", text: "Discussion service not available" }], isError: true }; + } + + const botId = getBotUserIdFromToken(moderatorBotToken); + const initiatorDiscordId = identityRegistry.findByAgentId(initiatorAgentId)?.discordUserId; + const memberIds = [...new Set([ + ...(initiatorDiscordId ? [initiatorDiscordId] : []), + ...p.participants, + ...(botId ? [botId] : []), + ])]; + + const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [ + { id: p.callbackGuildId, type: 0, deny: "1024" }, + ...memberIds.map((id) => ({ id, type: 1, allow: "1024" })), + ]; + + let channelId: string; try { - const result = await discussionService.handleCallback({ - channelId: String(ctx?.channelId || ""), - summaryPath: String((params as Record).summaryPath || ""), - callerAgentId: ctx?.agentId, - callerSessionKey: ctx?.sessionKey, + channelId = await createDiscordChannel({ + token: moderatorBotToken, + guildId: p.callbackGuildId, + name: p.name, + permissionOverwrites: overwrites, + logger: api.logger, }); - return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; - } catch (error) { - return { content: [{ type: "text", text: `discuss-callback failed: ${String(error)}` }], isError: true }; + } catch (err) { + return { content: [{ type: "text", text: `Failed to create channel: ${String(err)}` }], isError: true }; } + + try { + channelStore.setLockedMode(channelId, "discussion", { + initiatorAgentId, + callbackGuildId: p.callbackGuildId, + callbackChannelId: p.callbackChannelId, + concluded: false, + }); + } catch (err) { + return { content: [{ type: "text", text: `Failed to register channel: ${String(err)}` }], isError: true }; + } + + await onDiscussionCreate({ + channelId, + guildId: p.callbackGuildId, + initiatorAgentId, + callbackGuildId: p.callbackGuildId, + callbackChannelId: p.callbackChannelId, + discussionGuide: p.discussionGuide, + participants: p.participants, + }); + + return { content: [{ type: "text", text: `Discussion channel created: ${channelId}` }] }; + }, + }); + + // ─────────────────────────────────────────────── + // discussion-complete + // ─────────────────────────────────────────────── + api.registerTool({ + name: "discussion-complete", + description: "Mark a discussion as complete, archive the channel, and post the summary path to the callback channel.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + discussionChannelId: { type: "string", description: "The discussion channel ID" }, + summary: { type: "string", description: "File path to the summary (must be under {workspace}/discussion-summary/)" }, + }, + required: ["discussionChannelId", "summary"], + }, + handler: async (params, ctx) => { + const p = params as { discussionChannelId: string; summary: string }; + const callerAgentId = ctx?.agentId; + if (!callerAgentId) { + return { content: [{ type: "text", text: "Cannot resolve agentId from session" }], isError: true }; + } + + const rec = channelStore.getRecord(p.discussionChannelId); + if (rec.mode !== "discussion") { + return { content: [{ type: "text", text: `Channel ${p.discussionChannelId} is not a discussion channel` }], isError: true }; + } + if (!rec.discussion) { + return { content: [{ type: "text", text: "Discussion metadata not found" }], isError: true }; + } + if (rec.discussion.initiatorAgentId !== callerAgentId) { + return { + content: [{ type: "text", text: `Only the initiator (${rec.discussion.initiatorAgentId}) may call discussion-complete` }], + isError: true, + }; + } + if (!p.summary.includes("discussion-summary")) { + return { + content: [{ type: "text", text: "Summary path must be under {workspace}/discussion-summary/" }], + isError: true, + }; + } + + channelStore.concludeDiscussion(p.discussionChannelId); + + if (moderatorBotToken) { + const { sendModeratorMessage } = await import("../core/moderator-discord.js"); + await sendModeratorMessage( + moderatorBotToken, rec.discussion.callbackChannelId, + `Discussion complete. Summary: ${p.summary}`, + api.logger, + ).catch(() => undefined); + } + + return { content: [{ type: "text", text: `Discussion ${p.discussionChannelId} concluded. Summary posted to ${rec.discussion.callbackChannelId}.` }] }; }, }); } diff --git a/plugin/turn-manager.js b/plugin/turn-manager.js deleted file mode 100644 index 5a9f704..0000000 --- a/plugin/turn-manager.js +++ /dev/null @@ -1 +0,0 @@ -export * from './turn-manager.ts'; diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 5961fef..0c4d737 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -1,455 +1,268 @@ /** - * Turn-based speaking manager for group channels. + * Turn Manager (v2) * - * Rules: - * - Humans (humanList) are never in the turn order - * - Turn order is auto-populated from channel/server members minus humans - * - currentSpeaker can be null (dormant state) - * - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null) - * - Dormant → any new message reactivates: - * - If sender is NOT in turn order → current = first in list - * - If sender IS in turn order → current = next after sender + * Per-channel state machine governing who speaks when. + * Called from before_model_resolve (check turn) and agent_end (advance turn). */ -import { getChannelShuffling, isMultiMessageMode, markLastShuffled } from "./core/channel-modes.js"; - -export type ChannelTurnState = { - /** Ordered accountIds for this channel (auto-populated, shuffled) */ - turnOrder: string[]; - /** Current speaker accountId, or null if dormant */ - currentSpeaker: string | null; - /** Set of accountIds that have NO_REPLY'd this cycle */ - noRepliedThisCycle: Set; - /** Timestamp of last state change */ - lastChangedAt: number; - // ── Mention override state ── - /** Original turn order saved when override is active */ - savedTurnOrder?: string[]; - /** First agent in override cycle; used to detect cycle completion */ - overrideFirstAgent?: string; - // ── Wait-for-human state ── - /** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */ - waitingForHuman: boolean; +export type SpeakerEntry = { + agentId: string; + discordUserId: string; }; -const channelTurns = new Map(); - -/** Turn timeout: if the current speaker hasn't responded, auto-advance */ -const TURN_TIMEOUT_MS = 60_000; - -// --- helpers --- - -function shuffleArray(arr: T[]): T[] { - const a = [...arr]; - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [a[i], a[j]] = [a[j], a[i]]; - } - return a; -} - -function reshuffleTurnOrder(channelId: string, currentOrder: string[], lastSpeaker?: string): string[] { - const shufflingEnabled = getChannelShuffling(channelId); - if (!shufflingEnabled) return currentOrder; - - const shuffled = shuffleArray(currentOrder); - - // If there's a last speaker and they're in the order, ensure they're not first - if (lastSpeaker && shuffled.length > 1 && shuffled[0] === lastSpeaker) { - // Find another speaker to swap with - for (let i = 1; i < shuffled.length; i++) { - if (shuffled[i] !== lastSpeaker) { - [shuffled[0], shuffled[i]] = [shuffled[i], shuffled[0]]; - break; - } - } - } - - return shuffled; -} - -// --- public API --- +type ChannelTurnState = { + speakerList: SpeakerEntry[]; + currentIndex: number; + /** Tracks which agents sent empty turns in the current cycle. */ + emptyThisCycle: Set; + /** Tracks which agents completed a turn at all this cycle. */ + completedThisCycle: Set; + dormant: boolean; + /** Discord message ID recorded at before_model_resolve, used as poll anchor. */ + anchorMessageId: Map; // agentId → messageId +}; /** - * Initialize or update the turn order for a channel. - * Called with the list of bot accountIds (already filtered, humans excluded). + * All mutable state is stored on globalThis so it persists across VM-context + * hot-reloads within the same gateway process. OpenClaw re-imports this module + * in a fresh isolated VM context on each reload, but all contexts share the real + * globalThis object because they run in the same Node.js process. */ -export function initTurnOrder(channelId: string, botAccountIds: string[]): void { - const existing = channelTurns.get(channelId); - if (existing) { - // Compare membership against base order. - // If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison. - const baseOrder = existing.savedTurnOrder || existing.turnOrder; - const oldSet = new Set(baseOrder); - const newSet = new Set(botAccountIds); - const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id)); - if (same) return; // no change +const _G = globalThis as Record; - console.log( - `[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` + - `oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` + - `oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`, - ); +function channelStates(): Map { + if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map(); + return _G._tmChannelStates as Map; +} - const nextOrder = shuffleArray(botAccountIds); +function pendingTurns(): Set { + if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set(); + return _G._tmPendingTurns as Set; +} - // Mention override active: update only the saved base order. - // Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered. - if (existing.savedTurnOrder) { - existing.savedTurnOrder = nextOrder; - existing.lastChangedAt = Date.now(); - console.log( - `[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` + - `savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` + - `keptCurrent=${existing.currentSpeaker}`, - ); - return; - } - - // Non-mention flow: preserve previous behavior (re-init to dormant). - channelTurns.set(channelId, { - turnOrder: nextOrder, - currentSpeaker: null, // start dormant - noRepliedThisCycle: new Set(), - lastChangedAt: Date.now(), - waitingForHuman: false, - }); - - console.log( - `[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`, - ); - return; - } - - console.log( - `[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`, - ); - - const nextOrder = shuffleArray(botAccountIds); - channelTurns.set(channelId, { - turnOrder: nextOrder, - currentSpeaker: null, // start dormant - noRepliedThisCycle: new Set(), - lastChangedAt: Date.now(), - waitingForHuman: false, - }); - - console.log( - `[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`, - ); +function blockedPendingCounts(): Map { + if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map(); + return _G._tmBlockedPendingCounts as Map; } /** - * Check if the given accountId is allowed to speak. + * Shared initialization lock: prevents multiple concurrent VM contexts from + * simultaneously initializing the same channel's speaker list. + * Used by both before_model_resolve and message_received hooks. */ -export function checkTurn(channelId: string, accountId: string): { - allowed: boolean; - currentSpeaker: string | null; - reason: string; -} { - const state = channelTurns.get(channelId); - if (!state || state.turnOrder.length === 0) { - return { allowed: true, currentSpeaker: null, reason: "no_turn_state" }; - } +export function getInitializingChannels(): Set { + if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set(); + return _G._tmInitializingChannels as Set; +} - // Waiting for human → block all agents - if (state.waitingForHuman) { - return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" }; - } +export function markTurnStarted(channelId: string, agentId: string): void { + pendingTurns().add(`${channelId}:${agentId}`); +} - // Not in turn order (human or unknown) → always allowed - if (!state.turnOrder.includes(accountId)) { - return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" }; - } +export function isTurnPending(channelId: string, agentId: string): boolean { + return pendingTurns().has(`${channelId}:${agentId}`); +} - // Dormant → not allowed (will be activated by onNewMessage) - if (state.currentSpeaker === null) { - return { allowed: false, currentSpeaker: null, reason: "dormant" }; - } - - // Check timeout → auto-advance - if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) { - advanceTurn(channelId); - // Re-check after advance - const updated = channelTurns.get(channelId)!; - if (updated.currentSpeaker === accountId) { - return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" }; - } - return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" }; - } - - if (accountId === state.currentSpeaker) { - return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" }; - } - - return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" }; +export function clearTurnPending(channelId: string, agentId: string): void { + pendingTurns().delete(`${channelId}:${agentId}`); } /** - * Called when a new message arrives in the channel. - * Handles reactivation from dormant state and human-triggered resets. - * - * NOTE: For human messages with @mentions, call setMentionOverride() instead. - * - * @param senderAccountId - the accountId of the message sender (could be human/bot/unknown) - * @param isHuman - whether the sender is in the humanList + * Counts NO_REPLY completions currently in-flight for an agent that was + * blocked (non-speaker or init-suppressed). These completions take ~10s to + * arrive (history-building overhead) and may arrive after markTurnStarted, + * causing false empty-turn detection. We count them and skip one per agent_end + * until the count reaches zero, at which point the next agent_end is real. */ -export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void { - const state = channelTurns.get(channelId); - if (!state || state.turnOrder.length === 0) return; - - // Check for multi-message mode exit condition - if (isMultiMessageMode(channelId) && isHuman) { - // In multi-message mode, human messages don't trigger turn activation - // We only exit multi-message mode if the end marker is detected in a higher-level hook - return; - } - - if (isHuman) { - // Human message: clear wait-for-human, restore original order if overridden, activate from first - state.waitingForHuman = false; - restoreOriginalOrder(state); - state.currentSpeaker = state.turnOrder[0]; - state.noRepliedThisCycle = new Set(); - state.lastChangedAt = Date.now(); - return; - } - - if (state.waitingForHuman) { - // Waiting for human — ignore non-human messages - return; - } - - if (state.currentSpeaker !== null) { - // Already active, no change needed from incoming message - return; - } - - // Dormant state + non-human message → reactivate - if (senderAccountId && state.turnOrder.includes(senderAccountId)) { - // Sender is in turn order → next after sender - const idx = state.turnOrder.indexOf(senderAccountId); - const nextIdx = (idx + 1) % state.turnOrder.length; - state.currentSpeaker = state.turnOrder[nextIdx]; - } else { - // Sender not in turn order → start from first - state.currentSpeaker = state.turnOrder[0]; - } - state.noRepliedThisCycle = new Set(); - state.lastChangedAt = Date.now(); +export function incrementBlockedPending(channelId: string, agentId: string): void { + const bpc = blockedPendingCounts(); + const key = `${channelId}:${agentId}`; + bpc.set(key, (bpc.get(key) ?? 0) + 1); } -/** - * Restore original turn order if an override is active. - */ -function restoreOriginalOrder(state: ChannelTurnState): void { - if (state.savedTurnOrder) { - state.turnOrder = state.savedTurnOrder; - state.savedTurnOrder = undefined; - state.overrideFirstAgent = undefined; - } -} - -/** - * Set a temporary mention override for the turn order. - * When a human @mentions specific agents, only those agents speak (in their - * relative order from the current turn order). After the cycle returns to the - * first agent, the original order is restored. - * - * @param channelId - Discord channel ID - * @param mentionedAccountIds - accountIds of @mentioned agents, ordered by - * their position in the current turn order - * @returns true if override was set, false if no valid agents - */ -export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean { - const state = channelTurns.get(channelId); - if (!state || mentionedAccountIds.length === 0) return false; - - console.log( - `[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` + - `mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` + - `order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`, - ); - - // Restore any existing override first - restoreOriginalOrder(state); - - // Filter to agents actually in the turn order - const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id)); - if (validIds.length === 0) { - console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`); - return false; - } - - // Order by their position in the current turn order - validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b)); - - // Save original and apply override - state.savedTurnOrder = [...state.turnOrder]; - state.turnOrder = validIds; - state.overrideFirstAgent = validIds[0]; - state.currentSpeaker = validIds[0]; - state.noRepliedThisCycle = new Set(); - state.lastChangedAt = Date.now(); - - console.log( - `[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` + - `overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` + - `savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`, - ); - +/** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */ +export function consumeBlockedPending(channelId: string, agentId: string): boolean { + const bpc = blockedPendingCounts(); + const key = `${channelId}:${agentId}`; + const count = bpc.get(key) ?? 0; + if (count <= 0) return false; + bpc.set(key, count - 1); return true; } -/** - * Check if a mention override is currently active. - */ -export function hasMentionOverride(channelId: string): boolean { - const state = channelTurns.get(channelId); - return !!state?.savedTurnOrder; +export function resetBlockedPending(channelId: string, agentId: string): void { + blockedPendingCounts().delete(`${channelId}:${agentId}`); +} + +function getState(channelId: string): ChannelTurnState | undefined { + return channelStates().get(channelId); +} + +function ensureState(channelId: string): ChannelTurnState { + const cs = channelStates(); + let s = cs.get(channelId); + if (!s) { + s = { + speakerList: [], + currentIndex: 0, + emptyThisCycle: new Set(), + completedThisCycle: new Set(), + dormant: false, + anchorMessageId: new Map(), + }; + cs.set(channelId, s); + } + return s; +} + +/** Replace the speaker list (called at cycle boundaries and on init). */ +export function setSpeakerList(channelId: string, speakers: SpeakerEntry[]): void { + const s = ensureState(channelId); + s.speakerList = speakers; + s.currentIndex = 0; +} + +/** Get the currently active speaker, or null if dormant / list empty. */ +export function getCurrentSpeaker(channelId: string): SpeakerEntry | null { + const s = getState(channelId); + if (!s || s.dormant || s.speakerList.length === 0) return null; + return s.speakerList[s.currentIndex] ?? null; +} + +/** Check if a given agentId is the current speaker. */ +export function isCurrentSpeaker(channelId: string, agentId: string): boolean { + const speaker = getCurrentSpeaker(channelId); + return speaker?.agentId === agentId; +} + +/** Record the Discord anchor message ID for an agent's upcoming turn. */ +export function setAnchor(channelId: string, agentId: string, messageId: string): void { + const s = ensureState(channelId); + s.anchorMessageId.set(agentId, messageId); +} + +export function getAnchor(channelId: string, agentId: string): string | undefined { + return getState(channelId)?.anchorMessageId.get(agentId); } /** - * Set the channel to "waiting for human" state. - * All agents will be routed to no-reply until a human sends a message. + * Advance the speaker after a turn completes. + * Returns the new current speaker (or null if dormant). + * + * @param isEmpty - whether the completed turn was an empty turn + * @param rebuildFn - async function that fetches current Discord members and + * returns a new SpeakerEntry[]. Called at cycle boundaries. + * @param previousLastAgentId - for shuffle mode: the last speaker of the + * previous cycle (cannot become the new first speaker). */ -export function setWaitingForHuman(channelId: string): void { - const state = channelTurns.get(channelId); - if (!state) return; - state.waitingForHuman = true; - state.currentSpeaker = null; - state.noRepliedThisCycle = new Set(); - state.lastChangedAt = Date.now(); +export async function advanceSpeaker( + channelId: string, + agentId: string, + isEmpty: boolean, + rebuildFn: () => Promise, + previousLastAgentId?: string, +): Promise<{ next: SpeakerEntry | null; enteredDormant: boolean }> { + const s = ensureState(channelId); + + // Record this turn + s.completedThisCycle.add(agentId); + if (isEmpty) s.emptyThisCycle.add(agentId); + + const wasLastInCycle = s.currentIndex >= s.speakerList.length - 1; + + if (!wasLastInCycle) { + // Middle of cycle — just advance pointer + s.currentIndex++; + s.dormant = false; + return { next: s.speakerList[s.currentIndex] ?? null, enteredDormant: false }; + } + + // === Cycle boundary === + const newSpeakers = await rebuildFn(); + const previousAgentIds = new Set(s.speakerList.map((sp) => sp.agentId)); + const hasNewAgents = newSpeakers.some((sp) => !previousAgentIds.has(sp.agentId)); + + const allEmpty = + s.completedThisCycle.size > 0 && + [...s.completedThisCycle].every((id) => s.emptyThisCycle.has(id)); + + // Reset cycle tracking + s.emptyThisCycle = new Set(); + s.completedThisCycle = new Set(); + + if (allEmpty && !hasNewAgents) { + // Enter dormant + s.speakerList = newSpeakers; + s.currentIndex = 0; + s.dormant = true; + return { next: null, enteredDormant: true }; + } + + // Continue with updated list (apply shuffle if caller provides previousLastAgentId) + s.speakerList = previousLastAgentId != null + ? shuffleList(newSpeakers, previousLastAgentId) + : newSpeakers; + s.currentIndex = 0; + s.dormant = false; + + return { next: s.speakerList[0] ?? null, enteredDormant: false }; } /** - * Check if the channel is waiting for a human reply. + * Wake the channel from dormant. + * Returns the new first speaker. */ -export function isWaitingForHuman(channelId: string): boolean { - const state = channelTurns.get(channelId); - return !!state?.waitingForHuman; +export function wakeFromDormant(channelId: string): SpeakerEntry | null { + const s = getState(channelId); + if (!s) return null; + s.dormant = false; + s.currentIndex = 0; + s.emptyThisCycle = new Set(); + s.completedThisCycle = new Set(); + return s.speakerList[0] ?? null; +} + +export function isDormant(channelId: string): boolean { + return getState(channelId)?.dormant ?? false; +} + +export function hasSpeakers(channelId: string): boolean { + const s = getState(channelId); + return (s?.speakerList.length ?? 0) > 0; } /** - * Called when the current speaker finishes (end symbol detected) or says NO_REPLY. - * @param wasNoReply - true if the speaker said NO_REPLY (empty/silent) - * @returns the new currentSpeaker (or null if dormant) + * Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker. */ -export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null { - const state = channelTurns.get(channelId); - if (!state) return null; - if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore - - if (wasNoReply) { - state.noRepliedThisCycle.add(accountId); - - // Check if ALL agents have NO_REPLY'd this cycle - const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id)); - if (allNoReplied) { - // If override active, restore original order before going dormant - restoreOriginalOrder(state); - // Go dormant - state.currentSpeaker = null; - state.noRepliedThisCycle = new Set(); - state.lastChangedAt = Date.now(); - return null; - } - } else { - // Successful speech resets the cycle counter - state.noRepliedThisCycle = new Set(); +export function shuffleList(list: SpeakerEntry[], previousLastAgentId?: string): SpeakerEntry[] { + if (list.length <= 1) return list; + const arr = [...list]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; } - - const prevSpeaker = state.currentSpeaker; - const next = advanceTurn(channelId); - - // Check if override cycle completed (returned to first agent) - if (state.overrideFirstAgent && next === state.overrideFirstAgent) { - restoreOriginalOrder(state); - state.currentSpeaker = null; - state.noRepliedThisCycle = new Set(); - state.lastChangedAt = Date.now(); - return null; // go dormant after override cycle completes + if (previousLastAgentId && arr[0].agentId === previousLastAgentId && arr.length > 1) { + const swapIdx = 1 + Math.floor(Math.random() * (arr.length - 1)); + [arr[0], arr[swapIdx]] = [arr[swapIdx], arr[0]]; } - - // Check if we've completed a full cycle (all agents spoke once) - // This happens when we're back to the first agent in the turn order - const isFirstSpeakerAgain = next === state.turnOrder[0]; - if (!wasNoReply && !state.overrideFirstAgent && next && isFirstSpeakerAgain && state.noRepliedThisCycle.size === 0) { - // Completed a full cycle without anyone NO_REPLYing - reshuffle if enabled - const newOrder = reshuffleTurnOrder(channelId, state.turnOrder, prevSpeaker); - if (newOrder !== state.turnOrder) { - state.turnOrder = newOrder; - markLastShuffled(channelId); - console.log(`[dirigent][turn-debug] reshuffled turn order for channel=${channelId} newOrder=${JSON.stringify(newOrder)}`); - } - } - - return next; + return arr; } -/** - * Advance to next speaker in order. - */ -export function advanceTurn(channelId: string): string | null { - const state = channelTurns.get(channelId); - if (!state || state.turnOrder.length === 0) return null; - - if (state.currentSpeaker === null) return null; - - const idx = state.turnOrder.indexOf(state.currentSpeaker); - const nextIdx = (idx + 1) % state.turnOrder.length; - - // Skip agents that already NO_REPLY'd this cycle - let attempts = 0; - let candidateIdx = nextIdx; - while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) { - candidateIdx = (candidateIdx + 1) % state.turnOrder.length; - attempts++; - } - - if (attempts >= state.turnOrder.length) { - // All have NO_REPLY'd - state.currentSpeaker = null; - state.lastChangedAt = Date.now(); - return null; - } - - state.currentSpeaker = state.turnOrder[candidateIdx]; - state.lastChangedAt = Date.now(); - return state.currentSpeaker; -} - -/** - * Force reset: go dormant. - */ -export function resetTurn(channelId: string): void { - const state = channelTurns.get(channelId); - if (state) { - restoreOriginalOrder(state); - state.currentSpeaker = null; - state.noRepliedThisCycle = new Set(); - state.waitingForHuman = false; - state.lastChangedAt = Date.now(); - } -} - -/** - * Get debug info. - */ -export function getTurnDebugInfo(channelId: string): Record { - const state = channelTurns.get(channelId); - if (!state) return { channelId, hasTurnState: false }; +export function getDebugInfo(channelId: string) { + const s = getState(channelId); + if (!s) return { exists: false }; return { - channelId, - hasTurnState: true, - turnOrder: state.turnOrder, - currentSpeaker: state.currentSpeaker, - noRepliedThisCycle: [...state.noRepliedThisCycle], - lastChangedAt: state.lastChangedAt, - dormant: state.currentSpeaker === null, - waitingForHuman: state.waitingForHuman, - hasOverride: !!state.savedTurnOrder, - overrideFirstAgent: state.overrideFirstAgent || null, - savedTurnOrder: state.savedTurnOrder || null, + exists: true, + speakerList: s.speakerList.map((sp) => sp.agentId), + currentIndex: s.currentIndex, + currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null, + dormant: s.dormant, + emptyThisCycle: [...s.emptyThisCycle], + completedThisCycle: [...s.completedThisCycle], }; } + +/** Remove a channel's turn state entirely (e.g. when archived). */ +export function clearChannel(channelId: string): void { + channelStates().delete(channelId); +} diff --git a/plugin/web/control-page.ts b/plugin/web/control-page.ts new file mode 100644 index 0000000..b877783 --- /dev/null +++ b/plugin/web/control-page.ts @@ -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(["work", "discussion"]); + +function html(strings: TemplateStringsArray, ...values: unknown[]): string { + return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), ""); +} + +function escapeHtml(s: unknown): string { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function modeBadge(mode: ChannelMode): string { + const colors: Record = { + none: "#888", chat: "#5865f2", report: "#57f287", + work: "#fee75c", discussion: "#eb459e", + }; + return `${escapeHtml(mode)}`; +} + +function buildPage(content: string): string { + return ` + + + + + Dirigent + + + +

Dirigent

+

OpenClaw multi-agent turn management

+ ${content} + + +`; +} + +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() + ? `` + : ""; + + // Build identity table rows + const identityRows = entries.map((e) => html` + + ${escapeHtml(e.discordUserId)} + ${escapeHtml(e.agentId)} + ${escapeHtml(e.agentName)} + + `).join(""); + + // Build guild sections + let guildHtml = "

Loading guilds…

"; + if (moderatorBotToken) { + try { + const guilds = await fetchAdminGuilds(moderatorBotToken); + if (guilds.length === 0) { + guildHtml = "

No guilds with admin permissions found.

"; + } 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) + : ``; + return html` + ${escapeHtml(ch.id)} + #${escapeHtml(ch.name)} + ${dropdown} + `; + }).join(""); + + guildHtml += html` +
+
+ ${escapeHtml(guild.name)} + ${escapeHtml(guild.id)} +
+ + + ${channelRows} +
Channel IDNameMode
+
`; + } + } + } catch (err) { + guildHtml = `

Failed to load guilds: ${escapeHtml(String(err))}

`; + } + } else { + guildHtml = "

moderatorBotToken not configured — cannot list guilds.

"; + } + + const content = html` +

Identity Registry

+ + + + ${identityRows} +
Discord User IDAgent IDAgent Name
+
+ + + + + ${paddedCellBtn} +
+ +

Guild & Channel Configuration

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