825 lines
32 KiB
TypeScript
825 lines
32 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js";
|
|
import { advanceTurn, resetTurn, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js";
|
|
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
|
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
|
import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
|
|
import { registerBeforePromptBuildHook } from "./hooks/before-prompt-build.js";
|
|
import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js";
|
|
import { registerMessageSentHook } from "./hooks/message-sent.js";
|
|
|
|
// ── No-Reply API child process lifecycle ──────────────────────────────
|
|
let noReplyProcess: ChildProcess | null = null;
|
|
|
|
function startNoReplyApi(logger: { info: (m: string) => void; warn: (m: string) => void }, pluginDir: string, port = 8787): void {
|
|
logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`);
|
|
|
|
if (noReplyProcess) {
|
|
logger.info("dirigent: no-reply API already running, skipping");
|
|
return;
|
|
}
|
|
|
|
const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs");
|
|
logger.info(`dirigent: resolved serverPath=${serverPath}`);
|
|
|
|
if (!fs.existsSync(serverPath)) {
|
|
logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`);
|
|
return;
|
|
}
|
|
|
|
logger.info(`dirigent: no-reply API server found, spawning process...`);
|
|
|
|
noReplyProcess = spawn(process.execPath, [serverPath], {
|
|
env: { ...process.env, PORT: String(port) },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
detached: false,
|
|
});
|
|
|
|
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
|
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
|
|
|
noReplyProcess.on("exit", (code, signal) => {
|
|
logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`);
|
|
noReplyProcess = null;
|
|
});
|
|
|
|
logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`);
|
|
}
|
|
|
|
function stopNoReplyApi(logger: { info: (m: string) => void }): void {
|
|
if (!noReplyProcess) return;
|
|
logger.info("dirigent: stopping no-reply API");
|
|
noReplyProcess.kill("SIGTERM");
|
|
noReplyProcess = null;
|
|
}
|
|
|
|
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
|
|
|
type DecisionRecord = {
|
|
decision: Decision;
|
|
createdAt: number;
|
|
needsRestore?: boolean;
|
|
};
|
|
|
|
type PolicyState = {
|
|
filePath: string;
|
|
channelPolicies: Record<string, ChannelPolicy>;
|
|
};
|
|
|
|
type DebugConfig = {
|
|
enableDebugLogs?: boolean;
|
|
debugLogChannelIds?: string[];
|
|
};
|
|
|
|
const sessionDecision = new Map<string, DecisionRecord>();
|
|
const sessionAllowed = new Map<string, boolean>(); // Track if session was allowed to speak (true) or forced no-reply (false)
|
|
const sessionInjected = new Set<string>(); // Track which sessions have already injected the end marker
|
|
const sessionChannelId = new Map<string, string>(); // Track sessionKey -> channelId mapping
|
|
const sessionAccountId = new Map<string, string>(); // Track sessionKey -> accountId mapping
|
|
const sessionTurnHandled = new Set<string>(); // Track sessions where turn was already advanced in before_message_write
|
|
const MAX_SESSION_DECISIONS = 2000;
|
|
const DECISION_TTL_MS = 5 * 60 * 1000;
|
|
|
|
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string {
|
|
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
|
|
let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK) must NOT include ${symbols}.`;
|
|
if (isGroupChat) {
|
|
instruction += `\n\nGroup chat rules: If this message is not relevant to you, does not need your response, or you have nothing valuable to add, reply with NO_REPLY. Do not speak just for the sake of speaking.`;
|
|
instruction += `\n\nWait for human reply: If you need a human to respond to your message, end with ${waitIdentifier} instead of ${symbols}. This pauses all agents until a human speaks. Use this sparingly — only when you are confident the human is actively participating in the discussion (has sent a message recently). Do NOT use it speculatively.`;
|
|
}
|
|
return instruction;
|
|
}
|
|
|
|
function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): string {
|
|
return `\n\nScheduling identifier: "${schedulingIdentifier}". This identifier itself is meaningless — it carries no semantic content. When you receive a message containing <@YOUR_USER_ID> followed by the scheduling identifier, check recent chat history and decide whether you have something to say. If not, reply NO_REPLY.`;
|
|
}
|
|
|
|
const policyState: PolicyState = {
|
|
filePath: "",
|
|
channelPolicies: {},
|
|
};
|
|
|
|
function pruneDecisionMap(now = Date.now()) {
|
|
for (const [k, v] of sessionDecision.entries()) {
|
|
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
|
}
|
|
|
|
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
|
|
const keys = sessionDecision.keys();
|
|
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
|
|
const k = keys.next();
|
|
if (k.done) break;
|
|
sessionDecision.delete(k.value);
|
|
}
|
|
}
|
|
|
|
|
|
function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig {
|
|
const root = (api.config as Record<string, unknown>) || {};
|
|
const plugins = (root.plugins as Record<string, unknown>) || {};
|
|
const entries = (plugins.entries as Record<string, unknown>) || {};
|
|
// Support both "dirigent" and legacy "whispergate" config keys
|
|
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) {
|
|
// Merge with defaults to ensure optional fields have values
|
|
return {
|
|
enableDiscordControlTool: true,
|
|
enableDirigentPolicyTool: true,
|
|
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
|
enableDebugLogs: false,
|
|
debugLogChannelIds: [],
|
|
schedulingIdentifier: "➡️",
|
|
waitIdentifier: "👤",
|
|
...cfg,
|
|
} as DirigentConfig;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string {
|
|
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json");
|
|
}
|
|
|
|
function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig) {
|
|
if (policyState.filePath) return;
|
|
const filePath = resolvePoliciesPath(api, config);
|
|
policyState.filePath = filePath;
|
|
|
|
try {
|
|
if (!fs.existsSync(filePath)) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, "{}\n", "utf8");
|
|
policyState.channelPolicies = {};
|
|
return;
|
|
}
|
|
|
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
|
|
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch (err) {
|
|
api.logger.warn(`dirigent: failed init policy file ${filePath}: ${String(err)}`);
|
|
policyState.channelPolicies = {};
|
|
}
|
|
}
|
|
|
|
/** Resolve agentId → Discord accountId from config bindings */
|
|
function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
|
|
const root = (api.config as Record<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;
|
|
}
|
|
|
|
/**
|
|
* Get all Discord bot accountIds from config bindings.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Track which bot accountIds have been seen in each channel via message_received.
|
|
* Key: channelId, Value: Set of accountIds seen.
|
|
*/
|
|
const channelSeenAccounts = new Map<string, Set<string>>();
|
|
|
|
/**
|
|
* Record a bot accountId seen in a channel.
|
|
* Returns true if this is a new account for this channel (turn order should be updated).
|
|
*/
|
|
function recordChannelAccount(channelId: string, accountId: string): boolean {
|
|
let seen = channelSeenAccounts.get(channelId);
|
|
if (!seen) {
|
|
seen = new Set();
|
|
channelSeenAccounts.set(channelId, seen);
|
|
}
|
|
if (seen.has(accountId)) return false;
|
|
seen.add(accountId);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get the list of bot accountIds seen in a channel.
|
|
* Only returns accounts that are also in the global bindings (actual bots).
|
|
*/
|
|
function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] {
|
|
const allBots = new Set(getAllBotAccountIds(api));
|
|
const seen = channelSeenAccounts.get(channelId);
|
|
if (!seen) return [];
|
|
return [...seen].filter(id => allBots.has(id));
|
|
}
|
|
|
|
/**
|
|
* Ensure turn order is initialized for a channel.
|
|
* Uses only bot accounts that have been seen in this channel.
|
|
*/
|
|
function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void {
|
|
const botAccounts = getChannelBotAccountIds(api, channelId);
|
|
if (botAccounts.length > 0) {
|
|
initTurnOrder(channelId, botAccounts);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build agent identity string for injection into group chat prompts.
|
|
* Includes agent name, Discord accountId, and Discord userId.
|
|
*/
|
|
function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
|
|
const root = (api.config as Record<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;
|
|
|
|
// Find accountId for this agent
|
|
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;
|
|
|
|
// Find agent name
|
|
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
|
|
const name = (agent?.name as string) || agentId;
|
|
|
|
// Resolve Discord userId from bot token
|
|
const discordUserId = resolveDiscordUserId(api, accountId);
|
|
|
|
let identity = `You are ${name} (Discord account: ${accountId}`;
|
|
if (discordUserId) {
|
|
identity += `, Discord userId: ${discordUserId}`;
|
|
}
|
|
identity += `).`;
|
|
|
|
return identity;
|
|
}
|
|
|
|
// --- Moderator bot helpers ---
|
|
|
|
/** Extract Discord user ID from a bot token (base64-encoded in first segment) */
|
|
function userIdFromToken(token: string): string | undefined {
|
|
try {
|
|
const segment = token.split(".")[0];
|
|
// Add padding
|
|
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
|
return Buffer.from(padded, "base64").toString("utf8");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Resolve accountId → Discord user ID by reading the account's bot token from config */
|
|
function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined {
|
|
const root = (api.config as Record<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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
return userIdFromToken(config.moderatorBotToken);
|
|
}
|
|
|
|
/** Send a message as the moderator bot via Discord REST API */
|
|
async function sendModeratorMessage(token: string, channelId: string, content: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }): Promise<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;
|
|
}
|
|
}
|
|
|
|
function persistPolicies(api: OpenClawPluginApi): void {
|
|
const filePath = policyState.filePath;
|
|
if (!filePath) throw new Error("policy file path not initialized");
|
|
const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n";
|
|
const tmp = `${filePath}.tmp`;
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(tmp, before, "utf8");
|
|
fs.renameSync(tmp, filePath);
|
|
api.logger.info(`dirigent: policy file persisted: ${filePath}`);
|
|
}
|
|
|
|
function pickDefined(input: Record<string, unknown>) {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(input)) {
|
|
if (v !== undefined) out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
|
|
if (!cfg.enableDebugLogs) return false;
|
|
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
|
|
if (allow.length === 0) return true;
|
|
if (!channelId) return true;
|
|
return allow.includes(channelId);
|
|
}
|
|
|
|
function debugCtxSummary(ctx: Record<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,
|
|
};
|
|
}
|
|
|
|
export default {
|
|
id: "dirigent",
|
|
name: "Dirigent",
|
|
register(api: OpenClawPluginApi) {
|
|
// Merge pluginConfig with defaults (in case config is missing from openclaw.json)
|
|
const baseConfig = {
|
|
enableDiscordControlTool: true,
|
|
enableDirigentPolicyTool: true,
|
|
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
|
schedulingIdentifier: "➡️",
|
|
waitIdentifier: "👤",
|
|
...(api.pluginConfig || {}),
|
|
} as DirigentConfig & {
|
|
enableDiscordControlTool: boolean;
|
|
discordControlApiBaseUrl: string;
|
|
discordControlApiToken?: string;
|
|
discordControlCallerId?: string;
|
|
enableDirigentPolicyTool: boolean;
|
|
};
|
|
|
|
const liveAtRegister = getLivePluginConfig(api, baseConfig as DirigentConfig);
|
|
ensurePolicyStateLoaded(api, liveAtRegister);
|
|
|
|
// Resolve plugin directory for locating sibling modules (no-reply-api/)
|
|
// Note: api.resolvePath(".") returns cwd, not script directory. Use import.meta.url instead.
|
|
const pluginDir = path.dirname(new URL(import.meta.url).pathname);
|
|
api.logger.info(`dirigent: pluginDir resolved from import.meta.url: ${pluginDir}`);
|
|
|
|
// Gateway lifecycle: start/stop no-reply API and moderator bot with the gateway
|
|
api.on("gateway_start", () => {
|
|
api.logger.info(`dirigent: gateway_start event received`);
|
|
|
|
// Check no-reply-api server file exists
|
|
const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs");
|
|
api.logger.info(`dirigent: checking no-reply-api server at ${serverPath}, exists=${fs.existsSync(serverPath)}`);
|
|
|
|
// Additional debug: list what's in the parent directory
|
|
const parentDir = path.resolve(pluginDir, "..");
|
|
try {
|
|
const entries = fs.readdirSync(parentDir);
|
|
api.logger.info(`dirigent: parent dir (${parentDir}) entries: ${JSON.stringify(entries)}`);
|
|
} catch (e) {
|
|
api.logger.warn(`dirigent: cannot read parent dir: ${String(e)}`);
|
|
}
|
|
|
|
startNoReplyApi(api.logger, pluginDir);
|
|
|
|
const live = getLivePluginConfig(api, baseConfig as DirigentConfig);
|
|
api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`);
|
|
|
|
if (live.moderatorBotToken) {
|
|
api.logger.info("dirigent: starting moderator bot presence...");
|
|
startModeratorPresence(live.moderatorBotToken, api.logger);
|
|
api.logger.info("dirigent: moderator bot presence started");
|
|
} else {
|
|
api.logger.info("dirigent: moderator bot not starting - no moderatorBotToken in config");
|
|
}
|
|
});
|
|
|
|
api.on("gateway_stop", () => {
|
|
stopNoReplyApi(api.logger);
|
|
stopModeratorPresence();
|
|
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_discord_channel_create",
|
|
description: "Create a private Discord channel with specific user/role permissions.",
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
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: "dirigent_discord_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_discord_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" } }] },
|
|
},
|
|
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: ["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 };
|
|
}
|
|
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 },
|
|
);
|
|
|
|
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 };
|
|
}
|
|
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 },
|
|
);
|
|
|
|
// Turn management is handled internally by the plugin (not exposed as tools).
|
|
// Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control.
|
|
|
|
registerMessageReceivedHook({
|
|
api,
|
|
baseConfig: baseConfig as DirigentConfig,
|
|
getLivePluginConfig,
|
|
shouldDebugLog,
|
|
debugCtxSummary,
|
|
ensureTurnOrder,
|
|
getModeratorUserId,
|
|
recordChannelAccount,
|
|
extractMentionedUserIds,
|
|
buildUserIdToAccountIdMap,
|
|
});
|
|
|
|
registerBeforeModelResolveHook({
|
|
api,
|
|
baseConfig: baseConfig as DirigentConfig,
|
|
sessionDecision,
|
|
sessionAllowed,
|
|
sessionChannelId,
|
|
sessionAccountId,
|
|
policyState,
|
|
DECISION_TTL_MS,
|
|
ensurePolicyStateLoaded,
|
|
getLivePluginConfig,
|
|
resolveAccountId,
|
|
pruneDecisionMap,
|
|
shouldDebugLog,
|
|
ensureTurnOrder,
|
|
});
|
|
|
|
registerBeforePromptBuildHook({
|
|
api,
|
|
baseConfig: baseConfig as DirigentConfig,
|
|
sessionDecision,
|
|
sessionInjected,
|
|
policyState,
|
|
DECISION_TTL_MS,
|
|
ensurePolicyStateLoaded,
|
|
getLivePluginConfig,
|
|
shouldDebugLog,
|
|
buildEndMarkerInstruction,
|
|
buildSchedulingIdentifierInstruction,
|
|
buildAgentIdentity,
|
|
});
|
|
|
|
// Register slash commands for Discord
|
|
api.registerCommand({
|
|
name: "dirigent",
|
|
description: "Dirigent channel policy management",
|
|
acceptsArgs: true,
|
|
handler: async (cmdCtx) => {
|
|
const args = cmdCtx.args || "";
|
|
const parts = args.trim().split(/\s+/);
|
|
const subCmd = parts[0] || "help";
|
|
|
|
if (subCmd === "help") {
|
|
return { text: `Dirigent commands:\n` +
|
|
`/dirigent status - Show current channel status\n` +
|
|
`/dirigent turn-status - Show turn-based speaking status\n` +
|
|
`/dirigent turn-advance - Manually advance turn\n` +
|
|
`/dirigent turn-reset - Reset turn order` };
|
|
}
|
|
|
|
if (subCmd === "status") {
|
|
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
|
|
}
|
|
|
|
if (subCmd === "turn-status") {
|
|
const channelId = cmdCtx.channelId;
|
|
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
|
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
|
|
}
|
|
|
|
if (subCmd === "turn-advance") {
|
|
const channelId = cmdCtx.channelId;
|
|
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
|
const next = advanceTurn(channelId);
|
|
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
|
|
}
|
|
|
|
if (subCmd === "turn-reset") {
|
|
const channelId = cmdCtx.channelId;
|
|
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
|
resetTurn(channelId);
|
|
return { text: JSON.stringify({ ok: true }) };
|
|
}
|
|
|
|
return { text: `Unknown subcommand: ${subCmd}`, isError: true };
|
|
},
|
|
});
|
|
|
|
// Handle NO_REPLY detection before message write
|
|
registerBeforeMessageWriteHook({
|
|
api,
|
|
baseConfig: baseConfig as DirigentConfig,
|
|
policyState,
|
|
sessionAllowed,
|
|
sessionChannelId,
|
|
sessionAccountId,
|
|
sessionTurnHandled,
|
|
ensurePolicyStateLoaded,
|
|
getLivePluginConfig,
|
|
shouldDebugLog,
|
|
ensureTurnOrder,
|
|
resolveDiscordUserId,
|
|
sendModeratorMessage,
|
|
});
|
|
|
|
// Turn advance: when an agent sends a message, check if it signals end of turn
|
|
registerMessageSentHook({
|
|
api,
|
|
baseConfig: baseConfig as DirigentConfig,
|
|
policyState,
|
|
sessionChannelId,
|
|
sessionAccountId,
|
|
sessionTurnHandled,
|
|
ensurePolicyStateLoaded,
|
|
getLivePluginConfig,
|
|
resolveDiscordUserId,
|
|
sendModeratorMessage,
|
|
});
|
|
},
|
|
};
|