feat: split dirigent_tools into individual tools + human @mention override
Feature 1: Split dirigent_tools - Replace monolithic dirigent_tools (9 actions) with 9 individual tools - Discord: dirigent_channel_create, dirigent_channel_update, dirigent_member_list - Policy: dirigent_policy_get, dirigent_policy_set, dirigent_policy_delete - Turn: dirigent_turn_status, dirigent_turn_advance, dirigent_turn_reset - Extract shared executeDiscordAction() helper Feature 2: Human @mention override - When humanList user @mentions agents, temporarily override turn order - Only mentioned agents cycle, ordered by their turn order position - Original order restores when cycle returns to first agent or all NO_REPLY - New: setMentionOverride(), hasMentionOverride(), extractMentionedUserIds() - New: buildUserIdToAccountIdMap() for reverse userId→accountId resolution Bump version to 0.3.0
This commit is contained in:
405
plugin/index.ts
405
plugin/index.ts
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js";
|
||||
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js";
|
||||
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo, setMentionOverride, hasMentionOverride } from "./turn-manager.js";
|
||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||
|
||||
// ── No-Reply API child process lifecycle ──────────────────────────────
|
||||
@@ -409,6 +409,44 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string
|
||||
return userIdFromToken(acct.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a reverse map: Discord userId → accountId for all configured Discord accounts.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Discord @mention user IDs from message content.
|
||||
* Matches <@USER_ID> and <@!USER_ID> patterns.
|
||||
* Returns user IDs in the order they appear.
|
||||
*/
|
||||
function extractMentionedUserIds(content: string): string[] {
|
||||
const regex = /<@!?(\d+)>/g;
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const id = match[1];
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Get the moderator bot's Discord user ID from its token */
|
||||
function getModeratorUserId(config: DirigentConfig): string | undefined {
|
||||
if (!config.moderatorBotToken) return undefined;
|
||||
@@ -555,18 +593,44 @@ export default {
|
||||
api.logger.info("dirigent: gateway stopping, services shut down");
|
||||
});
|
||||
|
||||
// ── Helper: execute Discord control API action ──
|
||||
async function executeDiscordAction(action: DiscordControlAction, params: Record<string, unknown>) {
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & {
|
||||
discordControlApiBaseUrl?: string;
|
||||
discordControlApiToken?: string;
|
||||
discordControlCallerId?: string;
|
||||
enableDiscordControlTool?: boolean;
|
||||
};
|
||||
if (live.enableDiscordControlTool === false) {
|
||||
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
|
||||
}
|
||||
const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, "");
|
||||
const body = pickDefined({ ...params, action });
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`;
|
||||
if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId;
|
||||
const r = await fetch(`${baseUrl}/v1/discord/action`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
return { content: [{ type: "text", text: `discord action failed (${r.status}): ${text}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
// ── Discord control tools ──
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_tools",
|
||||
description: "Dirigent unified tool: Discord admin actions + in-memory policy management.",
|
||||
name: "dirigent_channel_create",
|
||||
description: "Create a private Discord channel with specific user/role permissions.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel", "turn-status", "turn-advance", "turn-reset"],
|
||||
},
|
||||
guildId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
type: { type: "number" },
|
||||
@@ -578,127 +642,224 @@ export default {
|
||||
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: "dirigent_channel_update",
|
||||
description: "Update permissions on an existing private Discord channel.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
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 },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_member_list",
|
||||
description: "List members of a Discord guild.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
guildId: { type: "string" },
|
||||
limit: { type: "number" },
|
||||
after: { type: "string" },
|
||||
fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] },
|
||||
dryRun: { type: "boolean" },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
return executeDiscordAction("member-list", params);
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
);
|
||||
|
||||
// ── Policy tools ──
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_policy_get",
|
||||
description: "Get all Dirigent channel policies.",
|
||||
parameters: { type: "object", additionalProperties: false, properties: {}, required: [] },
|
||||
async execute() {
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean };
|
||||
if (live.enableDirigentPolicyTool === false) {
|
||||
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
|
||||
}
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }],
|
||||
};
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_policy_set",
|
||||
description: "Set or update a Dirigent channel policy.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
channelId: { type: "string" },
|
||||
listMode: { type: "string", enum: ["human-list", "agent-list"] },
|
||||
humanList: { type: "array", items: { type: "string" } },
|
||||
agentList: { type: "array", items: { type: "string" } },
|
||||
endSymbols: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["action"],
|
||||
required: ["channelId"],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & {
|
||||
discordControlApiBaseUrl?: string;
|
||||
discordControlApiToken?: string;
|
||||
discordControlCallerId?: string;
|
||||
enableDiscordControlTool?: boolean;
|
||||
enableDirigentPolicyTool?: boolean;
|
||||
};
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
const action = String(params.action || "");
|
||||
const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]);
|
||||
|
||||
if (discordActions.has(action)) {
|
||||
if (live.enableDiscordControlTool === false) {
|
||||
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
|
||||
}
|
||||
const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, "");
|
||||
const body = pickDefined({ ...params, action: action as DiscordControlAction });
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`;
|
||||
if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId;
|
||||
|
||||
const r = await fetch(`${baseUrl}/v1/discord/action`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `dirigent_tools discord failed (${r.status}): ${text}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean };
|
||||
if (live.enableDirigentPolicyTool === false) {
|
||||
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
|
||||
}
|
||||
|
||||
if (action === "policy-get") {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }],
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||
try {
|
||||
const next: ChannelPolicy = {
|
||||
listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined,
|
||||
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
|
||||
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
|
||||
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined,
|
||||
};
|
||||
policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
|
||||
persistPolicies(api);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] };
|
||||
} catch (err) {
|
||||
policyState.channelPolicies = prev;
|
||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||
}
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
);
|
||||
|
||||
if (action === "policy-set-channel") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
|
||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||
try {
|
||||
const next: ChannelPolicy = {
|
||||
listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined,
|
||||
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
|
||||
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
|
||||
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined,
|
||||
};
|
||||
policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
|
||||
persistPolicies(api);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] };
|
||||
} catch (err) {
|
||||
policyState.channelPolicies = prev;
|
||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||
}
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_policy_delete",
|
||||
description: "Delete a Dirigent channel policy.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
channelId: { type: "string" },
|
||||
},
|
||||
required: ["channelId"],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean };
|
||||
if (live.enableDirigentPolicyTool === false) {
|
||||
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
|
||||
}
|
||||
|
||||
if (action === "policy-delete-channel") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||
try {
|
||||
delete policyState.channelPolicies[channelId];
|
||||
persistPolicies(api);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] };
|
||||
} catch (err) {
|
||||
policyState.channelPolicies = prev;
|
||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||
}
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||
try {
|
||||
delete policyState.channelPolicies[channelId];
|
||||
persistPolicies(api);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] };
|
||||
} catch (err) {
|
||||
policyState.channelPolicies = prev;
|
||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||
}
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
);
|
||||
|
||||
if (action === "turn-status") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] };
|
||||
}
|
||||
// ── Turn management tools ──
|
||||
|
||||
if (action === "turn-advance") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] };
|
||||
}
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_turn_status",
|
||||
description: "Show turn-based speaking status for a channel.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
channelId: { type: "string" },
|
||||
},
|
||||
required: ["channelId"],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] };
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
);
|
||||
|
||||
if (action === "turn-reset") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
resetTurn(channelId);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
||||
}
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_turn_advance",
|
||||
description: "Manually advance the speaking turn in a channel.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
channelId: { type: "string" },
|
||||
},
|
||||
required: ["channelId"],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] };
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
);
|
||||
|
||||
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
||||
api.registerTool(
|
||||
{
|
||||
name: "dirigent_turn_reset",
|
||||
description: "Reset turn order for a channel (go dormant).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
channelId: { type: "string" },
|
||||
},
|
||||
required: ["channelId"],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
resetTurn(channelId);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
@@ -747,7 +908,49 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
// Human @mention override: when a human mentions specific agents,
|
||||
// temporarily override the turn order to only those agents.
|
||||
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) {
|
||||
// Build reverse map: userId → accountId
|
||||
const userIdMap = buildUserIdToAccountIdMap(api);
|
||||
// Exclude moderator bot from mention targets
|
||||
const mentionedAccountIds = mentionedUserIds
|
||||
.map(uid => userIdMap.get(uid))
|
||||
.filter((aid): aid is string => !!aid);
|
||||
|
||||
if (mentionedAccountIds.length > 0) {
|
||||
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))}`);
|
||||
}
|
||||
// Skip normal onNewMessage — override already set currentSpeaker
|
||||
} else {
|
||||
// No valid agents in mentions, fall through to normal handling
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
// Mentioned users aren't agents, normal human message
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
// No mentions, normal human message
|
||||
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"}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user