diff --git a/CHANGELOG.md b/CHANGELOG.md index c828e07..64c8e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.3.0 + +- **Split `dirigent_tools` into 6 individual tools**: + - Discord: `dirigent_discord_channel_create`, `dirigent_discord_channel_update`, `dirigent_discord_member_list` + - Policy: `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete` + - Turn management tools removed (internal plugin logic only; use `/dirigent` slash commands) +- **Human @mention override**: When a `humanList` user @mentions specific agents: + - Temporarily overrides the speaking order to only mentioned agents + - Agents cycle in their original turn-order position + - After all mentioned agents have spoken, original order restores and state goes dormant + - Handles edge cases: all NO_REPLY, reset, non-agent mentions +- **Wait for human reply**: New `waitIdentifier` (default: `๐Ÿ‘ค`): + - Agent ends with `๐Ÿ‘ค` to signal it needs a human response + - All agents go silent (routed to no-reply model) until a human speaks + - Prompt injection warns agents to use sparingly (only when human is actively participating) +- **Installer improvements**: + - Dynamic OpenClaw dir resolution (`$OPENCLAW_DIR` โ†’ `openclaw` CLI โ†’ `~/.openclaw`) + - Plugin installed to `$(openclaw_dir)/plugins/dirigent` + - New `--update` mode: pulls latest from git `latest` branch and reinstalls + ## 0.2.0 - **Project renamed from WhisperGate to Dirigent** @@ -24,7 +44,7 @@ - supports `--install` / `--uninstall` - uninstall restores all recorded changes - writes install/uninstall records under `~/.openclaw/dirigent-install-records/` -- Added discord-control-api with: +- Added discord-control-api with: (historical; later migrated into plugin internal Discord REST control) - `channel-private-create` (create private channel for allowlist) - `channel-private-update` (update allowlist/overwrites for existing channel) - `member-list` (guild members list with pagination + optional field projection) diff --git a/FEAT.md b/FEAT.md new file mode 100644 index 0000000..56b0fd2 --- /dev/null +++ b/FEAT.md @@ -0,0 +1,158 @@ +# Dirigent โ€” Feature List + +All implemented features across all versions. + +--- + +## Core: Rule-Based No-Reply Gate +- Deterministic logic in `before_model_resolve` hook decides whether to route to no-reply model +- **human-list** mode: humanList senders bypass gate; others need end symbol to pass +- **agent-list** mode: agentList senders need end symbol; others bypass +- Non-Discord messages skip entirely +- DM sessions (no metadata) always bypass +- Per-channel policy overrides via JSON file (runtime-updateable) + +## Core: End-Symbol Enforcement +- Injects prompt instruction: "Your response MUST end with ๐Ÿ”š" +- Gateway keywords (NO_REPLY, HEARTBEAT_OK) exempt from end symbol +- Group chats get additional rule: "If not relevant, reply NO_REPLY" +- End symbols configurable per-channel via policy + +## Core: No-Reply API +- OpenAI-compatible API (`/v1/chat/completions`, `/v1/responses`, `/v1/models`) +- Always returns `NO_REPLY` โ€” used as the override model target +- Optional bearer auth (`AUTH_TOKEN`) +- Auto-started/stopped with gateway lifecycle + +## Turn-Based Speaking (Multi-Bot) +- Only the current speaker is allowed to respond; others forced to no-reply model +- Turn order auto-populated from bot accounts seen in each channel +- Turn advances on end-symbol (successful speech) or NO_REPLY +- Successful speech resets NO_REPLY cycle counter +- If all bots NO_REPLY in a cycle โ†’ channel goes **dormant** +- Dormant reactivation: human message โ†’ first in order; bot message โ†’ next after sender +- Turn timeout: auto-advance after 60s of inactivity +- Manual control: `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` + +## Human @Mention Override *(v0.3.0)* +- When a `humanList` user @mentions specific agents (`<@USER_ID>`): + - Extracts mentioned Discord user IDs from message content + - Maps userIds โ†’ accountIds via reverse bot token lookup + - Filters to agents in the current turn order + - Orders by their position in the current turn order + - Temporarily replaces speaking order with only those agents + - Cycle: a โ†’ b โ†’ c โ†’ (back to a) โ†’ restore original order, go dormant +- Edge cases: + - All override agents NO_REPLY โ†’ restore + dormant + - `resetTurn` clears any active override + - Human message without mentions โ†’ restores override, normal flow + - Mentioned users not in turn order โ†’ ignored + +## Agent Identity Injection +- Group chat prompts include: agent name, Discord accountId, Discord userId +- userId resolved from bot token (base64 first segment) + +## Wait for Human Reply *(v0.3.0)* +- Configurable wait identifier (default: `๐Ÿ‘ค`) +- Agent ends message with `๐Ÿ‘ค` instead of `๐Ÿ”š` when it needs a human to reply +- Triggers "waiting for human" state: + - All agents routed to no-reply model + - Turn manager goes dormant + - State clears automatically when a human sends a message +- Prompt injection tells agents: + - Use wait identifier only when confident the human is actively participating + - Do NOT use it speculatively +- Works with mention override: wait identifier during override also triggers waiting state + +## Scheduling Identifier +- Configurable identifier (default: `โžก๏ธ`) used for moderator handoff +- Handoff format: `<@TARGET_USER_ID>โžก๏ธ` (non-semantic scheduling signal) +- Agent prompt explains: identifier is meaningless โ€” check chat history, decide whether to reply +- If nothing to say โ†’ NO_REPLY + +## Moderator Bot Presence +- Maintains Discord Gateway WebSocket connection for moderator bot +- Shows "online" status with "Moderating" activity +- Handles reconnect, resume, heartbeat, invalid session recovery +- Singleton guard prevents duplicate connections +- Sends handoff messages to trigger next speaker's turn + +## Individual Tools *(v0.3.0)* +Six standalone tools (split from former monolithic `dirigent_tools`): + +### Discord Control +- **`dirigent_discord_channel_create`** โ€” Create private Discord channel with user/role permissions +- **`dirigent_discord_channel_update`** โ€” Update permissions on existing private channel +- **`dirigent_discord_member_list`** โ€” List guild members with pagination and field projection + +### Policy Management +- **`dirigent_policy_get`** โ€” Get all channel policies +- **`dirigent_policy_set`** โ€” Set/update a channel policy (listMode, humanList, agentList, endSymbols) +- **`dirigent_policy_delete`** โ€” Delete a channel policy + +### Turn Management (internal only โ€” not exposed as tools) +Turn management is handled entirely by the plugin. Manual control via slash commands: +- `/dirigent turn-status` โ€” Show turn state +- `/dirigent turn-advance` โ€” Manually advance to next speaker +- `/dirigent turn-reset` โ€” Reset turn order (go dormant, clear overrides) + +## Slash Command: `/dirigent` +- `status` โ€” Show all channel policies +- `turn-status` โ€” Show turn state for current channel +- `turn-advance` โ€” Manually advance turn +- `turn-reset` โ€” Reset turn order + +## Project Rename (WhisperGate โ†’ Dirigent) *(v0.2.0)* +- All plugin ids, tool names, config keys, file paths, docs updated +- Legacy `whispergate` config key still supported as fallback + +## Installer Script *(updated v0.3.0)* +- `scripts/install-dirigent-openclaw.mjs` +- `--install` / `--uninstall` / `--update` modes +- **Dynamic OpenClaw dir resolution**: `$OPENCLAW_DIR` โ†’ `openclaw config get dataDir` โ†’ `~/.openclaw` +- Builds dist and copies to `$(openclaw_dir)/plugins/dirigent` +- `--update`: pulls latest from git `latest` branch, then reinstalls +- Auto-reinstall (uninstall + install) if already installed +- Backup before changes, rollback on failure +- Records stored in `$(openclaw_dir)/dirigent-install-records/` + +## Discord Control API (Sidecar) +- Private channel create/update with permission overwrites +- Member list with pagination + field projection +- Guardrails: action validation, id-list limits, response-size limit +- (Migrated) Discord control now runs in-plugin via direct Discord REST (no companion service) + +--- + +## NEW_FEAT ๅˆๅนถ่ฎฐๅฝ•๏ผˆๅŽŸ NEW_FEAT.md๏ผ‰ + +### ่ƒŒๆ™ฏไธŽ็›ฎๆ ‡ +- ่งฃๅ†ณ turn ๅˆๅง‹ๅŒ–ไพ่ต–่ขซๅŠจ่ง‚ๅฏŸ๏ผˆ`recordChannelAccount`๏ผ‰ๅฏผ่‡ด `currentSpeaker` ็ฉบๅ€ผ็š„้—ฎ้ข˜ใ€‚ +- ๅฐ† Discord control ไปŽ sidecar ่ฟ็งปๅˆฐๆ’ไปถๅ†…ๆจกๅ—ใ€‚ +- ้‡‡็”จ channel ๆˆๅ‘˜็ผ“ๅญ˜๏ผˆๅ†…ๅญ˜ + ๆœฌๅœฐๆŒไน…ๅŒ–๏ผ‰๏ผŒ้ฟๅ…่ฝฎ่ฏขใ€‚ + +### ๅ…ณ้”ฎๅฎž็Žฐๆ–นๅ‘ +- ็ปŸไธ€ channelId ่งฃๆž้“พ่ทฏ๏ผŒ้ฟๅ… `channel=discord` ้”™ไฝใ€‚ +- `before_model_resolve / before_prompt_build` ไธŽๆถˆๆฏ hook ไฝฟ็”จไธ€่‡ด่งฃๆž็ญ–็•ฅใ€‚ +- ๆธ…็†ๆœชไฝฟ็”จๅ‡ฝๆ•ฐ๏ผŒ้™ไฝŽๆŽ’้šœๅ™ช้Ÿณใ€‚ +- ๆจกๅ—ๅŒ–้‡ๆž„๏ผš`index.ts` ไฝœไธบ wiring๏ผŒ้€ป่พ‘ๆ‹†ๅ…ฅ `hooks/core/tools/policy/commands`ใ€‚ + +### Channel ๆˆๅ‘˜็ผ“ๅญ˜ +- ็ผ“ๅญ˜ๆ–‡ไปถ๏ผš`~/.openclaw/dirigent-channel-members.json` +- ๅฏๅŠจๅŠ ่ฝฝใ€่ฟ่กŒๆ—ถๅŽŸๅญๅ†™็›˜ใ€‚ +- ่ฎฐๅฝ•ๅญ—ๆฎตๅŒ…ๅซ `botAccountIds/updatedAt/source/guildId`ใ€‚ +- ้ฆ–ๆฌกๆ— ็ผ“ๅญ˜ๆ—ถๅ…่ฎธ bootstrap ๆ‹‰ๅ–๏ผŒ้šๅŽ่ตฐๆœฌๅœฐ็ผ“ๅญ˜ใ€‚ + +### Turn ๅˆๅง‹ๅŒ–ๆ”น้€  +- `ensureTurnOrder(channelId)` ๅŸบไบŽ็ผ“ๅญ˜ไธญ็š„ botAccountIds ๅˆๅง‹ๅŒ–ใ€‚ +- ไธๅ†ไป…ไพ่ต–โ€œๅทฒ่ง่ดฆๅทโ€่ขซๅŠจ่ฎฐๅฝ•ใ€‚ +- ๆๅ‡ๆ–ฐ้ข‘้“้ฆ–ๆกๆถˆๆฏๅœบๆ™ฏ็š„็จณๅฎšๆ€งใ€‚ + +### ๆƒ้™่ฎก็ฎ—๏ผˆ้ข‘้“ๅฏ่งๆˆๅ‘˜๏ผ‰ +- ้€š่ฟ‡ guild ๆˆๅ‘˜ + roles + channel overwrites ่ฎก็ฎ— `VIEW_CHANNEL` ๅฏ่งๆ€งใ€‚ +- ็”จไบŽๅ†…้ƒจ turn bootstrap๏ผŒไธๅฏนๅค–ๆšด้œฒไธบๅ…ฌๅ…ฑๅทฅๅ…ทใ€‚ + +### ้ฃŽ้™ฉไธŽๆณจๆ„ +- ๆƒ้™ไฝ่ฎก็ฎ—ๅฟ…้กปไธฅๆ ผๆŒ‰ Discord ่ง„ๅˆ™ใ€‚ +- ็ผ“ๅญ˜่ฏปๅ†™้œ€ๅŽŸๅญๅŒ–๏ผŒ้˜ฒๅนถๅ‘ๆŸๅใ€‚ +- ้€š่ฟ‡ `updatedAt/source/guildId` ๆ้ซ˜ๅฏ่ง‚ๆต‹ๆ€งไธŽๆŽ’้šœๆ•ˆ็އใ€‚ diff --git a/Makefile b/Makefile index 5f3893c..8bc67c5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up smoke-discord-control +.PHONY: check check-rules test-api up down smoke render-config package-plugin check: cd plugin && npm run check @@ -24,8 +24,3 @@ render-config: package-plugin: node scripts/package-plugin.mjs -discord-control-up: - cd discord-control-api && node server.mjs - -smoke-discord-control: - ./scripts/smoke-discord-control.sh diff --git a/NEW_FEAT.md b/NEW_FEAT.md deleted file mode 100644 index db7024b..0000000 --- a/NEW_FEAT.md +++ /dev/null @@ -1,4 +0,0 @@ -# New Features - -1. ๆ‹†ๅˆ† dirigent-tools๏ผšไธๅ†็”จไธ€ไธชไธปๅทฅๅ…ท็ฎก็†ๅคšไธชๅฐๅทฅๅ…ทใ€‚ -2. ไบบ็ฑป@่ง„ๅˆ™๏ผšๅฝ“ๆฅ่‡ช humanList ็š„็”จๆˆทๆถˆๆฏๅŒ…ๅซ <@USER_ID> ๆ—ถ๏ผŒๆŒ‰่ขซ @ ็š„ agent ้กบๅบไธดๆ—ถ่ฆ†็›– speaking order ๅพช็Žฏ๏ผ›ๅ›žๅˆฐ้ฆ–ไธช agent ๅŽๆขๅคๅŽŸ้กบๅบใ€‚ diff --git a/README.md b/README.md index 0a614f6..dfcf810 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,17 @@ Dirigent adds deterministic logic **before model selection** and **turn-based sp - **Agent identity injection** - Injects agent name, Discord accountId, and Discord userId into group chat prompts +- **Human @mention override** + - When a `humanList` user @mentions agents, temporarily overrides turn order + - Only mentioned agents cycle; original order restores when cycle completes + - **Per-channel policy runtime** - Policies stored in a standalone JSON file - - Update at runtime via `dirigent_tools` (memory first โ†’ persist to file) + - Update at runtime via `dirigent_policy_set` / `dirigent_policy_delete` tools - **Discord control actions (optional)** - Private channel create/update + member list - - Unified via `dirigent_tools` + - Via `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` tools --- @@ -46,7 +50,7 @@ Dirigent adds deterministic logic **before model selection** and **turn-based sp - `plugin/` โ€” OpenClaw plugin (gate + turn manager + moderator presence) - `no-reply-api/` โ€” OpenAI-compatible API that always returns `NO_REPLY` -- `discord-control-api/` โ€” Discord admin extension API (private channels + member list) +- Discord admin actions are now handled in-plugin via direct Discord REST API calls (no sidecar service) - `docs/` โ€” rollout, integration, run-mode notes, turn-wakeup analysis - `scripts/` โ€” smoke/dev/helper checks - `Makefile` โ€” common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`) @@ -74,12 +78,21 @@ Discord extension capabilities: `docs/DISCORD_CONTROL.md`. ## Runtime tools & commands -### Tool: `dirigent_tools` +### Tools (6 individual tools) -Actions: -- `policy-get`, `policy-set-channel`, `policy-delete-channel` -- `turn-status`, `turn-advance`, `turn-reset` -- `channel-private-create`, `channel-private-update`, `member-list` +**Discord control:** +- `dirigent_discord_channel_create` โ€” Create private channel +- `dirigent_discord_channel_update` โ€” Update channel permissions +- `dirigent_discord_member_list` โ€” List guild members + +**Policy management:** +- `dirigent_policy_get` โ€” Get all policies +- `dirigent_policy_set` โ€” Set/update channel policy +- `dirigent_policy_delete` โ€” Delete channel policy + +> Turn management is internal to the plugin (not exposed as tools). + +> See `FEAT.md` for full feature documentation. ### Slash command (Discord) @@ -100,6 +113,7 @@ Common options (see `docs/INTEGRATION.md`): - `humanList`, `agentList` - `endSymbols` - `schedulingIdentifier` (default `โžก๏ธ`) +- `waitIdentifier` (default `๐Ÿ‘ค`) โ€” agent ends with this to pause all agents until human replies - `channelPoliciesFile` (per-channel overrides) - `moderatorBotToken` (handoff messages) - `enableDebugLogs`, `debugLogChannelIds` diff --git a/TASKLIST.md b/TASKLIST.md index 65d0109..7e7395a 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -39,11 +39,48 @@ --- +## 6) Split dirigent_tools into Individual Tools โœ… +- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions. +- **After**: 6 individual tools (Discord tools prefixed `dirigent_discord_*`): + - `dirigent_discord_channel_create` โ€” Create private Discord channel + - `dirigent_discord_channel_update` โ€” Update channel permissions + - `dirigent_discord_member_list` โ€” List guild members + - `dirigent_policy_get` โ€” Get all channel policies + - `dirigent_policy_set` โ€” Set/update a channel policy + - `dirigent_policy_delete` โ€” Delete a channel policy +- Turn management (status/advance/reset) NOT exposed as tools โ€” purely internal plugin logic, accessible via `/dirigent` slash commands. +- Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication. +- **Done**: All tools registered individually with specific parameter schemas. + +## 7) Human @Mention Override โœ… +- When a message from a `humanList` user contains `<@USER_ID>` mentions: + - Extract mentioned Discord user IDs from the message content. + - Map user IDs โ†’ accountIds via bot token decoding (reverse of `resolveDiscordUserId`). + - Filter to agents in the current turn order. + - Order by their position in the current turn order. + - Temporarily replace the speaking order with only those agents. + - After the cycle returns to the first agent, restore the original order and go dormant. +- Edge cases handled: + - All override agents NO_REPLY โ†’ restore original order, go dormant. + - `resetTurn` clears any active override. + - New human message without mentions โ†’ restores override before normal handling. + - Mentioned users not in turn order โ†’ ignored, normal flow. +- New functions in `turn-manager.ts`: `setMentionOverride()`, `hasMentionOverride()`. +- New helpers in `index.ts`: `buildUserIdToAccountIdMap()`, `extractMentionedUserIds()`. +- **Done**: Override logic integrated in `message_received` handler. + +## 8) Wait for Human Reply โœ… +- Added configurable `waitIdentifier` (default: `๐Ÿ‘ค`) to config and config schema. +- Prompt injection in group chats: tells agents to end with `๐Ÿ‘ค` instead of end symbol when they need a human response. Warns to use sparingly โ€” only when human is actively participating. +- Detection in `before_message_write` and `message_sent`: if last char matches wait identifier โ†’ `setWaitingForHuman(channelId)`. +- Turn manager `waitingForHuman` state: + - `checkTurn()` blocks all agents (`reason: "waiting_for_human"`) โ†’ routed to no-reply model. + - `onNewMessage()` clears `waitingForHuman` on human message โ†’ normal flow resumes. + - Non-human messages ignored while waiting. + - `resetTurn()` also clears waiting state. +- **Done**: Full lifecycle implemented across turn-manager, rules, and index. + +--- + ## Open Items / Notes - User requested the previous README commit should have been pushed to `main` directly (was pushed to a branch). Address separately if needed. -- **New feature: Human @ list override** - - When a message is from a user in `humanList` and contains `<@USER_ID>` mentions: - - Detect which agents are @-mentioned (e.g., a, b, c). - - Determine their order in the current speaking order list (e.g., a โ†’ b โ†’ c). - - Temporarily replace the speaking order with `[a, b, c]` and cycle a โ†’ b โ†’ c. - - After the cycle returns to **a** again, restore the original speaking order list. diff --git a/discord-control-api/package.json b/discord-control-api/package.json deleted file mode 100644 index ef1f8c6..0000000 --- a/discord-control-api/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "dirigent-discord-control-api", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "start": "node server.mjs" - } -} diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs deleted file mode 100644 index 27a21db..0000000 --- a/discord-control-api/server.mjs +++ /dev/null @@ -1,388 +0,0 @@ -import http from "node:http"; - -const port = Number(process.env.PORT || 8790); -const authToken = process.env.AUTH_TOKEN || ""; -const requireAuthToken = String(process.env.REQUIRE_AUTH_TOKEN || "false").toLowerCase() === "true"; -const discordToken = process.env.DISCORD_BOT_TOKEN || ""; -const discordBase = "https://discord.com/api/v10"; - -const enabledActions = { - channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false", - channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false", - memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false", -}; - -const allowedGuildIds = new Set( - String(process.env.ALLOWED_GUILD_IDS || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean), -); - -const allowedCallerIds = new Set( - String(process.env.ALLOWED_CALLER_IDS || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean), -); - -const BIT_VIEW_CHANNEL = 1024n; -const BIT_SEND_MESSAGES = 2048n; -const BIT_READ_MESSAGE_HISTORY = 65536n; - -const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20)); -const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000)); -const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200)); - -function sendJson(res, status, payload) { - res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify(payload)); -} - -function fail(status, code, message, details) { - return { status, code, message, details }; -} - -function readCallerId(req) { - const v = req.headers["x-openclaw-caller-id"] || req.headers["x-caller-id"]; - return typeof v === "string" ? v.trim() : ""; -} - -function ensureControlAuth(req) { - if (requireAuthToken && !authToken) { - throw fail(500, "auth_misconfigured", "REQUIRE_AUTH_TOKEN=true but AUTH_TOKEN is empty"); - } - if (!authToken) return; - const header = req.headers.authorization || ""; - if (header !== `Bearer ${authToken}`) { - throw fail(401, "unauthorized", "invalid or missing bearer token"); - } - - if (allowedCallerIds.size > 0) { - const callerId = readCallerId(req); - if (!callerId || !allowedCallerIds.has(callerId)) { - throw fail(403, "caller_forbidden", "caller is not in ALLOWED_CALLER_IDS", { callerId: callerId || null }); - } - } -} - -function ensureDiscordToken() { - if (!discordToken) { - throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN"); - } -} - -function ensureGuildAllowed(guildId) { - if (allowedGuildIds.size === 0) return; - if (!allowedGuildIds.has(guildId)) { - throw fail(403, "guild_forbidden", "guild is not in ALLOWED_GUILD_IDS", { guildId }); - } -} - -function ensureActionEnabled(action) { - if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) { - throw fail(403, "action_disabled", "channel-private-create is disabled"); - } - if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) { - throw fail(403, "action_disabled", "channel-private-update is disabled"); - } - if (action === "member-list" && !enabledActions.memberList) { - throw fail(403, "action_disabled", "member-list is disabled"); - } -} - -async function discordRequest(path, init = {}) { - ensureDiscordToken(); - const headers = { - Authorization: `Bot ${discordToken}`, - "Content-Type": "application/json", - ...(init.headers || {}), - }; - - const r = await fetch(`${discordBase}${path}`, { ...init, headers }); - const text = await r.text(); - let data = text; - try { - data = text ? JSON.parse(text) : {}; - } catch {} - - if (!r.ok) { - throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data); - } - return data; -} - -function toStringMask(v, fallback) { - if (v === undefined || v === null || v === "") return String(fallback); - if (typeof v === "string") return v; - if (typeof v === "number") return String(Math.floor(v)); - if (typeof v === "bigint") return String(v); - throw fail(400, "invalid_mask", "invalid permission bit mask"); -} - -function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) { - const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY; - const denyDefault = BIT_VIEW_CHANNEL; - - const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault); - const targetAllow = toStringMask(allowMask, allowDefault); - - const overwrites = [ - { - id: guildId, - type: 0, - allow: "0", - deny: everyoneDeny, - }, - ]; - - for (const roleId of allowedRoleIds) { - overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" }); - } - for (const userId of allowedUserIds) { - overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" }); - } - - return overwrites; -} - -function parseFieldList(input) { - if (Array.isArray(input)) return input.map((x) => String(x).trim()).filter(Boolean); - if (typeof input === "string") return input.split(",").map((x) => x.trim()).filter(Boolean); - return []; -} - -function normalizeIdList(value, label) { - const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : []; - if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) { - throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, { - label, - limit: MAX_PRIVATE_MUTATION_TARGETS, - size: arr.length, - }); - } - return arr; -} - -function pick(obj, keys) { - if (!obj || typeof obj !== "object") return obj; - const out = {}; - for (const k of keys) { - if (k in obj) out[k] = obj[k]; - } - return out; -} - -function projectMember(member, fields) { - if (!fields.length) return member; - const base = pick(member, fields); - if (fields.some((f) => f.startsWith("user.")) && member?.user) { - const userFields = fields - .filter((f) => f.startsWith("user.")) - .map((f) => f.slice(5)) - .filter(Boolean); - base.user = pick(member.user, userFields); - } - return base; -} - -async function actionChannelPrivateCreate(body) { - const guildId = String(body.guildId || "").trim(); - const name = String(body.name || "").trim(); - if (!guildId) throw fail(400, "bad_request", "guildId is required"); - if (!name) throw fail(400, "bad_request", "name is required"); - ensureGuildAllowed(guildId); - - const payload = { - name, - type: Number.isInteger(body.type) ? body.type : 0, - parent_id: body.parentId || undefined, - topic: body.topic || undefined, - position: Number.isInteger(body.position) ? body.position : undefined, - nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined, - permission_overwrites: buildPrivateOverwrites({ - guildId, - allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"), - allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"), - allowMask: body.allowMask, - denyEveryoneMask: body.denyEveryoneMask, - }), - }; - - if (body.dryRun === true) { - return { ok: true, action: "channel-private-create", dryRun: true, payload }; - } - - const channel = await discordRequest(`/guilds/${guildId}/channels`, { - method: "POST", - body: JSON.stringify(payload), - }); - - return { ok: true, action: "channel-private-create", channel }; -} - -async function actionChannelPrivateUpdate(body) { - const guildId = String(body.guildId || "").trim(); - const channelId = String(body.channelId || "").trim(); - if (!guildId) throw fail(400, "bad_request", "guildId is required"); - if (!channelId) throw fail(400, "bad_request", "channelId is required"); - ensureGuildAllowed(guildId); - - const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY); - const denyMask = toStringMask(body.denyMask, 0n); - const mode = String(body.mode || "merge").trim(); - if (mode !== "merge" && mode !== "replace") { - throw fail(400, "bad_request", "mode must be merge or replace", { mode }); - } - - const addUserIds = normalizeIdList(body.addUserIds, "addUserIds"); - const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds"); - const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds"); - - const existing = await discordRequest(`/channels/${channelId}`); - const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : []; - - let next = []; - if (mode === "replace") { - // keep @everyone deny if present, otherwise set one - const everyone = existingOverwrites.find((o) => String(o.id) === guildId && Number(o.type) === 0) || { - id: guildId, - type: 0, - allow: "0", - deny: String(BIT_VIEW_CHANNEL), - }; - next.push({ id: String(everyone.id), type: 0, allow: String(everyone.allow || "0"), deny: String(everyone.deny || BIT_VIEW_CHANNEL) }); - } else { - next = existingOverwrites.map((o) => ({ id: String(o.id), type: Number(o.type) === 1 ? 1 : 0, allow: String(o.allow || "0"), deny: String(o.deny || "0") })); - } - - const removeSet = new Set(removeTargetIds); - if (removeSet.size > 0) { - next = next.filter((o) => !removeSet.has(String(o.id))); - } - - const upsert = (id, type) => { - const idx = next.findIndex((o) => String(o.id) === String(id)); - const row = { id: String(id), type, allow: allowMask, deny: denyMask }; - if (idx >= 0) next[idx] = row; - else next.push(row); - }; - - for (const userId of addUserIds) upsert(userId, 1); - for (const roleId of addRoleIds) upsert(roleId, 0); - - const payload = { permission_overwrites: next }; - - if (body.dryRun === true) { - return { ok: true, action: "channel-private-update", dryRun: true, payload, mode }; - } - - const channel = await discordRequest(`/channels/${channelId}`, { - method: "PATCH", - body: JSON.stringify(payload), - }); - - return { ok: true, action: "channel-private-update", mode, channel }; -} - -async function actionMemberList(body) { - const guildId = String(body.guildId || "").trim(); - if (!guildId) throw fail(400, "bad_request", "guildId is required"); - ensureGuildAllowed(guildId); - - const limitRaw = Number(body.limit ?? 100); - const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; - const after = body.after ? String(body.after) : undefined; - const fields = parseFieldList(body.fields); - if (fields.length > MAX_MEMBER_FIELDS) { - throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", { - limit: MAX_MEMBER_FIELDS, - size: fields.length, - }); - } - - const qs = new URLSearchParams(); - qs.set("limit", String(limit)); - if (after) qs.set("after", after); - - const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); - const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members; - - const response = { - ok: true, - action: "member-list", - guildId, - count: Array.isArray(projected) ? projected.length : 0, - fields: fields.length ? fields : undefined, - members: projected, - }; - - const bytes = Buffer.byteLength(JSON.stringify(response), "utf8"); - if (bytes > MAX_MEMBER_RESPONSE_BYTES) { - throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", { - bytes, - limit: MAX_MEMBER_RESPONSE_BYTES, - hint: "reduce limit or set fields projection", - }); - } - - return response; -} - -async function handleAction(body) { - const action = String(body.action || "").trim(); - if (!action) throw fail(400, "bad_request", "action is required"); - ensureActionEnabled(action); - - if (action === "channel-private-create") return await actionChannelPrivateCreate(body); - if (action === "channel-private-update") return await actionChannelPrivateUpdate(body); - if (action === "member-list") return await actionMemberList(body); - - throw fail(400, "unsupported_action", `unsupported action: ${action}`); -} - -const server = http.createServer((req, res) => { - if (req.method === "GET" && req.url === "/health") { - return sendJson(res, 200, { - ok: true, - service: "discord-control-api", - authRequired: !!authToken || requireAuthToken, - actionGates: enabledActions, - guildAllowlistEnabled: allowedGuildIds.size > 0, - limits: { - maxMemberFields: MAX_MEMBER_FIELDS, - maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES, - maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS, - }, - }); - } - - if (req.method !== "POST" || req.url !== "/v1/discord/action") { - return sendJson(res, 404, { error: "not_found" }); - } - - let body = ""; - req.on("data", (chunk) => { - body += chunk; - if (body.length > 2_000_000) req.destroy(); - }); - - req.on("end", async () => { - try { - ensureControlAuth(req); - const parsed = body ? JSON.parse(body) : {}; - const result = await handleAction(parsed); - return sendJson(res, 200, result); - } catch (err) { - return sendJson(res, err?.status || 500, { - error: err?.code || "request_failed", - message: String(err?.message || err), - details: err?.details, - }); - } - }); -}); - -server.listen(port, () => { - console.log(`[discord-control-api] listening on :${port}`); -}); diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index f2c75ff..84e3a80 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -21,7 +21,6 @@ "enableDirigentPolicyTool": true, "enableDebugLogs": false, "debugLogChannelIds": [], - "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", "discordControlCallerId": "agent-main" } diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md deleted file mode 100644 index de9119e..0000000 --- a/docs/DISCORD_CONTROL.md +++ /dev/null @@ -1,150 +0,0 @@ -# Discord Control API - -็›ฎๆ ‡๏ผš่กฅ้ฝ OpenClaw ๅ†…็ฝฎ message ๅทฅๅ…ทๅฝ“ๅ‰ๆœช่ฆ†็›–็š„ไธคไธช่ƒฝๅŠ›๏ผš - -> ็Žฐๅœจๅฏไปฅ้€š่ฟ‡ Dirigent ๆ’ไปถๅ†…็ฝฎ็š„ๅฏ้€‰ๅทฅๅ…ท `discord_control` ็›ดๆŽฅ่ฐƒ็”จ๏ผˆๆ— ้œ€ๆ‰‹ๅ†™ curl๏ผ‰ใ€‚ -> ๆณจๆ„๏ผš่ฏฅๅทฅๅ…ทๆ˜ฏ optional๏ผŒ้œ€่ฆๅœจ agent tools allowlist ไธญๆ˜พๅผๅ…่ฎธ๏ผˆไพ‹ๅฆ‚ๅ…่ฎธ `dirigent` ๆˆ– `discord_control`๏ผ‰ใ€‚ - -1. ๅˆ›ๅปบๆŒ‡ๅฎšๅๅ•ๅฏ่ง็š„็งไบบ้ข‘้“ -2. ๆŸฅ็œ‹ server ๆˆๅ‘˜ๅˆ—่กจ๏ผˆๅˆ†้กต๏ผ‰ - -## Start - -```bash -cd discord-control-api -export DISCORD_BOT_TOKEN='xxx' -# ๅปบ่ฎฎๅฏ็”จ -export AUTH_TOKEN='strong-token' -# optional hard requirement -# export REQUIRE_AUTH_TOKEN=true -# optional action gates -# export ENABLE_CHANNEL_PRIVATE_CREATE=true -# export ENABLE_CHANNEL_PRIVATE_UPDATE=true -# export ENABLE_MEMBER_LIST=true -# optional allowlist -# export ALLOWED_GUILD_IDS='123,456' -# export ALLOWED_CALLER_IDS='agent-main,agent-admin' -# optional limits -# export MAX_MEMBER_FIELDS=20 -# export MAX_MEMBER_RESPONSE_BYTES=500000 -# export MAX_PRIVATE_MUTATION_TARGETS=200 -node server.mjs -``` - -Health: - -```bash -curl -sS http://127.0.0.1:8790/health -``` - -## Unified action endpoint - -`POST /v1/discord/action` - -- Header: `Authorization: Bearer `๏ผˆ่‹ฅ้…็ฝฎ๏ผ‰ -- Header: `X-OpenClaw-Caller-Id: `๏ผˆ่‹ฅ้…็ฝฎไบ† `ALLOWED_CALLER_IDS`๏ผ‰ -- Body: `{ "action": "...", ... }` - ---- - -## Action: channel-private-create - -ไธŽ OpenClaw `channel-create` ๅ‚ๆ•ฐไฟๆŒ้ฃŽๆ ผไธ€่‡ด๏ผŒๅนถๅขžๅŠ ็งๅฏ†่ฆ†็›–ๅ‚ๆ•ฐใ€‚ - -### Request - -```json -{ - "action": "channel-private-create", - "guildId": "123", - "name": "private-room", - "type": 0, - "parentId": "456", - "topic": "secret", - "position": 3, - "nsfw": false, - "allowedUserIds": ["111", "222"], - "allowedRoleIds": ["333"], - "allowMask": "67648", - "denyEveryoneMask": "1024", - "dryRun": false -} -``` - -่ฏดๆ˜Ž๏ผš -- ้ป˜่ฎค deny `@everyone` ็š„ `VIEW_CHANNEL`ใ€‚ -- ้ป˜่ฎค็ป™ allowed targets ๆ”พ่กŒ๏ผš`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`ใ€‚ -- `allowMask/denyEveryoneMask` ไฝฟ็”จ Discord permission bit stringใ€‚ - ---- - -## Action: channel-private-update - -ๅฏน็Žฐๆœ‰้ข‘้“็š„็™ฝๅๅ•/่ฆ†็›–ๆƒ้™ๅšๅขžๅˆ ๆ”นใ€‚ - -### Request - -```json -{ - "action": "channel-private-update", - "guildId": "123", - "channelId": "789", - "mode": "merge", - "addUserIds": ["111"], - "addRoleIds": ["333"], - "removeTargetIds": ["222"], - "allowMask": "67648", - "denyMask": "0", - "dryRun": false -} -``` - -่ฏดๆ˜Ž๏ผš -- `mode=merge`๏ผšๅœจ็Žฐๆœ‰่ฆ†็›–ๅŸบ็ก€ไธŠๅขžๅˆ  -- `mode=replace`๏ผš้‡ๅปบ่ฆ†็›–๏ผˆไฟ็•™/่กฅไธŠ @everyone deny๏ผ‰ - ---- - -## Action: member-list - -### Request - -```json -{ - "action": "member-list", - "guildId": "123", - "limit": 100, - "after": "0", - "fields": ["user.id", "user.username", "nick", "roles", "joined_at"] -} -``` - -่ฏดๆ˜Ž๏ผš -- `limit` 1~1000 -- `after` ็”จไบŽๅˆ†้กต๏ผˆDiscord snowflake๏ผ‰ -- `fields` ๅฏ้€‰๏ผšๅญ—ๆฎต่ฃๅ‰ช๏ผŒๅ‡ๅฐ่ฟ”ๅ›žไฝ“๏ผ›ๅฏ็”จ `user.xxx` ้€‰ๅญๅญ—ๆฎต - ---- - -## Curl examples - -็คบไพ‹ๆ–‡ไปถ๏ผš`docs/EXAMPLES.discord-control.json` - -ๅฟซ้€Ÿๆฃ€ๆŸฅ่„šๆœฌ๏ผš - -```bash -./scripts/smoke-discord-control.sh -# with env -# AUTH_TOKEN=xxx CALLER_ID=agent-main GUILD_ID=123 CHANNEL_ID=456 ./scripts/smoke-discord-control.sh -``` - -## Notes - -้‰ดๆƒไธŽๅ†…็ฝฎ้ฃŽๆ ผๅฏน้ฝ๏ผˆ็ฎ€ๅŒ–็‰ˆ๏ผ‰๏ผš -- ๆŽงๅˆถ้ข token๏ผš`AUTH_TOKEN` / `REQUIRE_AUTH_TOKEN` -- ่ฐƒ็”จ่€… allowlist๏ผš`ALLOWED_CALLER_IDS`๏ผˆ้…ๅˆ `X-OpenClaw-Caller-Id`๏ผ‰ -- action gate๏ผš`ENABLE_CHANNEL_PRIVATE_CREATE` / `ENABLE_MEMBER_LIST` -- guild allowlist๏ผš`ALLOWED_GUILD_IDS` - -- ่ฟ™ไธๆ˜ฏ bot ่‡ชๆๆƒๅทฅๅ…ท๏ผ›bot ไป้œ€็”ฑ็ฎก็†ๅ‘˜ๆŽˆไบˆ่ถณๅคŸๆƒ้™ใ€‚ -- ่‹ฅๆ— ๆƒ้™๏ผŒDiscord API ไผš่ฟ”ๅ›ž 403 ๅนถ้€ไผ ้”™่ฏฏ็ป†่Š‚ใ€‚ diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index e04cbb5..6eb7378 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -27,25 +27,22 @@ The script prints JSON for: You can merge this snippet manually into your `openclaw.json`. -## Installer script (with rollback) - -For production-like install with automatic rollback on error (Node-only installer): +## Installer script ```bash -node ./scripts/install-dirigent-openclaw.mjs --install +node ./scripts/install.mjs --install +# optional port override +node ./scripts/install.mjs --install --no-reply-port 8787 # or wrapper ./scripts/install-dirigent-openclaw.sh --install ``` -Uninstall (revert all recorded config changes): +Uninstall: ```bash -node ./scripts/install-dirigent-openclaw.mjs --uninstall +node ./scripts/install.mjs --uninstall # or wrapper ./scripts/install-dirigent-openclaw.sh --uninstall -# or specify a record explicitly -# RECORD_FILE=~/.openclaw/dirigent-install-records/dirigent-YYYYmmddHHMMSS.json \ -# node ./scripts/install-dirigent-openclaw.mjs --uninstall ``` Environment overrides: @@ -64,12 +61,10 @@ Environment overrides: The script: - writes via `openclaw config set ... --json` -- creates config backup first -- restores backup automatically if any install step fails -- restarts gateway during install, then validates `dirigentway/no-reply` is visible via `openclaw models list/status` -- writes a change record for every install/uninstall: - - directory: `~/.openclaw/dirigent-install-records/` - - latest pointer: `~/.openclaw/dirigent-install-record-latest.json` +- installs plugin + no-reply-api into `~/.openclaw/plugins` +- updates `plugins.entries.dirigent` and `models.providers.` +- supports `--no-reply-port` (also written into `plugins.entries.dirigent.config.noReplyPort`) +- does not maintain install/uninstall record files Policy state semantics: - channel policy file is loaded once into memory on startup diff --git a/docs/PR_SUMMARY.md b/docs/PR_SUMMARY.md index 64be235..f8c8502 100644 --- a/docs/PR_SUMMARY.md +++ b/docs/PR_SUMMARY.md @@ -51,5 +51,5 @@ This PR delivers two tracks: ## Rollback - Disable plugin entry or remove plugin path from OpenClaw config -- Stop `discord-control-api` process +- (Legacy note) `discord-control-api` sidecar has been removed; Discord control is in-plugin now - Keep no-reply API stopped if not needed diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 53c0635..3c192d0 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -8,7 +8,7 @@ 1. Dirigent ๅŸบ็ก€้™ๆ€ไธŽ่„šๆœฌๆต‹่ฏ• 2. no-reply-api ้š”็ฆป้›†ๆˆๆต‹่ฏ• -3. discord-control-api ๅŠŸ่ƒฝๆต‹่ฏ•๏ผˆdryRun + ๅฎžๆ“๏ผ‰ +3. ๏ผˆๅކๅฒ๏ผ‰discord-control-api ๅŠŸ่ƒฝๆต‹่ฏ•๏ผˆdryRun + ๅฎžๆ“๏ผŒๅฝ“ๅ‰็‰ˆๆœฌๅทฒ่ฟ็งปไธบ in-plugin๏ผ‰ ๆœช่ฆ†็›–๏ผš @@ -53,7 +53,7 @@ make check check-rules test-api --- -### B. discord-control-api dryRun + ๅฎžๆ“ๆต‹่ฏ• +### B. ๏ผˆๅކๅฒ๏ผ‰discord-control-api dryRun + ๅฎžๆ“ๆต‹่ฏ•๏ผˆๅฝ“ๅ‰็‰ˆๆœฌๅทฒ่ฟ็งป๏ผ‰ ๆ‰ง่กŒๅ†…ๅฎนไธŽ็ป“ๆžœ๏ผš @@ -113,7 +113,7 @@ make check check-rules test-api ### 2) ๅ›žๅฝ’ๆต‹่ฏ• -- discord-control-api ๅผ•ๅ…ฅๅŽ๏ผŒไธๅฝฑๅ“ Dirigent ๅŽŸๆœ‰ๆต็จ‹ +- ๏ผˆๅކๅฒ็ป“่ฎบ๏ผ‰discord-control-api ๅผ•ๅ…ฅๅŽ๏ผŒไธๅฝฑๅ“ Dirigent ๅŽŸๆœ‰ๆต็จ‹๏ผ›็Žฐๅทฒ่ฟ็งปไธบ in-plugin ๅฎž็Žฐ - ่ง„ๅˆ™ๆ ก้ชŒ่„šๆœฌๅœจๆœ€ๆ–ฐไปฃ็ ็ปง็ปญ็จณๅฎš้€š่ฟ‡ ### 3) ่ฟ่กŒไธŽๅฎ‰ๅ…จๆ ก้ชŒ diff --git a/docs/TURN-WAKEUP-PROBLEM.md b/docs/TURN-WAKEUP-PROBLEM.md index d9c42ea..f78eb72 100644 --- a/docs/TURN-WAKEUP-PROBLEM.md +++ b/docs/TURN-WAKEUP-PROBLEM.md @@ -78,7 +78,7 @@ When current speaker NO_REPLYs, have **that bot** send a brief handoff message i **Challenges:** - Adds visible noise to the channel (could use a convention like a specific emoji reaction) - The no-reply'd bot can't send messages (it was silenced) -- Could use the discord-control-api to send as a different bot +- Could use in-plugin Discord REST control to send as a different bot (sidecar removed) ### 6. Timer-Based Retry (Pragmatic) @@ -93,7 +93,7 @@ After advancing the turn, set a short timer (e.g., 2-3 seconds). If no new messa **Solution 5 (Bot-to-Bot Handoff)** is the most pragmatic with current constraints. The implementation would be: 1. In the `message_sent` hook, after detecting NO_REPLY and advancing the turn: -2. Use the discord-control-api to send a short message (e.g., `[่ฝฎ่ฝฌ]` or a specific emoji) from the **next speaker's bot account** in the channel +2. Use in-plugin Discord REST control to send a short message (e.g., `[่ฝฎ่ฝฌ]` or a specific emoji) from the **next speaker's bot account** in the channel 3. This real Discord message triggers OpenClaw to route it to all agents 4. The turn manager allows only the (now-current) next speaker to respond 5. The next speaker sees the original conversation context in their session history and responds appropriately diff --git a/package.json b/package.json index 44692d6..cc009cf 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "@hangman-lab/dirigent", - "version": "0.2.0", + "version": "0.3.0", "description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw", "type": "module", "files": [ "dist/", "plugin/", "no-reply-api/", - "discord-control-api/", + "docs/", - "scripts/install-dirigent-openclaw.mjs", + "scripts/install.mjs", "docker-compose.yml", "Makefile", "README.md", @@ -17,9 +17,10 @@ "TASKLIST.md" ], "scripts": { - "prepare": "mkdir -p dist/dirigent && cp plugin/* dist/dirigent/", - "postinstall": "node scripts/install-dirigent-openclaw.mjs --install", - "uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall" + "prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/", + "postinstall": "node scripts/install.mjs --install", + "uninstall": "node scripts/install.mjs --uninstall", + "update": "node scripts/install.mjs --update" }, "keywords": [ "openclaw", diff --git a/plugin/README.md b/plugin/README.md index cd6592d..add6333 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -39,7 +39,7 @@ Unified optional tool: - `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["๐Ÿ”š"]) - `enableDiscordControlTool` (default true) -- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`) +- Discord control actions are executed in-plugin via Discord REST API (no `discordControlApiBaseUrl` needed) - `discordControlApiToken` - `discordControlCallerId` - `enableDebugLogs` (default false) diff --git a/plugin/channel-resolver.ts b/plugin/channel-resolver.ts new file mode 100644 index 0000000..c795b1b --- /dev/null +++ b/plugin/channel-resolver.ts @@ -0,0 +1,73 @@ +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/dirigent-command.ts b/plugin/commands/dirigent-command.ts new file mode 100644 index 0000000..7232816 --- /dev/null +++ b/plugin/commands/dirigent-command.ts @@ -0,0 +1,133 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js"; +import type { DirigentConfig } from "../rules.js"; + +type CommandDeps = { + api: OpenClawPluginApi; + baseConfig: DirigentConfig; + policyState: { filePath: string; channelPolicies: Record }; + persistPolicies: (api: OpenClawPluginApi) => void; + ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; +}; + +export function registerDirigentCommand(deps: CommandDeps): void { + const { api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded, getLivePluginConfig } = deps; + + api.registerCommand({ + name: "dirigent", + description: "Dirigent runtime commands", + acceptsArgs: true, + handler: async (cmdCtx) => { + const args = cmdCtx.args || ""; + const parts = args.trim().split(/\s+/); + const subCmd = parts[0] || "help"; + + if (subCmd === "help") { + return { + text: + `Dirigent commands:\n` + + `/dirigent status - Show current channel status\n` + + `/dirigent turn-status - Show turn-based speaking status\n` + + `/dirigent turn-advance - Manually advance turn\n` + + `/dirigent turn-reset - Reset turn order\n` + + `/dirigent_policy get \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 }) }; + } + + return { text: `Unknown subcommand: ${subCmd}`, isError: true }; + }, + }); + + api.registerCommand({ + name: "dirigent_policy", + description: "Dirigent channel policy CRUD", + acceptsArgs: true, + handler: async (cmdCtx) => { + const live = getLivePluginConfig(api, 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/core/channel-members.ts b/plugin/core/channel-members.ts new file mode 100644 index 0000000..fab4e50 --- /dev/null +++ b/plugin/core/channel-members.ts @@ -0,0 +1,141 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { buildUserIdToAccountIdMap } from "./identity.js"; + +const PERM_VIEW_CHANNEL = 1n << 10n; +const PERM_ADMINISTRATOR = 1n << 3n; + +function toBigIntPerm(v: unknown): bigint { + if (typeof v === "bigint") return v; + if (typeof v === "number") return BigInt(Math.trunc(v)); + if (typeof v === "string" && v.trim()) { + try { + return BigInt(v.trim()); + } catch { + return 0n; + } + } + return 0n; +} + +function roleOrMemberType(v: unknown): number { + if (typeof v === "number") return v; + if (typeof v === "string" && v.toLowerCase() === "member") return 1; + return 0; +} + +async function discordRequest(token: string, method: string, path: string): Promise<{ ok: boolean; status: number; json: any; text: string }> { + const r = await fetch(`https://discord.com/api/v10${path}`, { + method, + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + }, + }); + const text = await r.text(); + let json: any = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { ok: r.ok, status: r.status, json, text }; +} + +function canViewChannel(member: any, guildId: string, guildRoles: Map, channelOverwrites: any[]): boolean { + const roleIds: string[] = Array.isArray(member?.roles) ? member.roles : []; + let perms = guildRoles.get(guildId) || 0n; + for (const rid of roleIds) perms |= guildRoles.get(rid) || 0n; + + if ((perms & PERM_ADMINISTRATOR) !== 0n) return true; + + let everyoneAllow = 0n; + let everyoneDeny = 0n; + for (const ow of channelOverwrites) { + if (String(ow?.id || "") === guildId && roleOrMemberType(ow?.type) === 0) { + everyoneAllow = toBigIntPerm(ow?.allow); + everyoneDeny = toBigIntPerm(ow?.deny); + break; + } + } + perms = (perms & ~everyoneDeny) | everyoneAllow; + + let roleAllow = 0n; + let roleDeny = 0n; + for (const ow of channelOverwrites) { + if (roleOrMemberType(ow?.type) !== 0) continue; + const id = String(ow?.id || ""); + if (id !== guildId && roleIds.includes(id)) { + roleAllow |= toBigIntPerm(ow?.allow); + roleDeny |= toBigIntPerm(ow?.deny); + } + } + perms = (perms & ~roleDeny) | roleAllow; + + for (const ow of channelOverwrites) { + if (roleOrMemberType(ow?.type) !== 1) continue; + if (String(ow?.id || "") === String(member?.user?.id || "")) { + const allow = toBigIntPerm(ow?.allow); + const deny = toBigIntPerm(ow?.deny); + perms = (perms & ~deny) | allow; + break; + } + } + + return (perms & PERM_VIEW_CHANNEL) !== 0n; +} + +function getAnyDiscordToken(api: OpenClawPluginApi): 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)) { + if (typeof rec?.token === "string" && rec.token) return rec.token; + } + return undefined; +} + +export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise { + const token = getAnyDiscordToken(api); + if (!token) return []; + + const ch = await discordRequest(token, "GET", `/channels/${channelId}`); + if (!ch.ok) return []; + const guildId = String(ch.json?.guild_id || ""); + if (!guildId) return []; + + const rolesResp = await discordRequest(token, "GET", `/guilds/${guildId}/roles`); + if (!rolesResp.ok) return []; + const rolePerms = new Map(); + for (const r of Array.isArray(rolesResp.json) ? rolesResp.json : []) { + rolePerms.set(String(r?.id || ""), toBigIntPerm(r?.permissions)); + } + + const members: any[] = []; + let after = ""; + while (true) { + const q = new URLSearchParams({ limit: "1000" }); + if (after) q.set("after", after); + const mResp = await discordRequest(token, "GET", `/guilds/${guildId}/members?${q.toString()}`); + if (!mResp.ok) return []; + const batch = Array.isArray(mResp.json) ? mResp.json : []; + members.push(...batch); + if (batch.length < 1000) break; + after = String(batch[batch.length - 1]?.user?.id || ""); + if (!after) break; + } + + const overwrites = Array.isArray(ch.json?.permission_overwrites) ? ch.json.permission_overwrites : []; + const visibleUserIds = members + .filter((m) => canViewChannel(m, guildId, rolePerms, overwrites)) + .map((m) => String(m?.user?.id || "")) + .filter(Boolean); + + const userToAccount = buildUserIdToAccountIdMap(api); + const out = new Set(); + for (const uid of visibleUserIds) { + const aid = userToAccount.get(uid); + if (aid) out.add(aid); + } + return [...out]; +} diff --git a/plugin/core/identity.ts b/plugin/core/identity.ts new file mode 100644 index 0000000..6b35d2b --- /dev/null +++ b/plugin/core/identity.ts @@ -0,0 +1,79 @@ +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/live-config.ts b/plugin/core/live-config.ts new file mode 100644 index 0000000..8da04af --- /dev/null +++ b/plugin/core/live-config.ts @@ -0,0 +1,23 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { DirigentConfig } from "../rules.js"; + +export function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig { + const root = (api.config as Record) || {}; + const plugins = (root.plugins as Record) || {}; + const entries = (plugins.entries as Record) || {}; + const entry = (entries.dirigent as Record) || (entries.whispergate as Record) || {}; + const cfg = (entry.config as Record) || {}; + if (Object.keys(cfg).length > 0) { + return { + enableDiscordControlTool: true, + enableDirigentPolicyTool: true, + enableDebugLogs: false, + debugLogChannelIds: [], + noReplyPort: 8787, + schedulingIdentifier: "โžก๏ธ", + waitIdentifier: "๐Ÿ‘ค", + ...cfg, + } as DirigentConfig; + } + return fallback; +} diff --git a/plugin/core/mentions.ts b/plugin/core/mentions.ts new file mode 100644 index 0000000..6fa041e --- /dev/null +++ b/plugin/core/mentions.ts @@ -0,0 +1,31 @@ +import type { DirigentConfig } from "../rules.js"; + +function userIdFromToken(token: string): string | undefined { + try { + const segment = token.split(".")[0]; + const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); + return Buffer.from(padded, "base64").toString("utf8"); + } catch { + return undefined; + } +} + +export function extractMentionedUserIds(content: string): string[] { + const regex = /<@!?(\d+)>/g; + const ids: string[] = []; + const seen = new Set(); + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null) { + const id = match[1]; + if (!seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + return ids; +} + +export function getModeratorUserId(config: DirigentConfig): string | undefined { + if (!config.moderatorBotToken) return undefined; + return userIdFromToken(config.moderatorBotToken); +} diff --git a/plugin/core/moderator-discord.ts b/plugin/core/moderator-discord.ts new file mode 100644 index 0000000..4bca80d --- /dev/null +++ b/plugin/core/moderator-discord.ts @@ -0,0 +1,49 @@ +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; + } +} + +export function resolveDiscordUserId(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 async function sendModeratorMessage( + token: string, + channelId: string, + content: string, + logger: { info: (msg: string) => void; warn: (msg: string) => void }, +): Promise { + try { + const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ content }), + }); + if (!r.ok) { + const text = await r.text(); + logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`); + return false; + } + logger.info(`dirigent: moderator message sent to channel=${channelId}`); + return true; + } catch (err) { + logger.warn(`dirigent: moderator send error: ${String(err)}`); + return false; + } +} diff --git a/plugin/core/no-reply-process.ts b/plugin/core/no-reply-process.ts new file mode 100644 index 0000000..ce43217 --- /dev/null +++ b/plugin/core/no-reply-process.ts @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; + +let noReplyProcess: ChildProcess | null = null; + +export function startNoReplyApi( + logger: { info: (m: string) => void; warn: (m: string) => void }, + pluginDir: string, + port = 8787, +): void { + logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`); + + if (noReplyProcess) { + logger.info("dirigent: no-reply API already running, skipping"); + return; + } + + const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs"); + logger.info(`dirigent: resolved serverPath=${serverPath}`); + + if (!fs.existsSync(serverPath)) { + logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`); + return; + } + + logger.info("dirigent: no-reply API server found, spawning process..."); + + noReplyProcess = spawn(process.execPath, [serverPath], { + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`)); + noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`)); + + noReplyProcess.on("exit", (code, signal) => { + logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`); + noReplyProcess = null; + }); + + logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`); +} + +export function stopNoReplyApi(logger: { info: (m: string) => void }): void { + if (!noReplyProcess) return; + logger.info("dirigent: stopping no-reply API"); + noReplyProcess.kill("SIGTERM"); + noReplyProcess = null; +} diff --git a/plugin/core/session-state.ts b/plugin/core/session-state.ts new file mode 100644 index 0000000..108bef7 --- /dev/null +++ b/plugin/core/session-state.ts @@ -0,0 +1,31 @@ +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 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 new file mode 100644 index 0000000..3743882 --- /dev/null +++ b/plugin/core/turn-bootstrap.ts @@ -0,0 +1,127 @@ +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 new file mode 100644 index 0000000..a38760a --- /dev/null +++ b/plugin/core/utils.ts @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..dc176e5 --- /dev/null +++ b/plugin/decision-input.ts @@ -0,0 +1,37 @@ +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/before-message-write.ts b/plugin/hooks/before-message-write.ts new file mode 100644 index 0000000..faee8ba --- /dev/null +++ b/plugin/hooks/before-message-write.ts @@ -0,0 +1,185 @@ +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; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; + shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; + ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | 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; +}; + +export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void { + const { + api, + baseConfig, + policyState, + sessionAllowed, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + ensureTurnOrder, + resolveDiscordUserId, + sendModeratorMessage, + } = deps; + + api.on("before_message_write", (event, ctx) => { + try { + api.logger.info( + `dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`, + ); + + const key = ctx.sessionKey; + let channelId: string | undefined; + let accountId: string | undefined; + + if (key) { + channelId = sessionChannelId.get(key); + accountId = sessionAccountId.get(key); + } + + let content = ""; + const msg = (event as Record).message as Record | undefined; + if (msg) { + const role = msg.role as string | undefined; + if (role && role !== "assistant") 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 = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); + + const trimmed = content.trim(); + const isEmpty = trimmed.length === 0; + const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/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; + const wasNoReply = isEmpty || 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) { + api.logger.info(`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`); + + if (wasAllowed === undefined) return; + + if (wasAllowed === false) { + sessionAllowed.delete(key); + api.logger.info( + `dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, + ); + return; + } + + void ensureTurnOrder(api, channelId); + const nextSpeaker = onSpeakerDone(channelId, accountId, true); + sessionAllowed.delete(key); + sessionTurnHandled.add(key); + + api.logger.info( + `dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + ); + + if (!nextSpeaker) { + if (shouldDebugLog(live, channelId)) { + api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`); + } + return; + } + + if (live.moderatorBotToken) { + const nextUserId = resolveDiscordUserId(api, nextSpeaker); + if (nextUserId) { + const schedulingId = live.schedulingIdentifier || "โžก๏ธ"; + const handoffMsg = `<@${nextUserId}>${schedulingId}`; + void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { + api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`); + }); + } else { + api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); + } + } + } else if (hasEndSymbol) { + void ensureTurnOrder(api, channelId); + const nextSpeaker = onSpeakerDone(channelId, accountId, false); + sessionAllowed.delete(key); + sessionTurnHandled.add(key); + + api.logger.info( + `dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + ); + } else { + api.logger.info(`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`); + return; + } + } catch (err) { + api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`); + } + }); +} diff --git a/plugin/hooks/before-model-resolve.ts b/plugin/hooks/before-model-resolve.ts new file mode 100644 index 0000000..0b551f6 --- /dev/null +++ b/plugin/hooks/before-model-resolve.ts @@ -0,0 +1,168 @@ +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"; + +type DebugConfig = { + enableDebugLogs?: boolean; + debugLogChannelIds?: string[]; +}; + +type DecisionRecord = { + decision: Decision; + createdAt: number; + needsRestore?: boolean; +}; + +type BeforeModelResolveDeps = { + api: OpenClawPluginApi; + baseConfig: DirigentConfig; + sessionDecision: Map; + sessionAllowed: Map; + sessionChannelId: Map; + sessionAccountId: Map; + policyState: { channelPolicies: Record }; + DECISION_TTL_MS: number; + ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; + resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined; + pruneDecisionMap: () => void; + shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; + ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | void; +}; + +export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void { + const { + api, + baseConfig, + sessionDecision, + sessionAllowed, + sessionChannelId, + sessionAccountId, + policyState, + DECISION_TTL_MS, + ensurePolicyStateLoaded, + getLivePluginConfig, + resolveAccountId, + pruneDecisionMap, + shouldDebugLog, + ensureTurnOrder, + } = deps; + + api.on("before_model_resolve", async (event, ctx) => { + const key = ctx.sessionKey; + if (!key) return; + + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + + const prompt = ((event as Record).prompt as string) || ""; + + if (live.enableDebugLogs) { + api.logger.info( + `dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + + `promptPreview=${prompt.slice(0, 300)}`, + ); + } + + const derived = deriveDecisionInputFromPrompt({ + prompt, + messageProvider: ctx.messageProvider, + sessionKey: key, + ctx: ctx as Record, + event: event as Record, + }); + + const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); + if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; + + if (derived.channelId) { + sessionChannelId.set(key, derived.channelId); + } + const resolvedAccountId = resolveAccountId(api, ctx.agentId || ""); + if (resolvedAccountId) { + sessionAccountId.set(key, resolvedAccountId); + } + + let rec = sessionDecision.get(key); + if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { + if (rec) sessionDecision.delete(key); + const decision = evaluateDecision({ + config: live, + channel: derived.channel, + channelId: derived.channelId, + channelPolicies: policyState.channelPolicies as Record, + 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 (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: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, + ); + return { + providerOverride: live.noReplyProvider, + modelOverride: live.noReplyModel, + }; + } + sessionAllowed.set(key, true); + } + } + + if (!rec.decision.shouldUseNoReply) { + if (rec.needsRestore) { + sessionDecision.delete(key); + return { + providerOverride: undefined, + modelOverride: undefined, + }; + } + return; + } + + rec.needsRestore = true; + sessionDecision.set(key, rec); + + if (live.enableDebugLogs) { + const hasConvMarker2 = prompt.includes("Conversation info (untrusted metadata):"); + api.logger.info( + `dirigent: DEBUG_NO_REPLY_TRIGGER 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 ?? "")} ` + + `decision=${rec.decision.reason} ` + + `shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` + + `hasConvMarker=${hasConvMarker2} promptLen=${prompt.length}`, + ); + } + + api.logger.info( + `dirigent: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, + ); + + return { + providerOverride: live.noReplyProvider, + modelOverride: live.noReplyModel, + }; + }); +} diff --git a/plugin/hooks/before-prompt-build.ts b/plugin/hooks/before-prompt-build.ts new file mode 100644 index 0000000..2c8331e --- /dev/null +++ b/plugin/hooks/before-prompt-build.ts @@ -0,0 +1,134 @@ +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; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; + 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, + getLivePluginConfig, + shouldDebugLog, + buildEndMarkerInstruction, + buildSchedulingIdentifierInstruction, + buildAgentIdentity, + } = deps; + + api.on("before_prompt_build", async (event, ctx) => { + const key = ctx.sessionKey; + if (!key) return; + + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) 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 = idStr + "\n\n"; + } + + let schedulingInstruction = ""; + if (isGroupChat) { + schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId); + } + + sessionInjected.add(key); + + api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); + return { prependContext: identity + instruction + schedulingInstruction }; + }); +} diff --git a/plugin/hooks/message-received.ts b/plugin/hooks/message-received.ts new file mode 100644 index 0000000..3629a15 --- /dev/null +++ b/plugin/hooks/message-received.ts @@ -0,0 +1,115 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js"; +import { extractDiscordChannelId } from "../channel-resolver.js"; +import type { DirigentConfig } from "../rules.js"; + +type DebugConfig = { + enableDebugLogs?: boolean; + debugLogChannelIds?: string[]; +}; + +type MessageReceivedDeps = { + api: OpenClawPluginApi; + baseConfig: DirigentConfig; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => 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; +}; + +export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { + const { + api, + baseConfig, + getLivePluginConfig, + shouldDebugLog, + debugCtxSummary, + ensureTurnOrder, + getModeratorUserId, + recordChannelAccount, + extractMentionedUserIds, + buildUserIdToAccountIdMap, + } = 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 = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; + if (shouldDebugLog(livePre, preChannelId)) { + api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); + } + + 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) : ""); + + const moderatorUserId = getModeratorUserId(livePre); + if (moderatorUserId && from === moderatorUserId) { + if (shouldDebugLog(livePre, preChannelId)) { + api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`); + } + } else { + const humanList = livePre.humanList || livePre.bypassUserIds || []; + const isHuman = humanList.includes(from); + const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; + + 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`); + } + } + + if (isHuman) { + const messageContent = ((e as Record).content as string) || ((e as Record).text as string) || ""; + const mentionedUserIds = extractMentionedUserIds(messageContent); + + if (mentionedUserIds.length > 0) { + const userIdMap = buildUserIdToAccountIdMap(api); + const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid); + + if (mentionedAccountIds.length > 0) { + await ensureTurnOrder(api, preChannelId); + const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds); + if (overrideSet) { + api.logger.info( + `dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`, + ); + if (shouldDebugLog(livePre, preChannelId)) { + api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`); + } + } else { + onNewMessage(preChannelId, senderAccountId, isHuman); + } + } else { + onNewMessage(preChannelId, senderAccountId, isHuman); + } + } else { + onNewMessage(preChannelId, senderAccountId, isHuman); + } + } else { + onNewMessage(preChannelId, senderAccountId, isHuman); + } + + if (shouldDebugLog(livePre, preChannelId)) { + api.logger.info( + `dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`, + ); + } + } + } + } catch (err) { + api.logger.warn(`dirigent: message hook failed: ${String(err)}`); + } + }); +} diff --git a/plugin/hooks/message-sent.ts b/plugin/hooks/message-sent.ts new file mode 100644 index 0000000..2c87284 --- /dev/null +++ b/plugin/hooks/message-sent.ts @@ -0,0 +1,123 @@ +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; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; + resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined; + sendModeratorMessage: ( + botToken: string, + channelId: string, + content: string, + logger: { info: (m: string) => void; warn: (m: string) => void }, + ) => Promise; +}; + +export function registerMessageSentHook(deps: MessageSentDeps): void { + const { + api, + baseConfig, + policyState, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + resolveDiscordUserId, + sendModeratorMessage, + } = 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 = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); + + const trimmed = content.trim(); + const isEmpty = trimmed.length === 0; + const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/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; + const wasNoReply = isEmpty || isNoReply; + + if (key && sessionTurnHandled.has(key)) { + sessionTurnHandled.delete(key); + api.logger.info( + `dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`, + ); + return; + } + + if (hasWaitIdentifier) { + setWaitingForHuman(channelId); + api.logger.info( + `dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`, + ); + return; + } + + if (wasNoReply || hasEndSymbol) { + const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); + const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol"; + api.logger.info( + `dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, + ); + + 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 a137357..3ef862f 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,88 +1,45 @@ import fs from "node:fs"; import path from "node:path"; -import { spawn, type ChildProcess } from "node:child_process"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js"; -import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js"; +import type { DirigentConfig } from "./rules.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; - -// โ”€โ”€ No-Reply API child process lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -let noReplyProcess: ChildProcess | null = null; - -function startNoReplyApi(logger: { info: (m: string) => void; warn: (m: string) => void }, pluginDir: string, port = 8787): void { - logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`); - - if (noReplyProcess) { - logger.info("dirigent: no-reply API already running, skipping"); - return; - } - - const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs"); - logger.info(`dirigent: resolved serverPath=${serverPath}`); - - if (!fs.existsSync(serverPath)) { - logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`); - return; - } - - logger.info(`dirigent: no-reply API server found, spawning process...`); - - noReplyProcess = spawn(process.execPath, [serverPath], { - env: { ...process.env, PORT: String(port) }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); - - noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`)); - noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`)); - - noReplyProcess.on("exit", (code, signal) => { - logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`); - noReplyProcess = null; - }); - - logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`); -} - -function stopNoReplyApi(logger: { info: (m: string) => void }): void { - if (!noReplyProcess) return; - logger.info("dirigent: stopping no-reply API"); - noReplyProcess.kill("SIGTERM"); - noReplyProcess = null; -} - -type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; - -type DecisionRecord = { - decision: Decision; - createdAt: number; - needsRestore?: boolean; -}; - -type PolicyState = { - filePath: string; - channelPolicies: Record; -}; +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 { registerDirigentTools } from "./tools/register-tools.js"; +import { getLivePluginConfig } from "./core/live-config.js"; +import { ensurePolicyStateLoaded, persistPolicies, policyState } from "./policy/store.js"; +import { buildAgentIdentity, buildUserIdToAccountIdMap, resolveAccountId } from "./core/identity.js"; +import { extractMentionedUserIds, getModeratorUserId } from "./core/mentions.js"; +import { ensureTurnOrder, recordChannelAccount } from "./core/turn-bootstrap.js"; +import { debugCtxSummary, pickDefined, shouldDebugLog } from "./core/utils.js"; +import { resolveDiscordUserId, sendModeratorMessage } from "./core/moderator-discord.js"; +import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js"; +import { + DECISION_TTL_MS, + pruneDecisionMap, + sessionAccountId, + sessionAllowed, + sessionChannelId, + sessionDecision, + sessionInjected, + sessionTurnHandled, +} from "./core/session-state.js"; type DebugConfig = { enableDebugLogs?: boolean; debugLogChannelIds?: string[]; }; -const sessionDecision = new Map(); -const sessionAllowed = new Map(); // Track if session was allowed to speak (true) or forced no-reply (false) -const sessionInjected = new Set(); // Track which sessions have already injected the end marker -const sessionChannelId = new Map(); // Track sessionKey -> channelId mapping -const sessionAccountId = new Map(); // Track sessionKey -> accountId mapping -const sessionTurnHandled = new Set(); // Track sessions where turn was already advanced in before_message_write -const MAX_SESSION_DECISIONS = 2000; -const DECISION_TTL_MS = 5 * 60 * 1000; - -function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string): string { +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) 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; } @@ -91,406 +48,6 @@ function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): str 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.`; } -const policyState: PolicyState = { - filePath: "", - channelPolicies: {}, -}; - -function normalizeChannel(ctx: Record): string { - const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; - for (const c of candidates) { - if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); - } - return ""; -} - -/** - * Extract the actual Discord channel ID from a conversationId or "to" field. - * OpenClaw uses format "channel:" for Discord conversations. - * Also tries event.to and event.metadata.to as fallbacks. - */ -function extractDiscordChannelId(ctx: Record, event?: Record): string | undefined { - const candidates: unknown[] = [ - ctx.conversationId, - event?.to, - (event?.metadata as Record)?.to, - ]; - for (const c of candidates) { - if (typeof c === "string" && c.trim()) { - const s = c.trim(); - // Handle "channel:123456" format - if (s.startsWith("channel:")) { - const id = s.slice("channel:".length); - if (/^\d+$/.test(id)) return id; - } - // Handle "discord:channel:123456" format - if (s.startsWith("discord:channel:")) { - const id = s.slice("discord:channel:".length); - if (/^\d+$/.test(id)) return id; - } - // If it's a raw snowflake (all digits), use directly - if (/^\d{15,}$/.test(s)) return s; - } - } - return undefined; -} - -function normalizeSender(event: Record, ctx: Record): string | undefined { - const direct = [ctx.senderId, ctx.from, event.from]; - for (const v of direct) { - if (typeof v === "string" && v.trim()) return v.trim(); - } - - const meta = (event.metadata || ctx.metadata) as Record | undefined; - if (!meta) return undefined; - const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id]; - for (const v of metaCandidates) { - if (typeof v === "string" && v.trim()) return v.trim(); - } - - return undefined; -} - -function extractUntrustedConversationInfo(text: string): Record | 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; - } -} - -function deriveDecisionInputFromPrompt( - prompt: string, - messageProvider?: string, - channelIdFromCtx?: string, -): { - channel: string; - channelId?: string; - senderId?: string; - content: string; - conv: Record; -} { - const conv = extractUntrustedConversationInfo(prompt) || {}; - const channel = (messageProvider || "").toLowerCase(); - - // Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id - let channelId = channelIdFromCtx; - if (!channelId) { - // Try chat_id field (format "channel:123456") - if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) { - channelId = conv.chat_id.slice("channel:".length); - } - // Try conversation_label (format "Guild #name channel id:123456") - if (!channelId && typeof conv.conversation_label === "string") { - const labelMatch = conv.conversation_label.match(/channel id:(\d+)/); - if (labelMatch) channelId = labelMatch[1]; - } - // Try channel_id field directly - if (!channelId && typeof conv.channel_id === "string" && conv.channel_id) { - channelId = conv.channel_id; - } - } - - const senderId = - (typeof conv.sender_id === "string" && conv.sender_id) || - (typeof conv.sender === "string" && conv.sender) || - undefined; - - return { channel, channelId, senderId, content: prompt, conv }; -} - -function pruneDecisionMap(now = Date.now()) { - for (const [k, v] of sessionDecision.entries()) { - if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); - } - - if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; - const keys = sessionDecision.keys(); - while (sessionDecision.size > MAX_SESSION_DECISIONS) { - const k = keys.next(); - if (k.done) break; - sessionDecision.delete(k.value); - } -} - - -function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig { - const root = (api.config as Record) || {}; - const plugins = (root.plugins as Record) || {}; - const entries = (plugins.entries as Record) || {}; - // Support both "dirigent" and legacy "whispergate" config keys - const entry = (entries.dirigent as Record) || (entries.whispergate as Record) || {}; - const cfg = (entry.config as Record) || {}; - if (Object.keys(cfg).length > 0) { - // Merge with defaults to ensure optional fields have values - return { - enableDiscordControlTool: true, - enableDirigentPolicyTool: true, - discordControlApiBaseUrl: "http://127.0.0.1:8790", - enableDebugLogs: false, - debugLogChannelIds: [], - schedulingIdentifier: "โžก๏ธ", - ...cfg, - } as DirigentConfig; - } - return fallback; -} - -function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string { - return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json"); -} - -function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig) { - 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 = {}; - } -} - -/** Resolve agentId โ†’ Discord accountId from config bindings */ -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; -} - -/** - * Get all Discord bot accountIds from config bindings. - */ -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; -} - -/** - * Track which bot accountIds have been seen in each channel via message_received. - * Key: channelId, Value: Set of accountIds seen. - */ -const channelSeenAccounts = new Map>(); - -/** - * Record a bot accountId seen in a channel. - * Returns true if this is a new account for this channel (turn order should be updated). - */ -function recordChannelAccount(channelId: string, accountId: string): boolean { - let seen = channelSeenAccounts.get(channelId); - if (!seen) { - seen = new Set(); - channelSeenAccounts.set(channelId, seen); - } - if (seen.has(accountId)) return false; - seen.add(accountId); - return true; -} - -/** - * Get the list of bot accountIds seen in a channel. - * Only returns accounts that are also in the global bindings (actual bots). - */ -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)); -} - -/** - * Ensure turn order is initialized for a channel. - * Uses only bot accounts that have been seen in this channel. - */ -function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { - const botAccounts = getChannelBotAccountIds(api, channelId); - if (botAccounts.length > 0) { - initTurnOrder(channelId, botAccounts); - } -} - -/** - * Build agent identity string for injection into group chat prompts. - * Includes agent name, Discord accountId, and Discord userId. - */ -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; - - // Find accountId for this agent - 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; - - // Find agent name - const agent = agents.find((a: Record) => a.id === agentId); - const name = (agent?.name as string) || agentId; - - // Resolve Discord userId from bot token - const discordUserId = resolveDiscordUserId(api, accountId); - - let identity = `You are ${name} (Discord account: ${accountId}`; - if (discordUserId) { - identity += `, Discord userId: ${discordUserId}`; - } - identity += `).`; - - return identity; -} - -// --- Moderator bot helpers --- - -/** Extract Discord user ID from a bot token (base64-encoded in first segment) */ -function userIdFromToken(token: string): string | undefined { - try { - const segment = token.split(".")[0]; - // Add padding - const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); - return Buffer.from(padded, "base64").toString("utf8"); - } catch { - return undefined; - } -} - -/** Resolve accountId โ†’ Discord user ID by reading the account's bot token from config */ -function resolveDiscordUserId(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); -} - -/** Get the moderator bot's Discord user ID from its token */ -function getModeratorUserId(config: DirigentConfig): string | undefined { - if (!config.moderatorBotToken) return undefined; - return userIdFromToken(config.moderatorBotToken); -} - -/** Send a message as the moderator bot via Discord REST API */ -async function sendModeratorMessage(token: string, channelId: string, content: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }): Promise { - try { - const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { - method: "POST", - headers: { - "Authorization": `Bot ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ content }), - }); - if (!r.ok) { - const text = await r.text(); - logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`); - return false; - } - logger.info(`dirigent: moderator message sent to channel=${channelId}`); - return true; - } catch (err) { - logger.warn(`dirigent: moderator send error: ${String(err)}`); - return false; - } -} - -function persistPolicies(api: OpenClawPluginApi): void { - const filePath = policyState.filePath; - if (!filePath) throw new Error("policy file path not initialized"); - const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n"; - const tmp = `${filePath}.tmp`; - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(tmp, before, "utf8"); - fs.renameSync(tmp, filePath); - api.logger.info(`dirigent: policy file persisted: ${filePath}`); -} - -function pickDefined(input: Record) { - const out: Record = {}; - for (const [k, v] of Object.entries(input)) { - if (v !== undefined) out[k] = v; - } - return out; -} - -function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean { - if (!cfg.enableDebugLogs) return false; - const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : []; - if (allow.length === 0) return true; - if (!channelId) return true; - return allow.includes(channelId); -} - -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, - }; -} - export default { id: "dirigent", name: "Dirigent", @@ -499,14 +56,12 @@ export default { const baseConfig = { enableDiscordControlTool: true, enableDirigentPolicyTool: true, - discordControlApiBaseUrl: "http://127.0.0.1:8790", schedulingIdentifier: "โžก๏ธ", + waitIdentifier: "๐Ÿ‘ค", + noReplyPort: 8787, ...(api.pluginConfig || {}), } as DirigentConfig & { enableDiscordControlTool: boolean; - discordControlApiBaseUrl: string; - discordControlApiToken?: string; - discordControlCallerId?: string; enableDirigentPolicyTool: boolean; }; @@ -522,22 +77,21 @@ export default { api.on("gateway_start", () => { api.logger.info(`dirigent: gateway_start event received`); + const live = getLivePluginConfig(api, baseConfig as DirigentConfig); + // Check no-reply-api server file exists - const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs"); + const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs"); api.logger.info(`dirigent: checking no-reply-api server at ${serverPath}, exists=${fs.existsSync(serverPath)}`); - // Additional debug: list what's in the parent directory - const parentDir = path.resolve(pluginDir, ".."); + // Additional debug: list what's in the plugin directory try { - const entries = fs.readdirSync(parentDir); - api.logger.info(`dirigent: parent dir (${parentDir}) entries: ${JSON.stringify(entries)}`); + const entries = fs.readdirSync(pluginDir); + api.logger.info(`dirigent: plugin dir (${pluginDir}) entries: ${JSON.stringify(entries)}`); } catch (e) { - api.logger.warn(`dirigent: cannot read parent dir: ${String(e)}`); + api.logger.warn(`dirigent: cannot read plugin dir: ${String(e)}`); } - startNoReplyApi(api.logger, pluginDir); - - const live = getLivePluginConfig(api, baseConfig as DirigentConfig); + startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787)); api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`); if (live.moderatorBotToken) { @@ -555,667 +109,101 @@ export default { api.logger.info("dirigent: gateway stopping, services shut down"); }); - api.registerTool( - { - name: "dirigent_tools", - description: "Dirigent unified tool: Discord admin actions + in-memory policy management.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - action: { - type: "string", - enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel", "turn-status", "turn-advance", "turn-reset"], - }, - guildId: { type: "string" }, - name: { type: "string" }, - type: { type: "number" }, - parentId: { type: "string" }, - topic: { type: "string" }, - position: { type: "number" }, - nsfw: { type: "boolean" }, - allowedUserIds: { type: "array", items: { type: "string" } }, - allowedRoleIds: { type: "array", items: { type: "string" } }, - allowMask: { type: "string" }, - denyEveryoneMask: { type: "string" }, - channelId: { type: "string" }, - mode: { type: "string", enum: ["merge", "replace"] }, - addUserIds: { type: "array", items: { type: "string" } }, - addRoleIds: { type: "array", items: { type: "string" } }, - removeTargetIds: { type: "array", items: { type: "string" } }, - denyMask: { type: "string" }, - limit: { type: "number" }, - after: { type: "string" }, - fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, - dryRun: { type: "boolean" }, - listMode: { type: "string", enum: ["human-list", "agent-list"] }, - humanList: { type: "array", items: { type: "string" } }, - agentList: { type: "array", items: { type: "string" } }, - endSymbols: { type: "array", items: { type: "string" } }, - }, - required: ["action"], - }, - async execute(_id: string, params: Record) { - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { - discordControlApiBaseUrl?: string; - discordControlApiToken?: string; - discordControlCallerId?: string; - enableDiscordControlTool?: boolean; - enableDirigentPolicyTool?: boolean; - }; - ensurePolicyStateLoaded(api, live); - - const action = String(params.action || ""); - const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]); - - if (discordActions.has(action)) { - if (live.enableDiscordControlTool === false) { - return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; - } - const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); - const body = pickDefined({ ...params, action: action as DiscordControlAction }); - const headers: Record = { "Content-Type": "application/json" }; - if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; - if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; - - const r = await fetch(`${baseUrl}/v1/discord/action`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - const text = await r.text(); - if (!r.ok) { - return { - content: [{ type: "text", text: `dirigent_tools discord failed (${r.status}): ${text}` }], - isError: true, - }; - } - return { content: [{ type: "text", text }] }; - } - - if (live.enableDirigentPolicyTool === false) { - return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; - } - - if (action === "policy-get") { - return { - content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], - }; - } - - if (action === "policy-set-channel") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - const next: ChannelPolicy = { - listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, - humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, - agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, - endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, - }; - policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } - } - - if (action === "policy-delete-channel") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - delete policyState.channelPolicies[channelId]; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } - } - - if (action === "turn-status") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] }; - } - - if (action === "turn-advance") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const next = advanceTurn(channelId); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] }; - } - - if (action === "turn-reset") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - resetTurn(channelId); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] }; - } - - return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; - }, - }, - { optional: false }, - ); - - api.on("message_received", async (event, ctx) => { - try { - const c = (ctx || {}) as Record; - const e = (event || {}) as Record; - // ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake. - // Extract the real Discord channel ID from conversationId or event.to. - const preChannelId = extractDiscordChannelId(c, e); - const livePre = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); - } - - // Turn management on message received - if (preChannelId) { - ensureTurnOrder(api, preChannelId); - // event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender. - // The actual sender ID is in event.metadata.senderId. - 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 : ""); - - // Ignore moderator bot messages โ€” they don't affect turn state - const moderatorUserId = getModeratorUserId(livePre); - if (moderatorUserId && from === moderatorUserId) { - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`); - } - // Don't call onNewMessage โ€” moderator messages are transparent to turn logic - } else { - const humanList = livePre.humanList || livePre.bypassUserIds || []; - const isHuman = humanList.includes(from); - const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; - - // Track which bot accounts are present in this channel - if (senderAccountId && senderAccountId !== "default") { - const isNew = recordChannelAccount(preChannelId, senderAccountId); - if (isNew) { - // Re-initialize turn order with updated channel membership - ensureTurnOrder(api, preChannelId); - api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); - } - } - - onNewMessage(preChannelId, senderAccountId, isHuman); - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); - } - } - } - } catch (err) { - api.logger.warn(`dirigent: message hook failed: ${String(err)}`); - } + // Register tools + registerDirigentTools({ + api, + baseConfig: baseConfig as DirigentConfig, + pickDefined, + getLivePluginConfig, }); - api.on("before_model_resolve", async (event, ctx) => { - const key = ctx.sessionKey; - if (!key) return; + // 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. - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - - const prompt = ((event as Record).prompt as string) || ""; - - if (live.enableDebugLogs) { - api.logger.info( - `dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + - `promptPreview=${prompt.slice(0, 300)}`, - ); - } - - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); - // Fallback: extract channelId from sessionKey (format "agent::discord:channel:") - if (!derived.channelId && key) { - const skMatch = key.match(/:channel:(\d+)$/); - if (skMatch) derived.channelId = skMatch[1]; - } - // Only proceed if: discord channel AND prompt contains untrusted metadata - const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); - if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; - - // Always save channelId and accountId mappings for use in later hooks - if (derived.channelId) { - sessionChannelId.set(key, derived.channelId); - } - const resolvedAccountId = resolveAccountId(api, ctx.agentId || ""); - if (resolvedAccountId) { - sessionAccountId.set(key, resolvedAccountId); - } - - let rec = sessionDecision.get(key); - if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { - if (rec) sessionDecision.delete(key); - const decision = evaluateDecision({ - config: live, - channel: derived.channel, - channelId: derived.channelId, - channelPolicies: policyState.channelPolicies, - 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}`, - ); - } - } - - // Turn-based check: ALWAYS check turn order regardless of evaluateDecision result. - // This ensures only the current speaker can respond even for human messages. - if (derived.channelId) { - ensureTurnOrder(api, derived.channelId); - const accountId = resolveAccountId(api, ctx.agentId || ""); - if (accountId) { - const turnCheck = checkTurn(derived.channelId, accountId); - if (!turnCheck.allowed) { - // Forced no-reply - record this session as not allowed to speak - sessionAllowed.set(key, false); - api.logger.info( - `dirigent: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, - ); - return { - providerOverride: live.noReplyProvider, - modelOverride: live.noReplyModel, - }; - } - // Allowed to speak - record this session as allowed - sessionAllowed.set(key, true); - } - } - - if (!rec.decision.shouldUseNoReply) { - if (rec.needsRestore) { - sessionDecision.delete(key); - return { - providerOverride: undefined, - modelOverride: undefined, - }; - } - return; - } - - rec.needsRestore = true; - sessionDecision.set(key, rec); - - if (live.enableDebugLogs) { - const prompt = ((event as Record).prompt as string) || ""; - const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); - api.logger.info( - `dirigent: DEBUG_NO_REPLY_TRIGGER 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 ?? "")} ` + - `decision=${rec.decision.reason} ` + - `shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` + - `hasConvMarker=${hasConvMarker} promptLen=${prompt.length}`, - ); - } - - api.logger.info( - `dirigent: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, - ); - - return { - providerOverride: live.noReplyProvider, - modelOverride: live.noReplyModel, - }; + registerMessageReceivedHook({ + api, + baseConfig: baseConfig as DirigentConfig, + getLivePluginConfig, + shouldDebugLog, + debugCtxSummary, + ensureTurnOrder, + getModeratorUserId, + recordChannelAccount, + extractMentionedUserIds, + buildUserIdToAccountIdMap, }); - api.on("before_prompt_build", async (event, ctx) => { - const key = ctx.sessionKey; - if (!key) return; + registerBeforeModelResolveHook({ + api, + baseConfig: baseConfig as DirigentConfig, + sessionDecision, + sessionAllowed, + sessionChannelId, + sessionAccountId, + policyState, + DECISION_TTL_MS, + ensurePolicyStateLoaded, + getLivePluginConfig, + resolveAccountId, + pruneDecisionMap, + shouldDebugLog, + ensureTurnOrder, + }); - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) 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, ctx.messageProvider, ctx.channelId); - - const decision = evaluateDecision({ - config: live, - channel: derived.channel, - channelId: derived.channelId, - channelPolicies: policyState.channelPolicies, - senderId: derived.senderId, - content: derived.content, - }); - rec = { decision, createdAt: Date.now() }; - if (shouldDebugLog(live, derived.channelId)) { - api.logger.info( - `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); - - // Only inject once per session (one-time injection) - 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; - } - - // Resolve end symbols from config/policy for dynamic instruction - const prompt = ((event as Record).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); - const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); - const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; - const schedulingId = live.schedulingIdentifier || "โžก๏ธ"; - const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId); - - // Inject agent identity for group chats (includes userId now) - let identity = ""; - if (isGroupChat && ctx.agentId) { - const idStr = buildAgentIdentity(api, ctx.agentId); - if (idStr) identity = idStr + "\n\n"; - } - - // Add scheduling identifier instruction for group chats - let schedulingInstruction = ""; - if (isGroupChat) { - schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId); - } - - // Mark session as injected (one-time injection) - sessionInjected.add(key); - - api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); - return { prependContext: identity + instruction + schedulingInstruction }; + registerBeforePromptBuildHook({ + api, + baseConfig: baseConfig as DirigentConfig, + sessionDecision, + sessionInjected, + policyState, + DECISION_TTL_MS, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + buildEndMarkerInstruction, + buildSchedulingIdentifierInstruction, + buildAgentIdentity, }); // Register slash commands for Discord - api.registerCommand({ - name: "dirigent", - description: "Dirigent channel policy management", - 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` }; - } - - if (subCmd === "status") { - return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) }; - } - - if (subCmd === "turn-status") { - const channelId = cmdCtx.channelId; - if (!channelId) return { text: "Cannot get channel ID", isError: true }; - return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }; - } - - if (subCmd === "turn-advance") { - const channelId = cmdCtx.channelId; - if (!channelId) return { text: "Cannot get channel ID", isError: true }; - const next = advanceTurn(channelId); - return { text: JSON.stringify({ ok: true, nextSpeaker: next }) }; - } - - if (subCmd === "turn-reset") { - const channelId = cmdCtx.channelId; - if (!channelId) return { text: "Cannot get channel ID", isError: true }; - resetTurn(channelId); - return { text: JSON.stringify({ ok: true }) }; - } - - return { text: `Unknown subcommand: ${subCmd}`, isError: true }; - }, + registerDirigentCommand({ + api, + baseConfig: baseConfig as DirigentConfig, + policyState, + persistPolicies, + ensurePolicyStateLoaded, + getLivePluginConfig, }); // Handle NO_REPLY detection before message write - 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 ?? {}))}`, - ); - - let 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; - if (msg) { - const role = msg.role as string | undefined; - if (role && role !== "assistant") 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 = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - const policy = resolvePolicy(live, channelId, policyState.channelPolicies); - - const trimmed = content.trim(); - const isEmpty = trimmed.length === 0; - const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); - const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; - const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); - const wasNoReply = isEmpty || 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])}`, - ); - - const wasAllowed = sessionAllowed.get(key); - - if (wasNoReply) { - api.logger.info( - `dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, - ); - - 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; - } - - ensureTurnOrder(api, channelId); - const nextSpeaker = onSpeakerDone(channelId, accountId, true); - sessionAllowed.delete(key); - sessionTurnHandled.add(key); - - api.logger.info( - `dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, - ); - - if (!nextSpeaker) { - if (shouldDebugLog(live, channelId)) { - api.logger.info( - `dirigent: before_message_write all agents no-reply, going dormant - no handoff`, - ); - } - return; - } - - // Trigger moderator handoff message using scheduling identifier format - if (live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const schedulingId = live.schedulingIdentifier || "โžก๏ธ"; - const handoffMsg = `<@${nextUserId}>${schedulingId}`; - void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { - api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`); - }); - } else { - api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); - } - } - } else if (hasEndSymbol) { - 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)}`); - } + registerBeforeMessageWriteHook({ + api, + baseConfig: baseConfig as DirigentConfig, + policyState, + sessionAllowed, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + ensureTurnOrder, + resolveDiscordUserId, + sendModeratorMessage, }); // Turn advance: when an agent sends a message, check if it signals end of turn - 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) { - const skMatch = key.match(/:channel:(\d+)$/); - if (skMatch) channelId = skMatch[1]; - } - 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 = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - const policy = resolvePolicy(live, channelId, policyState.channelPolicies); - - const trimmed = content.trim(); - const isEmpty = trimmed.length === 0; - const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); - const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; - const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); - const wasNoReply = isEmpty || isNoReply; - - // Skip if turn was already advanced in before_message_write - 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 (wasNoReply || hasEndSymbol) { - const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); - const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol"; - api.logger.info( - `dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, - ); - // Moderator handoff using scheduling identifier format - if (wasNoReply && nextSpeaker && live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const schedulingId = live.schedulingIdentifier || "โžก๏ธ"; - const handoffMsg = `<@${nextUserId}>${schedulingId}`; - 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)}`); - } + registerMessageSentHook({ + api, + baseConfig: baseConfig as DirigentConfig, + policyState, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + resolveDiscordUserId, + sendModeratorMessage, }); }, }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 1886edf..ba0c07f 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "dirigent", "name": "Dirigent", - "version": "0.2.0", + "version": "0.3.0", "description": "Rule-based no-reply gate with provider/model override and turn management", "entry": "./index.ts", "configSchema": { @@ -17,13 +17,12 @@ "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["๐Ÿ”š"] }, "schedulingIdentifier": { "type": "string", "default": "โžก๏ธ" }, + "waitIdentifier": { "type": "string", "default": "๐Ÿ‘ค" }, "noReplyProvider": { "type": "string" }, "noReplyModel": { "type": "string" }, + "noReplyPort": { "type": "number", "default": 8787 }, "enableDiscordControlTool": { "type": "boolean", "default": true }, "enableDirigentPolicyTool": { "type": "boolean", "default": true }, - "discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" }, - "discordControlApiToken": { "type": "string" }, - "discordControlCallerId": { "type": "string" }, "enableDebugLogs": { "type": "boolean", "default": false }, "debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "moderatorBotToken": { "type": "string" } diff --git a/plugin/policy/store.ts b/plugin/policy/store.ts new file mode 100644 index 0000000..1b4479e --- /dev/null +++ b/plugin/policy/store.ts @@ -0,0 +1,50 @@ +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.ts b/plugin/rules.ts index 26f181b..3750562 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -10,8 +10,11 @@ export type DirigentConfig = { endSymbols?: string[]; /** Scheduling identifier sent by moderator to activate agents (default: โžก๏ธ) */ schedulingIdentifier?: string; + /** Wait identifier: agent ends with this when waiting for a human reply (default: ๐Ÿ‘ค) */ + waitIdentifier?: string; noReplyProvider: string; noReplyModel: string; + noReplyPort?: number; /** Discord bot token for the moderator bot (used for turn handoff messages) */ moderatorBotToken?: string; }; diff --git a/plugin/tools/register-tools.ts b/plugin/tools/register-tools.ts new file mode 100644 index 0000000..4180ada --- /dev/null +++ b/plugin/tools/register-tools.ts @@ -0,0 +1,179 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { DirigentConfig } from "../rules.js"; + +type DiscordControlAction = "channel-private-create" | "channel-private-update"; + +type ToolDeps = { + api: OpenClawPluginApi; + baseConfig: DirigentConfig; + pickDefined: (obj: Record) => Record; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; +}; + +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; +} + +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; +} + +export function registerDirigentTools(deps: ToolDeps): void { + const { api, baseConfig, pickDefined, getLivePluginConfig } = deps; + + async function executeDiscordAction(action: DiscordControlAction, params: Record) { + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { + enableDiscordControlTool?: boolean; + discordControlAccountId?: string; + }; + if (live.enableDiscordControlTool === false) { + return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; + } + + const selected = parseAccountToken(api, (params.accountId as string | undefined) || live.discordControlAccountId); + if (!selected) { + return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true }; + } + const token = selected.token; + + if (action === "channel-private-create") { + const guildId = String(params.guildId || "").trim(); + const name = String(params.name || "").trim(); + if (!guildId || !name) return { content: [{ type: "text", text: "guildId and name are required" }], isError: true }; + + const allowedUserIds = Array.isArray(params.allowedUserIds) ? params.allowedUserIds.map(String) : []; + const allowedRoleIds = Array.isArray(params.allowedRoleIds) ? params.allowedRoleIds.map(String) : []; + const allowMask = String(params.allowMask || "1024"); + const denyEveryoneMask = String(params.denyEveryoneMask || "1024"); + + const overwrites: any[] = [ + { id: guildId, type: 0, allow: "0", deny: denyEveryoneMask }, + ...allowedRoleIds.map((id) => ({ id, type: 0, allow: allowMask, deny: "0" })), + ...allowedUserIds.map((id) => ({ id, type: 1, allow: allowMask, deny: "0" })), + ]; + + const body = pickDefined({ + name, + type: typeof params.type === "number" ? params.type : 0, + parent_id: params.parentId, + topic: params.topic, + position: params.position, + nsfw: params.nsfw, + permission_overwrites: overwrites, + }); + + const resp = await discordRequest(token, "POST", `/guilds/${guildId}/channels`, body); + if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true }; + return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] }; + } + + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + + const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge"; + const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : []; + const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : []; + const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : []; + const allowMask = String(params.allowMask || "1024"); + const denyMask = String(params.denyMask || "0"); + + const ch = await discordRequest(token, "GET", `/channels/${channelId}`); + if (!ch.ok) return { content: [{ type: "text", text: `discord action failed (${ch.status}): ${ch.text}` }], isError: true }; + + const current = Array.isArray(ch.json?.permission_overwrites) ? [...ch.json.permission_overwrites] : []; + const guildId = String(ch.json?.guild_id || ""); + const everyone = current.find((x: any) => String(x?.id || "") === guildId && roleOrMemberType(x?.type) === 0); + + let next: any[] = mode === "replace" ? (everyone ? [everyone] : []) : current.filter((x: any) => !removeTargetIds.includes(String(x?.id || ""))); + for (const id of addRoleIds) { + next = next.filter((x: any) => String(x?.id || "") !== id); + next.push({ id, type: 0, allow: allowMask, deny: denyMask }); + } + for (const id of addUserIds) { + next = next.filter((x: any) => String(x?.id || "") !== id); + next.push({ id, type: 1, allow: allowMask, deny: denyMask }); + } + + const resp = await discordRequest(token, "PATCH", `/channels/${channelId}`, { permission_overwrites: next }); + if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true }; + return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] }; + } + + api.registerTool({ + name: "discord_channel_create", + description: "Create a private Discord channel with specific user/role permissions.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + accountId: { type: "string" }, + guildId: { type: "string" }, + name: { type: "string" }, + type: { type: "number" }, + parentId: { type: "string" }, + topic: { type: "string" }, + position: { type: "number" }, + nsfw: { type: "boolean" }, + allowedUserIds: { type: "array", items: { type: "string" } }, + allowedRoleIds: { type: "array", items: { type: "string" } }, + allowMask: { type: "string" }, + denyEveryoneMask: { type: "string" }, + }, + required: [], + }, + async execute(_id: string, params: Record) { + return executeDiscordAction("channel-private-create", params); + }, + }, { optional: false }); + + api.registerTool({ + name: "discord_channel_update", + description: "Update permissions on an existing private Discord channel.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + accountId: { type: "string" }, + channelId: { type: "string" }, + mode: { type: "string", enum: ["merge", "replace"] }, + addUserIds: { type: "array", items: { type: "string" } }, + addRoleIds: { type: "array", items: { type: "string" } }, + removeTargetIds: { type: "array", items: { type: "string" } }, + allowMask: { type: "string" }, + denyMask: { type: "string" }, + }, + required: [], + }, + async execute(_id: string, params: Record) { + return executeDiscordAction("channel-private-update", params); + }, + }, { optional: false }); +} diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 033699e..121d68a 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -20,6 +20,14 @@ export type ChannelTurnState = { 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; }; const channelTurns = new Map(); @@ -47,19 +55,66 @@ function shuffleArray(arr: T[]): T[] { export function initTurnOrder(channelId: string, botAccountIds: string[]): void { const existing = channelTurns.get(channelId); if (existing) { - // Check if membership changed - const oldSet = new Set(existing.turnOrder); + // Compare membership against base order. + // If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison. + const baseOrder = existing.savedTurnOrder || existing.turnOrder; + const oldSet = new Set(baseOrder); const newSet = new Set(botAccountIds); const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id)); if (same) return; // no change + + console.log( + `[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)}`, + ); + + const nextOrder = shuffleArray(botAccountIds); + + // Mention override active: update only the saved base order. + // Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered. + if (existing.savedTurnOrder) { + existing.savedTurnOrder = nextOrder; + existing.lastChangedAt = Date.now(); + console.log( + `[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` + + `savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` + + `keptCurrent=${existing.currentSpeaker}`, + ); + return; + } + + // Non-mention flow: preserve previous behavior (re-init to dormant). + 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: shuffleArray(botAccountIds), + 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`, + ); } /** @@ -75,6 +130,11 @@ export function checkTurn(channelId: string, accountId: string): { return { allowed: true, currentSpeaker: null, reason: "no_turn_state" }; } + // Waiting for human โ†’ block all agents + if (state.waitingForHuman) { + return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" }; + } + // Not in turn order (human or unknown) โ†’ always allowed if (!state.turnOrder.includes(accountId)) { return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" }; @@ -107,6 +167,8 @@ export function checkTurn(channelId: string, accountId: string): { * Called when a new message arrives in the channel. * Handles reactivation from dormant state and human-triggered resets. * + * NOTE: For human messages with @mentions, call setMentionOverride() instead. + * * @param senderAccountId - the accountId of the message sender (could be human/bot/unknown) * @param isHuman - whether the sender is in the humanList */ @@ -115,13 +177,20 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi if (!state || state.turnOrder.length === 0) return; if (isHuman) { - // Human message: activate, start from first in order + // 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; @@ -141,6 +210,97 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi state.lastChangedAt = Date.now(); } +/** + * Restore original turn order if an override is active. + */ +function restoreOriginalOrder(state: ChannelTurnState): void { + if (state.savedTurnOrder) { + state.turnOrder = state.savedTurnOrder; + state.savedTurnOrder = undefined; + state.overrideFirstAgent = undefined; + } +} + +/** + * Set a temporary mention override for the turn order. + * When a human @mentions specific agents, only those agents speak (in their + * relative order from the current turn order). After the cycle returns to the + * first agent, the original order is restored. + * + * @param channelId - Discord channel ID + * @param mentionedAccountIds - accountIds of @mentioned agents, ordered by + * their position in the current turn order + * @returns true if override was set, false if no valid agents + */ +export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean { + const state = channelTurns.get(channelId); + if (!state || mentionedAccountIds.length === 0) return false; + + console.log( + `[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` + + `mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` + + `order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`, + ); + + // Restore any existing override first + restoreOriginalOrder(state); + + // Filter to agents actually in the turn order + const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id)); + if (validIds.length === 0) { + console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`); + return false; + } + + // Order by their position in the current turn order + validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b)); + + // Save original and apply override + state.savedTurnOrder = [...state.turnOrder]; + state.turnOrder = validIds; + state.overrideFirstAgent = validIds[0]; + state.currentSpeaker = validIds[0]; + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); + + console.log( + `[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` + + `overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` + + `savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`, + ); + + return true; +} + +/** + * Check if a mention override is currently active. + */ +export function hasMentionOverride(channelId: string): boolean { + const state = channelTurns.get(channelId); + return !!state?.savedTurnOrder; +} + +/** + * Set the channel to "waiting for human" state. + * All agents will be routed to no-reply until a human sends a message. + */ +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(); +} + +/** + * Check if the channel is waiting for a human reply. + */ +export function isWaitingForHuman(channelId: string): boolean { + const state = channelTurns.get(channelId); + return !!state?.waitingForHuman; +} + /** * Called when the current speaker finishes (end symbol detected) or says NO_REPLY. * @param wasNoReply - true if the speaker said NO_REPLY (empty/silent) @@ -157,6 +317,8 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: // 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(); @@ -168,7 +330,18 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: state.noRepliedThisCycle = new Set(); } - return advanceTurn(channelId); + 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 + } + + return next; } /** @@ -209,8 +382,10 @@ export function advanceTurn(channelId: string): string | null { 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(); } } @@ -229,5 +404,9 @@ export function getTurnDebugInfo(channelId: string): Record { noRepliedThisCycle: [...state.noRepliedThisCycle], lastChangedAt: state.lastChangedAt, dormant: state.currentSpeaker === null, + waitingForHuman: state.waitingForHuman, + hasOverride: !!state.savedTurnOrder, + overrideFirstAgent: state.overrideFirstAgent || null, + savedTurnOrder: state.savedTurnOrder || null, }; } diff --git a/scripts/install-dirigent-openclaw.mjs b/scripts/install-dirigent-openclaw.mjs deleted file mode 100755 index 3dfec54..0000000 --- a/scripts/install-dirigent-openclaw.mjs +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env node -/** - * Dirigent plugin installer/uninstaller with delta-tracking. - * Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys. - */ -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { execFileSync, spawnSync } from "node:child_process"; - -const modeArg = process.argv[2]; -if (modeArg !== "--install" && modeArg !== "--uninstall") { - console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall"); - process.exit(2); -} -const mode = modeArg === "--install" ? "install" : "uninstall"; - -const env = process.env; -const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); -const __dirname = path.dirname(new URL(import.meta.url).pathname); - -// Ensure dist/dirigent exists (handles git clone without npm prepare) -const DIST_DIR = path.resolve(__dirname, "..", "dist", "dirigent"); -const PLUGIN_SRC_DIR = path.resolve(__dirname, "..", "plugin"); -const NO_REPLY_API_SRC_DIR = path.resolve(__dirname, "..", "no-reply-api"); -const NO_REPLY_API_DIST_DIR = path.resolve(__dirname, "..", "dist", "no-reply-api"); - -if (mode === "install") { - // Copy plugin files to dist/dirigent - if (!fs.existsSync(DIST_DIR)) { - console.log("[dirigent] dist/dirigent/ not found, syncing from plugin/..."); - fs.mkdirSync(DIST_DIR, { recursive: true }); - for (const f of fs.readdirSync(PLUGIN_SRC_DIR)) { - fs.copyFileSync(path.join(PLUGIN_SRC_DIR, f), path.join(DIST_DIR, f)); - } - } - - // Copy no-reply-api files to dist/no-reply-api - if (!fs.existsSync(NO_REPLY_API_DIST_DIR)) { - console.log("[dirigent] dist/no-reply-api/ not found, syncing from no-reply-api/..."); - fs.mkdirSync(NO_REPLY_API_DIST_DIR, { recursive: true }); - for (const f of fs.readdirSync(NO_REPLY_API_SRC_DIR)) { - fs.copyFileSync(path.join(NO_REPLY_API_SRC_DIR, f), path.join(NO_REPLY_API_DIST_DIR, f)); - } - } -} - -const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR; -const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "dirigentway"; -const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; -const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1"; -const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token"; -const LIST_MODE = env.LIST_MODE || "human-list"; -const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]'; -const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]"; -const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/dirigent-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir()); -const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}"; -const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["๐Ÿ”š"]'; -const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "โžก๏ธ"; - -const STATE_DIR = (env.STATE_DIR || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir()); -const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir()); - -const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); -const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`; -const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`); - -const PATH_PLUGINS_LOAD = "plugins.load.paths"; -const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent"; -const PATH_PROVIDERS = "models.providers"; -const PATH_PROVIDER_ENTRY = `models.providers.${NO_REPLY_PROVIDER_ID}`; -const PATH_PLUGINS_ALLOW = "plugins.allow"; - -function runOpenclaw(args, { allowFail = false } = {}) { - try { - return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); - } catch (e) { - if (allowFail) return null; - throw e; - } -} - -function getJson(pathKey) { - const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); - if (out == null || out === "" || out === "undefined") return undefined; - try { - return JSON.parse(out); - } catch { - return undefined; - } -} - -function setJson(pathKey, value) { - runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); -} - -function unsetPath(pathKey) { - runOpenclaw(["config", "unset", pathKey], { allowFail: true }); -} - -function writeRecord(modeName, delta) { - fs.mkdirSync(STATE_DIR, { recursive: true }); - const rec = { - mode: modeName, - timestamp: ts, - openclawConfigPath: OPENCLAW_CONFIG_PATH, - backupPath: BACKUP_PATH, - delta, // { added: {...}, replaced: {...}, removed: {...} } - }; - fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2)); - fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK); - return rec; -} - -function readRecord(file) { - return JSON.parse(fs.readFileSync(file, "utf8")); -} - -function findLatestInstallRecord() { - if (!fs.existsSync(STATE_DIR)) return ""; - const files = fs - .readdirSync(STATE_DIR) - .filter((f) => /^dirigent-\d+\.json$/.test(f)) - .sort() - .reverse(); - for (const f of files) { - const p = path.join(STATE_DIR, f); - try { - const rec = readRecord(p); - if (rec?.mode === "install") return p; - } catch { - // ignore broken records - } - } - return ""; -} - -// Deep clone -function clone(v) { - if (v === undefined) return undefined; - return JSON.parse(JSON.stringify(v)); -} - -// Check if two values are deeply equal -function deepEqual(a, b) { - return JSON.stringify(a) === JSON.stringify(b); -} - -if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { - console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`); - process.exit(1); -} - -// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -// INSTALL (with auto-reinstall if already installed) -// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -if (mode === "install") { - // Check if already installed - if so, uninstall first - const existingRecord = findLatestInstallRecord(); - if (existingRecord) { - console.log("[dirigent] existing installation detected, uninstalling first..."); - process.env.RECORD_FILE = existingRecord; - // Re-exec ourselves in uninstall mode - const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], { - env: process.env, - stdio: ["inherit", "inherit", "inherit"], - }); - if (result.status !== 0) { - console.error("[dirigent] reinstall failed during uninstall phase"); - process.exit(1); - } - console.log("[dirigent] previous installation removed, proceeding with fresh install..."); - } - - fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); - console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); - - if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { - fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); - fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); - console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); - } - - const delta = { added: {}, replaced: {}, removed: {} }; - - try { - // โ”€โ”€ plugins.load.paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const plugins = getJson("plugins") || {}; - const oldPaths = clone(plugins.load?.paths) || []; - const newPaths = clone(oldPaths); - const pathIndex = newPaths.indexOf(PLUGIN_PATH); - if (pathIndex === -1) { - newPaths.push(PLUGIN_PATH); - delta.added[PATH_PLUGINS_LOAD] = PLUGIN_PATH; // added this path - } else { - // already present, no change - } - // save old paths for potential future rollback of this specific change - delta._prev = delta._prev || {}; - delta._prev[PATH_PLUGINS_LOAD] = oldPaths; - plugins.load = plugins.load || {}; - plugins.load.paths = newPaths; - - // โ”€โ”€ plugins.entries.dirigent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const oldEntry = clone(plugins.entries?.dirigent); - const newEntry = { - enabled: true, - config: { - enabled: true, - discordOnly: true, - listMode: LIST_MODE, - humanList: JSON.parse(HUMAN_LIST_JSON), - agentList: JSON.parse(AGENT_LIST_JSON), - channelPoliciesFile: CHANNEL_POLICIES_FILE, - endSymbols: JSON.parse(END_SYMBOLS_JSON), - schedulingIdentifier: SCHEDULING_IDENTIFIER, - noReplyProvider: NO_REPLY_PROVIDER_ID, - noReplyModel: NO_REPLY_MODEL_ID, - }, - }; - if (oldEntry === undefined) { - delta.added[PATH_PLUGIN_ENTRY] = newEntry; - } else { - delta.replaced[PATH_PLUGIN_ENTRY] = oldEntry; - } - plugins.entries = plugins.entries || {}; - plugins.entries.dirigent = newEntry; - setJson("plugins", plugins); - - // โ”€โ”€ models.providers. โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const providers = getJson(PATH_PROVIDERS) || {}; - const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]); - const newProvider = { - baseUrl: NO_REPLY_BASE_URL, - apiKey: NO_REPLY_API_KEY, - api: "openai-completions", - models: [ - { - id: NO_REPLY_MODEL_ID, - name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 8192, - }, - ], - }; - if (oldProvider === undefined) { - delta.added[PATH_PROVIDER_ENTRY] = newProvider; - } else { - delta.replaced[PATH_PROVIDER_ENTRY] = oldProvider; - } - providers[NO_REPLY_PROVIDER_ID] = newProvider; - setJson(PATH_PROVIDERS, providers); - - // โ”€โ”€ plugins.allow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const allowList = getJson(PATH_PLUGINS_ALLOW) || []; - const oldAllow = clone(allowList); - if (!allowList.includes("dirigent")) { - allowList.push("dirigent"); - delta.added[PATH_PLUGINS_ALLOW] = "dirigent"; - delta._prev = delta._prev || {}; - delta._prev[PATH_PLUGINS_ALLOW] = oldAllow; - setJson(PATH_PLUGINS_ALLOW, allowList); - console.log("[dirigent] added 'dirigent' to plugins.allow"); - } - - writeRecord("install", delta); - console.log("[dirigent] install ok (config written)"); - console.log(`[dirigent] record: ${RECORD_PATH}`); - console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart"); - } catch (e) { - fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); - console.error(`[dirigent] install failed; rollback complete: ${String(e)}`); - process.exit(1); - } -} - -// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -// UNINSTALL -// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -else { - const recFile = env.RECORD_FILE || findLatestInstallRecord(); - if (!recFile || !fs.existsSync(recFile)) { - console.log("[dirigent] no install record found, nothing to uninstall."); - process.exit(0); - } - - fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); - console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); - - const rec = readRecord(recFile); - const delta = rec.delta || { added: {}, replaced: {}, removed: {} }; - - try { - // โ”€โ”€ IMPORTANT: Order matters for OpenClaw config validation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // 1. First remove from allow (before deleting entry, otherwise validation fails) - if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) { - const allowList = getJson(PATH_PLUGINS_ALLOW) || []; - const idx = allowList.indexOf("dirigent"); - if (idx !== -1) { - allowList.splice(idx, 1); - setJson(PATH_PLUGINS_ALLOW, allowList); - console.log("[dirigent] removed 'dirigent' from plugins.allow"); - } - } - - // 2. Then remove entry - if (delta.added[PATH_PLUGIN_ENTRY] !== undefined || delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) { - unsetPath(PATH_PLUGIN_ENTRY); - console.log("[dirigent] removed plugins.entries.dirigent"); - } - - // 3. Then remove plugin path (after entry is gone) - if (delta.added[PATH_PLUGINS_LOAD] !== undefined) { - const plugins = getJson("plugins") || {}; - const paths = plugins.load?.paths || []; - const idx = paths.indexOf(PLUGIN_PATH); - if (idx !== -1) { - paths.splice(idx, 1); - plugins.load.paths = paths; - setJson("plugins", plugins); - console.log("[dirigent] removed plugin path from plugins.load.paths"); - } - } - - // 4. Finally remove provider - if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) { - const providers = getJson(PATH_PROVIDERS) || {}; - delete providers[NO_REPLY_PROVIDER_ID]; - setJson(PATH_PROVIDERS, providers); - console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`); - } - - // โ”€โ”€ Handle REPLACED provider: restore old value โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) { - const providers = getJson(PATH_PROVIDERS) || {}; - providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY]; - setJson(PATH_PROVIDERS, providers); - console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`); - } - - // Handle plugins.load.paths restoration (if it was replaced, not added) - if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) { - const plugins = getJson("plugins") || {}; - plugins.load = plugins.load || {}; - plugins.load.paths = delta._prev[PATH_PLUGINS_LOAD]; - setJson("plugins", plugins); - console.log("[dirigent] restored previous plugins.load.paths"); - } - - writeRecord("uninstall", delta); - console.log("[dirigent] uninstall ok"); - console.log(`[dirigent] record: ${RECORD_PATH}`); - console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart"); - } catch (e) { - fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); - console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`); - process.exit(1); - } -} diff --git a/scripts/install.mjs b/scripts/install.mjs new file mode 100755 index 0000000..4f70595 --- /dev/null +++ b/scripts/install.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execFileSync, spawnSync } from "node:child_process"; + +const VALID_MODES = new Set(["--install", "--uninstall", "--update"]); +let modeArg = null; +let argOpenClawDir = null; +let argNoReplyPort = 8787; + +for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (VALID_MODES.has(arg)) { + modeArg = arg; + } else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) { + argOpenClawDir = process.argv[++i]; + } else if (arg.startsWith("--openclaw-profile-path=")) { + argOpenClawDir = arg.split("=").slice(1).join("="); + } else if (arg === "--no-reply-port" && i + 1 < process.argv.length) { + argNoReplyPort = Number(process.argv[++i]); + } else if (arg.startsWith("--no-reply-port=")) { + argNoReplyPort = Number(arg.split("=").slice(1).join("=")); + } +} + +if (!modeArg) { + fail("Usage: node scripts/install.mjs --install|--uninstall|--update [--openclaw-profile-path ] [--no-reply-port ]"); + process.exit(2); +} + +if (!Number.isFinite(argNoReplyPort) || argNoReplyPort < 1 || argNoReplyPort > 65535) { + fail("invalid --no-reply-port (1-65535)"); + process.exit(2); +} + +const mode = modeArg.slice(2); + +const C = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", +}; + +function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; } +function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); } +function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); } +function ok(msg) { console.log(color(`\tโœ“ ${msg}`, "green")); } +function warn(msg) { console.log(color(`\tโš  ${msg}`, "yellow")); } +function fail(msg) { console.log(color(`\tโœ— ${msg}`, "red")); } + +function resolveOpenClawDir() { + if (argOpenClawDir) { + const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir()); + if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`); + return dir; + } + if (process.env.OPENCLAW_DIR) { + const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir()); + if (fs.existsSync(dir)) return dir; + warn(`OPENCLAW_DIR not found: ${dir}, fallback to ~/.openclaw`); + } + const fallback = path.join(os.homedir(), ".openclaw"); + if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir"); + return fallback; +} + +const OPENCLAW_DIR = resolveOpenClawDir(); +const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json"); +if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { + fail(`config not found: ${OPENCLAW_CONFIG_PATH}`); + process.exit(1); +} + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const REPO_ROOT = path.resolve(__dirname, ".."); +const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins"); +const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent"); +const NO_REPLY_INSTALL_DIR = path.join(PLUGIN_INSTALL_DIR, "no-reply-api"); + +const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigentway"; +const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply"; +const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort); +const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`; +const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token"; +const LIST_MODE = process.env.LIST_MODE || "human-list"; +const HUMAN_LIST_JSON = process.env.HUMAN_LIST_JSON || "[]"; +const AGENT_LIST_JSON = process.env.AGENT_LIST_JSON || "[]"; +const CHANNEL_POLICIES_FILE = process.env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"); +const CHANNEL_POLICIES_JSON = process.env.CHANNEL_POLICIES_JSON || "{}"; +const END_SYMBOLS_JSON = process.env.END_SYMBOLS_JSON || '["๐Ÿ”š"]'; +const SCHEDULING_IDENTIFIER = process.env.SCHEDULING_IDENTIFIER || "โžก๏ธ"; + +function runOpenclaw(args, allowFail = false) { + try { + return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); + } catch (e) { + if (allowFail) return null; + throw e; + } +} + +function getJson(pathKey) { + const out = runOpenclaw(["config", "get", pathKey, "--json"], true); + if (!out || out === "undefined") return undefined; + try { return JSON.parse(out); } catch { return undefined; } +} + +function setJson(pathKey, value) { + runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); +} + +function unsetPath(pathKey) { + runOpenclaw(["config", "unset", pathKey], true); +} + +function syncDirRecursive(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + fs.cpSync(src, dest, { recursive: true, force: true }); +} + +function isRegistered() { + const entry = getJson("plugins.entries.dirigent"); + return !!(entry && typeof entry === "object"); +} + +if (mode === "update") { + title("Update"); + const branch = process.env.DIRIGENT_GIT_BRANCH || "latest"; + step(1, 2, `update source branch=${branch}`); + execFileSync("git", ["fetch", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" }); + execFileSync("git", ["checkout", branch], { cwd: REPO_ROOT, stdio: "inherit" }); + execFileSync("git", ["pull", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" }); + ok("source updated"); + + step(2, 2, "run install after update"); + const script = path.join(REPO_ROOT, "scripts", "install.mjs"); + const args = [script, "--install", "--openclaw-profile-path", OPENCLAW_DIR, "--no-reply-port", String(NO_REPLY_PORT)]; + const ret = spawnSync(process.execPath, args, { cwd: REPO_ROOT, stdio: "inherit", env: process.env }); + process.exit(ret.status ?? 1); +} + +if (mode === "install") { + title("Install"); + step(1, 6, `environment: ${OPENCLAW_DIR}`); + + if (isRegistered()) { + warn("plugins.entries.dirigent exists; reinstalling in-place"); + } + + step(2, 6, "build dist assets"); + const pluginSrc = path.resolve(REPO_ROOT, "plugin"); + const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api"); + const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent"); + const distNoReply = path.resolve(REPO_ROOT, "dist", "dirigent", "no-reply-api"); + syncDirRecursive(pluginSrc, distPlugin); + syncDirRecursive(noReplySrc, distNoReply); + + step(3, 6, `install files -> ${PLUGIN_INSTALL_DIR}`); + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR); + syncDirRecursive(distNoReply, NO_REPLY_INSTALL_DIR); + + // cleanup old layout from previous versions + const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api"); + if (fs.existsSync(oldTopLevelNoReply)) { + fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true }); + ok(`removed legacy path: ${oldTopLevelNoReply}`); + } + + if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { + fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); + fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); + ok(`init channel policies file: ${CHANNEL_POLICIES_FILE}`); + } + + step(4, 6, "configure plugin entry/path"); + const plugins = getJson("plugins") || {}; + const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : []; + if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR); + plugins.load = plugins.load || {}; + plugins.load.paths = loadPaths; + + plugins.entries = plugins.entries || {}; + plugins.entries.dirigent = { + enabled: true, + config: { + enabled: true, + discordOnly: true, + listMode: LIST_MODE, + humanList: JSON.parse(HUMAN_LIST_JSON), + agentList: JSON.parse(AGENT_LIST_JSON), + channelPoliciesFile: CHANNEL_POLICIES_FILE, + endSymbols: JSON.parse(END_SYMBOLS_JSON), + schedulingIdentifier: SCHEDULING_IDENTIFIER, + noReplyProvider: NO_REPLY_PROVIDER_ID, + noReplyModel: NO_REPLY_MODEL_ID, + noReplyPort: NO_REPLY_PORT, + }, + }; + setJson("plugins", plugins); + + step(5, 6, "configure no-reply provider"); + const providers = getJson("models.providers") || {}; + providers[NO_REPLY_PROVIDER_ID] = { + baseUrl: NO_REPLY_BASE_URL, + apiKey: NO_REPLY_API_KEY, + api: "openai-completions", + models: [ + { + id: NO_REPLY_MODEL_ID, + name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, + ], + }; + setJson("models.providers", providers); + + step(6, 6, "enable plugin in allowlist"); + const allow = getJson("plugins.allow") || []; + if (!allow.includes("dirigent")) { + allow.push("dirigent"); + setJson("plugins.allow", allow); + } + + ok(`installed (no-reply port: ${NO_REPLY_PORT})`); + console.log("โ†ป restart gateway: openclaw gateway restart"); + process.exit(0); +} + +if (mode === "uninstall") { + title("Uninstall"); + step(1, 5, `environment: ${OPENCLAW_DIR}`); + + step(2, 5, "remove allowlist + plugin entry"); + const allow = getJson("plugins.allow") || []; + const idx = allow.indexOf("dirigent"); + if (idx >= 0) { + allow.splice(idx, 1); + setJson("plugins.allow", allow); + ok("removed from plugins.allow"); + } + + unsetPath("plugins.entries.dirigent"); + ok("removed plugins.entries.dirigent"); + + step(3, 5, "remove plugin load path"); + const plugins = getJson("plugins") || {}; + const paths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : []; + plugins.load = plugins.load || {}; + plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR); + setJson("plugins", plugins); + ok("removed plugin path from plugins.load.paths"); + + step(4, 5, "remove no-reply provider"); + const providers = getJson("models.providers") || {}; + delete providers[NO_REPLY_PROVIDER_ID]; + setJson("models.providers", providers); + ok(`removed provider ${NO_REPLY_PROVIDER_ID}`); + + step(5, 5, "remove installed files"); + if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true }); + if (fs.existsSync(NO_REPLY_INSTALL_DIR)) fs.rmSync(NO_REPLY_INSTALL_DIR, { recursive: true, force: true }); + const legacyNoReply = path.join(PLUGINS_DIR, "dirigent-no-reply-api"); + if (fs.existsSync(legacyNoReply)) fs.rmSync(legacyNoReply, { recursive: true, force: true }); + const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api"); + if (fs.existsSync(oldTopLevelNoReply)) fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true }); + ok("removed installed files"); + + console.log("โ†ป restart gateway: openclaw gateway restart"); + process.exit(0); +} diff --git a/scripts/smoke-discord-control.sh b/scripts/smoke-discord-control.sh deleted file mode 100755 index 613be5c..0000000 --- a/scripts/smoke-discord-control.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BASE_URL="${BASE_URL:-http://127.0.0.1:8790}" -AUTH_TOKEN="${AUTH_TOKEN:-}" -CALLER_ID="${CALLER_ID:-}" - -AUTH_HEADER=() -if [[ -n "$AUTH_TOKEN" ]]; then - AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}") -fi - -CALLER_HEADER=() -if [[ -n "$CALLER_ID" ]]; then - CALLER_HEADER=(-H "X-OpenClaw-Caller-Id: ${CALLER_ID}") -fi - -echo "[1] health" -curl -sS "${BASE_URL}/health" | sed -n '1,20p' - -if [[ -z "${GUILD_ID:-}" ]]; then - echo "skip action checks: set GUILD_ID (and optional CHANNEL_ID) to run dryRun actions" - exit 0 -fi - -echo "[2] dry-run private create" -curl -sS -X POST "${BASE_URL}/v1/discord/action" \ - -H 'Content-Type: application/json' \ - "${AUTH_HEADER[@]}" \ - "${CALLER_HEADER[@]}" \ - -d "{\"action\":\"channel-private-create\",\"guildId\":\"${GUILD_ID}\",\"name\":\"wg-dryrun\",\"dryRun\":true}" \ - | sed -n '1,80p' - -if [[ -n "${CHANNEL_ID:-}" ]]; then - echo "[3] dry-run private update" - curl -sS -X POST "${BASE_URL}/v1/discord/action" \ - -H 'Content-Type: application/json' \ - "${AUTH_HEADER[@]}" \ - "${CALLER_HEADER[@]}" \ - -d "{\"action\":\"channel-private-update\",\"guildId\":\"${GUILD_ID}\",\"channelId\":\"${CHANNEL_ID}\",\"mode\":\"merge\",\"dryRun\":true}" \ - | sed -n '1,100p' -fi - -echo "[4] member-list (limit=1)" -curl -sS -X POST "${BASE_URL}/v1/discord/action" \ - -H 'Content-Type: application/json' \ - "${AUTH_HEADER[@]}" \ - "${CALLER_HEADER[@]}" \ - -d "{\"action\":\"member-list\",\"guildId\":\"${GUILD_ID}\",\"limit\":1,\"fields\":[\"user.id\",\"user.username\"]}" \ - | sed -n '1,120p' - -echo "smoke-discord-control: done"