feat: split dirigent_tools + human @mention override #14

Merged
hzhang merged 37 commits from feat/split-tools-and-mention-override into main 2026-03-08 08:02:28 +00:00
42 changed files with 2630 additions and 2154 deletions

View File

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

158
FEAT.md Normal file
View File

@@ -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` 提高可观测性与排障效率。

View File

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

View File

@@ -1,4 +0,0 @@
# New Features
1. 拆分 dirigent-tools不再用一个主工具管理多个小工具。
2. 人类@规则:当来自 humanList 的用户消息包含 <@USER_ID> 时,按被 @ 的 agent 顺序临时覆盖 speaking order 循环;回到首个 agent 后恢复原顺序。

View File

@@ -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`

View File

@@ -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.

View File

@@ -1,9 +0,0 @@
{
"name": "dirigent-discord-control-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}

View File

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

View File

@@ -21,7 +21,6 @@
"enableDirigentPolicyTool": true,
"enableDebugLogs": false,
"debugLogChannelIds": [],
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
"discordControlCallerId": "agent-main"
}

View File

@@ -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 <AUTH_TOKEN>`(若配置)
- Header: `X-OpenClaw-Caller-Id: <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 并透传错误细节。

View File

@@ -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.<no-reply-provider>`
- 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

View File

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

View File

@@ -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) 运行与安全校验

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -0,0 +1,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<string, unknown> };
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 <discordChannelId>\n` +
`/dirigent_policy set <discordChannelId> <policy-json>\n` +
`/dirigent_policy delete <discordChannelId>`,
};
}
if (subCmd === "status") {
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
}
if (subCmd === "turn-status") {
const channelId = cmdCtx.channelId;
if (!channelId) return { text: "Cannot get channel ID", isError: true };
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
}
if (subCmd === "turn-advance") {
const channelId = cmdCtx.channelId;
if (!channelId) return { text: "Cannot get channel ID", isError: true };
const next = advanceTurn(channelId);
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
}
if (subCmd === "turn-reset") {
const channelId = cmdCtx.channelId;
if (!channelId) return { text: "Cannot get channel ID", isError: true };
resetTurn(channelId);
return { text: JSON.stringify({ ok: true }) };
}
return { text: `Unknown subcommand: ${subCmd}`, isError: true };
},
});
api.registerCommand({
name: "dirigent_policy",
description: "Dirigent channel policy CRUD",
acceptsArgs: true,
handler: async (cmdCtx) => {
const live = getLivePluginConfig(api, baseConfig);
ensurePolicyStateLoaded(api, live);
const args = (cmdCtx.args || "").trim();
if (!args) {
return {
text:
"Usage:\n" +
"/dirigent_policy get <discordChannelId>\n" +
"/dirigent_policy set <discordChannelId> <policy-json>\n" +
"/dirigent_policy delete <discordChannelId>",
isError: true,
};
}
const [opRaw, channelIdRaw, ...rest] = args.split(/\s+/);
const op = (opRaw || "").toLowerCase();
const channelId = (channelIdRaw || "").trim();
if (!channelId || !/^\d+$/.test(channelId)) {
return { text: "channelId is required and must be numeric Discord channel id", isError: true };
}
if (op === "get") {
const policy = (policyState.channelPolicies as Record<string, unknown>)[channelId];
return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) };
}
if (op === "delete") {
delete (policyState.channelPolicies as Record<string, unknown>)[channelId];
persistPolicies(api);
return { text: JSON.stringify({ ok: true, channelId, deleted: true }) };
}
if (op === "set") {
const jsonText = rest.join(" ").trim();
if (!jsonText) {
return { text: "set requires <policy-json>", isError: true };
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(jsonText);
} catch (e) {
return { text: `invalid policy-json: ${String(e)}`, isError: true };
}
const next: Record<string, unknown> = {};
if (typeof parsed.listMode === "string") next.listMode = parsed.listMode;
if (Array.isArray(parsed.humanList)) next.humanList = parsed.humanList.map(String);
if (Array.isArray(parsed.agentList)) next.agentList = parsed.agentList.map(String);
if (Array.isArray(parsed.endSymbols)) next.endSymbols = parsed.endSymbols.map(String);
(policyState.channelPolicies as Record<string, unknown>)[channelId] = next;
persistPolicies(api);
return { text: JSON.stringify({ ok: true, channelId, policy: next }, null, 2) };
}
return { text: `unsupported op: ${op}. use get|set|delete`, isError: true };
},
});
}

View File

@@ -0,0 +1,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<string, bigint>, 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<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
for (const rec of Object.values(accounts)) {
if (typeof rec?.token === "string" && rec.token) return rec.token;
}
return undefined;
}
export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise<string[]> {
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<string, bigint>();
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<string>();
for (const uid of visibleUserIds) {
const aid = userToAccount.get(uid);
if (aid) out.add(aid);
}
return [...out];
}

79
plugin/core/identity.ts Normal file
View File

@@ -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<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const acct = accounts[accountId];
if (!acct?.token || typeof acct.token !== "string") return undefined;
return userIdFromToken(acct.token);
}
export function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
return match.accountId;
}
}
}
return undefined;
}
export function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
if (!Array.isArray(bindings)) return undefined;
let accountId: string | undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
accountId = match.accountId;
break;
}
}
}
if (!accountId) return undefined;
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
const name = (agent?.name as string) || agentId;
const discordUserId = resolveDiscordUserIdFromAccount(api, accountId);
let identity = `You are ${name} (Discord account: ${accountId}`;
if (discordUserId) identity += `, Discord userId: ${discordUserId}`;
identity += `).`;
return identity;
}
export function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map<string, string> {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const map = new Map<string, string>();
for (const [accountId, acct] of Object.entries(accounts)) {
if (typeof acct.token === "string") {
const userId = userIdFromToken(acct.token);
if (userId) map.set(userId, accountId);
}
}
return map;
}

View File

@@ -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<string, unknown>) || {};
const plugins = (root.plugins as Record<string, unknown>) || {};
const entries = (plugins.entries as Record<string, unknown>) || {};
const entry = (entries.dirigent as Record<string, unknown>) || (entries.whispergate as Record<string, unknown>) || {};
const cfg = (entry.config as Record<string, unknown>) || {};
if (Object.keys(cfg).length > 0) {
return {
enableDiscordControlTool: true,
enableDirigentPolicyTool: true,
enableDebugLogs: false,
debugLogChannelIds: [],
noReplyPort: 8787,
schedulingIdentifier: "➡️",
waitIdentifier: "👤",
...cfg,
} as DirigentConfig;
}
return fallback;
}

31
plugin/core/mentions.ts Normal file
View File

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

View File

@@ -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<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const acct = accounts[accountId];
if (!acct?.token || typeof acct.token !== "string") return undefined;
return userIdFromToken(acct.token);
}
export async function sendModeratorMessage(
token: string,
channelId: string,
content: string,
logger: { info: (msg: string) => void; warn: (msg: string) => void },
): Promise<boolean> {
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;
}
}

View File

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

View File

@@ -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<string, DecisionRecord>();
export const sessionAllowed = new Map<string, boolean>();
export const sessionInjected = new Set<string>();
export const sessionChannelId = new Map<string, string>();
export const sessionAccountId = new Map<string, string>();
export const sessionTurnHandled = new Set<string>();
export function pruneDecisionMap(now = Date.now()): void {
for (const [k, v] of sessionDecision.entries()) {
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
}
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
const keys = sessionDecision.keys();
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
const k = keys.next();
if (k.done) break;
sessionDecision.delete(k.value);
}
}

View File

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

45
plugin/core/utils.ts Normal file
View File

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

37
plugin/decision-input.ts Normal file
View File

@@ -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<string, unknown>;
};
export function deriveDecisionInputFromPrompt(params: {
prompt: string;
messageProvider?: string;
sessionKey?: string;
ctx?: Record<string, unknown>;
event?: Record<string, unknown>;
}): DerivedDecisionInput {
const { prompt, messageProvider, sessionKey, ctx, event } = params;
const conv = extractUntrustedConversationInfo(prompt) || {};
const channel = (messageProvider || "").toLowerCase();
let channelId = extractDiscordChannelId(ctx || {}, event);
if (!channelId) channelId = extractDiscordChannelIdFromSessionKey(sessionKey);
if (!channelId) channelId = extractDiscordChannelIdFromConversationMetadata(conv);
const senderId =
(typeof conv.sender_id === "string" && conv.sender_id) ||
(typeof conv.sender === "string" && conv.sender) ||
undefined;
return { channel, channelId, senderId, content: prompt, conv };
}

View File

@@ -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<string, unknown> };
sessionAllowed: Map<string, boolean>;
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
sessionTurnHandled: Set<string>;
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> | void;
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
sendModeratorMessage: (
botToken: string,
channelId: string,
content: string,
logger: { info: (m: string) => void; warn: (m: string) => void },
) => Promise<void>;
};
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
const {
api,
baseConfig,
policyState,
sessionAllowed,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded,
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<string, unknown>).message as Record<string, unknown> | 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<string, unknown>).text === "string") {
content += (part as Record<string, unknown>).text;
}
}
}
}
if (!content) {
content = ((event as Record<string, unknown>).content as string) || "";
}
api.logger.info(
`dirigent: DEBUG before_message_write session=${key ?? "undefined"} channel=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} contentType=${typeof content} content=${String(content).slice(0, 200)}`,
);
if (!key || !channelId || !accountId) return;
const currentTurn = getTurnDebugInfo(channelId);
if (currentTurn.currentSpeaker !== accountId) {
api.logger.info(
`dirigent: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`,
);
return;
}
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
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)}`);
}
});
}

View File

@@ -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<string, DecisionRecord>;
sessionAllowed: Map<string, boolean>;
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
policyState: { channelPolicies: Record<string, unknown> };
DECISION_TTL_MS: number;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
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> | 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<string, unknown>).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<string, unknown>,
event: event as Record<string, unknown>,
});
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
if (derived.channelId) {
sessionChannelId.set(key, derived.channelId);
}
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
if (resolvedAccountId) {
sessionAccountId.set(key, resolvedAccountId);
}
let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key);
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies as Record<string, any>,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
sessionDecision.set(key, rec);
pruneDecisionMap();
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`dirigent: debug before_model_resolve recompute session=${key} ` +
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
}
}
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<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).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,
};
});
}

View File

@@ -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<string, DecisionRecord>;
sessionInjected: Set<string>;
policyState: { channelPolicies: Record<string, unknown> };
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<string, unknown>).prompt as string) || "";
const derived = deriveDecisionInputFromPrompt({
prompt,
messageProvider: ctx.messageProvider,
sessionKey: key,
ctx: ctx as Record<string, unknown>,
event: event as Record<string, unknown>,
});
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies as Record<string, any>,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`dirigent: debug before_prompt_build recompute session=${key} ` +
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
}
}
sessionDecision.delete(key);
if (sessionInjected.has(key)) {
if (shouldDebugLog(live, undefined)) {
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`);
}
return;
}
if (!rec.decision.shouldInjectEndMarkerPrompt) {
if (shouldDebugLog(live, undefined)) {
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`);
}
return;
}
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
const derived = deriveDecisionInputFromPrompt({
prompt,
messageProvider: ctx.messageProvider,
sessionKey: key,
ctx: ctx as Record<string, unknown>,
event: event as Record<string, unknown>,
});
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record<string, any>);
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
const schedulingId = live.schedulingIdentifier || "➡️";
const waitId = live.waitIdentifier || "👤";
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId);
let identity = "";
if (isGroupChat && ctx.agentId) {
const idStr = buildAgentIdentity(api, ctx.agentId);
if (idStr) identity = 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 };
});
}

View File

@@ -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<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
getModeratorUserId: (cfg: DirigentConfig) => string | undefined;
recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean;
extractMentionedUserIds: (content: string) => string[];
buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map<string, string>;
};
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<string, unknown>;
const e = (event || {}) as Record<string, unknown>;
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<string, unknown>).metadata as Record<string, unknown> | undefined;
const from =
(typeof metadata?.senderId === "string" && metadata.senderId) ||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
const moderatorUserId = getModeratorUserId(livePre);
if (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<string, unknown>).content as string) || ((e as Record<string, unknown>).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)}`);
}
});
}

View File

@@ -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<string, unknown> };
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
sessionTurnHandled: Set<string>;
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<void>;
};
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<string, unknown>;
const e = (event || {}) as Record<string, unknown>;
api.logger.info(
`dirigent: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` +
`ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` +
`ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` +
`session=${key ?? "undefined"}`,
);
let channelId = extractDiscordChannelId(c, e);
if (!channelId && key) {
channelId = sessionChannelId.get(key);
}
if (!channelId && key) {
channelId = extractDiscordChannelIdFromSessionKey(key);
}
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
const content = (event.content as string) || "";
api.logger.info(
`dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
);
if (!channelId || !accountId) return;
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
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)}`);
}
});
}

File diff suppressed because it is too large Load Diff

View File

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

50
plugin/policy/store.ts Normal file
View File

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

View File

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

View File

@@ -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<string, unknown>) => Record<string, unknown>;
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
};
function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") {
return { accountId, token: accounts[accountId].token as string };
}
for (const [aid, rec] of Object.entries(accounts)) {
if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token };
}
return null;
}
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
return executeDiscordAction("channel-private-update", params);
},
}, { optional: false });
}

View File

@@ -20,6 +20,14 @@ export type ChannelTurnState = {
noRepliedThisCycle: Set<string>;
/** Timestamp of last state change */
lastChangedAt: number;
// ── Mention override state ──
/** Original turn order saved when override is active */
savedTurnOrder?: string[];
/** First agent in override cycle; used to detect cycle completion */
overrideFirstAgent?: string;
// ── Wait-for-human state ──
/** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */
waitingForHuman: boolean;
};
const channelTurns = new Map<string, ChannelTurnState>();
@@ -47,19 +55,66 @@ function shuffleArray<T>(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: 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`,
);
return;
}
console.log(
`[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`,
);
const nextOrder = shuffleArray(botAccountIds);
channelTurns.set(channelId, {
turnOrder: nextOrder,
currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
waitingForHuman: false,
});
console.log(
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
);
}
/**
@@ -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<string, unknown> {
noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null,
waitingForHuman: state.waitingForHuman,
hasOverride: !!state.savedTurnOrder,
overrideFirstAgent: state.overrideFirstAgent || null,
savedTurnOrder: state.savedTurnOrder || null,
};
}

View File

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

279
scripts/install.mjs Executable file
View File

@@ -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 <path>] [--no-reply-port <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);
}

View File

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