feat: split dirigent_tools + human @mention override #14
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
- **Split `dirigent_tools` into individual tools**: Each action is now a separate tool with focused parameters:
|
||||||
|
- `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` (Discord control)
|
||||||
|
- `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete` (policy management)
|
||||||
|
- `dirigent_turn_status`, `dirigent_turn_advance`, `dirigent_turn_reset` (turn management)
|
||||||
|
- **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
|
||||||
|
|
||||||
## 0.2.0
|
## 0.2.0
|
||||||
|
|
||||||
- **Project renamed from WhisperGate to Dirigent**
|
- **Project renamed from WhisperGate to Dirigent**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# New Features
|
# New Features
|
||||||
|
|
||||||
1. 拆分 dirigent-tools:不再用一个主工具管理多个小工具。
|
1. 拆分 dirigent-tools:不再用一个主工具管理多个小工具。 ✅
|
||||||
2. 人类@规则:当来自 humanList 的用户消息包含 <@USER_ID> 时,按被 @ 的 agent 顺序临时覆盖 speaking order 循环;回到首个 agent 后恢复原顺序。
|
2. 人类@规则:当来自 humanList 的用户消息包含 <@USER_ID> 时,按被 @ 的 agent 顺序临时覆盖 speaking order 循环;回到首个 agent 后恢复原顺序。 ✅
|
||||||
|
|||||||
40
TASKLIST.md
40
TASKLIST.md
@@ -39,11 +39,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6) Split dirigent_tools into Individual Tools ✅
|
||||||
|
- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions.
|
||||||
|
- **After**: 9 individual tools, each with focused parameters:
|
||||||
|
- `dirigent_channel_create` — Create private Discord channel
|
||||||
|
- `dirigent_channel_update` — Update channel permissions
|
||||||
|
- `dirigent_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
|
||||||
|
- `dirigent_turn_status` — Show turn status
|
||||||
|
- `dirigent_turn_advance` — Manually advance turn
|
||||||
|
- `dirigent_turn_reset` — Reset turn order
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Open Items / Notes
|
## 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.
|
- 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.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@hangman-lab/dirigent",
|
"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",
|
"description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
335
plugin/index.ts
335
plugin/index.ts
@@ -3,7 +3,7 @@ import path from "node:path";
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js";
|
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";
|
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||||
|
|
||||||
// ── No-Reply API child process lifecycle ──────────────────────────────
|
// ── No-Reply API child process lifecycle ──────────────────────────────
|
||||||
@@ -409,6 +409,44 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string
|
|||||||
return userIdFromToken(acct.token);
|
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 */
|
/** Get the moderator bot's Discord user ID from its token */
|
||||||
function getModeratorUserId(config: DirigentConfig): string | undefined {
|
function getModeratorUserId(config: DirigentConfig): string | undefined {
|
||||||
if (!config.moderatorBotToken) return undefined;
|
if (!config.moderatorBotToken) return undefined;
|
||||||
@@ -555,18 +593,44 @@ export default {
|
|||||||
api.logger.info("dirigent: gateway stopping, services shut down");
|
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(
|
api.registerTool(
|
||||||
{
|
{
|
||||||
name: "dirigent_tools",
|
name: "dirigent_channel_create",
|
||||||
description: "Dirigent unified tool: Discord admin actions + in-memory policy management.",
|
description: "Create a private Discord channel with specific user/role permissions.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
properties: {
|
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" },
|
guildId: { type: "string" },
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
type: { type: "number" },
|
type: { type: "number" },
|
||||||
@@ -578,75 +642,108 @@ export default {
|
|||||||
allowedRoleIds: { type: "array", items: { type: "string" } },
|
allowedRoleIds: { type: "array", items: { type: "string" } },
|
||||||
allowMask: { type: "string" },
|
allowMask: { type: "string" },
|
||||||
denyEveryoneMask: { 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" },
|
channelId: { type: "string" },
|
||||||
mode: { type: "string", enum: ["merge", "replace"] },
|
mode: { type: "string", enum: ["merge", "replace"] },
|
||||||
addUserIds: { type: "array", items: { type: "string" } },
|
addUserIds: { type: "array", items: { type: "string" } },
|
||||||
addRoleIds: { type: "array", items: { type: "string" } },
|
addRoleIds: { type: "array", items: { type: "string" } },
|
||||||
removeTargetIds: { type: "array", items: { type: "string" } },
|
removeTargetIds: { type: "array", items: { type: "string" } },
|
||||||
|
allowMask: { type: "string" },
|
||||||
denyMask: { 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" },
|
limit: { type: "number" },
|
||||||
after: { type: "string" },
|
after: { type: "string" },
|
||||||
fields: { anyOf: [{ type: "string" }, { type: "array", items: { 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"] },
|
listMode: { type: "string", enum: ["human-list", "agent-list"] },
|
||||||
humanList: { type: "array", items: { type: "string" } },
|
humanList: { type: "array", items: { type: "string" } },
|
||||||
agentList: { type: "array", items: { type: "string" } },
|
agentList: { type: "array", items: { type: "string" } },
|
||||||
endSymbols: { type: "array", items: { type: "string" } },
|
endSymbols: { type: "array", items: { type: "string" } },
|
||||||
},
|
},
|
||||||
required: ["action"],
|
required: ["channelId"],
|
||||||
},
|
},
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & {
|
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean };
|
||||||
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 }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (live.enableDirigentPolicyTool === false) {
|
if (live.enableDirigentPolicyTool === false) {
|
||||||
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
|
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
|
||||||
}
|
}
|
||||||
|
ensurePolicyStateLoaded(api, live);
|
||||||
if (action === "policy-get") {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "policy-set-channel") {
|
|
||||||
const channelId = String(params.channelId || "").trim();
|
const channelId = String(params.channelId || "").trim();
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
|
|
||||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||||
try {
|
try {
|
||||||
const next: ChannelPolicy = {
|
const next: ChannelPolicy = {
|
||||||
@@ -662,9 +759,29 @@ export default {
|
|||||||
policyState.channelPolicies = prev;
|
policyState.channelPolicies = prev;
|
||||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
{ optional: false },
|
||||||
|
);
|
||||||
|
|
||||||
if (action === "policy-delete-channel") {
|
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();
|
const channelId = String(params.channelId || "").trim();
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||||
@@ -676,29 +793,73 @@ export default {
|
|||||||
policyState.channelPolicies = prev;
|
policyState.channelPolicies = prev;
|
||||||
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
{ optional: false },
|
||||||
|
);
|
||||||
|
|
||||||
if (action === "turn-status") {
|
// ── Turn management tools ──
|
||||||
|
|
||||||
|
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();
|
const channelId = String(params.channelId || "").trim();
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] };
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
{ optional: false },
|
||||||
|
);
|
||||||
|
|
||||||
if (action === "turn-advance") {
|
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();
|
const channelId = String(params.channelId || "").trim();
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
const next = advanceTurn(channelId);
|
const next = advanceTurn(channelId);
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] };
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
{ optional: false },
|
||||||
|
);
|
||||||
|
|
||||||
if (action === "turn-reset") {
|
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();
|
const channelId = String(params.channelId || "").trim();
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
resetTurn(channelId);
|
resetTurn(channelId);
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
||||||
}
|
|
||||||
|
|
||||||
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ optional: false },
|
{ optional: false },
|
||||||
@@ -747,7 +908,49 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
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)) {
|
if (shouldDebugLog(livePre, preChannelId)) {
|
||||||
api.logger.info(`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`);
|
api.logger.info(`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "dirigent",
|
"id": "dirigent",
|
||||||
"name": "Dirigent",
|
"name": "Dirigent",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Rule-based no-reply gate with provider/model override and turn management",
|
"description": "Rule-based no-reply gate with provider/model override and turn management",
|
||||||
"entry": "./index.ts",
|
"entry": "./index.ts",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export type ChannelTurnState = {
|
|||||||
noRepliedThisCycle: Set<string>;
|
noRepliedThisCycle: Set<string>;
|
||||||
/** Timestamp of last state change */
|
/** Timestamp of last state change */
|
||||||
lastChangedAt: number;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
const channelTurns = new Map<string, ChannelTurnState>();
|
const channelTurns = new Map<string, ChannelTurnState>();
|
||||||
@@ -107,6 +112,8 @@ export function checkTurn(channelId: string, accountId: string): {
|
|||||||
* Called when a new message arrives in the channel.
|
* Called when a new message arrives in the channel.
|
||||||
* Handles reactivation from dormant state and human-triggered resets.
|
* 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 senderAccountId - the accountId of the message sender (could be human/bot/unknown)
|
||||||
* @param isHuman - whether the sender is in the humanList
|
* @param isHuman - whether the sender is in the humanList
|
||||||
*/
|
*/
|
||||||
@@ -115,7 +122,8 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi
|
|||||||
if (!state || state.turnOrder.length === 0) return;
|
if (!state || state.turnOrder.length === 0) return;
|
||||||
|
|
||||||
if (isHuman) {
|
if (isHuman) {
|
||||||
// Human message: activate, start from first in order
|
// Human message without @mentions: restore original order if overridden, activate from first
|
||||||
|
restoreOriginalOrder(state);
|
||||||
state.currentSpeaker = state.turnOrder[0];
|
state.currentSpeaker = state.turnOrder[0];
|
||||||
state.noRepliedThisCycle = new Set();
|
state.noRepliedThisCycle = new Set();
|
||||||
state.lastChangedAt = Date.now();
|
state.lastChangedAt = Date.now();
|
||||||
@@ -141,6 +149,61 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi
|
|||||||
state.lastChangedAt = Date.now();
|
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;
|
||||||
|
|
||||||
|
// 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) 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();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a mention override is currently active.
|
||||||
|
*/
|
||||||
|
export function hasMentionOverride(channelId: string): boolean {
|
||||||
|
const state = channelTurns.get(channelId);
|
||||||
|
return !!state?.savedTurnOrder;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
|
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
|
||||||
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
|
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
|
||||||
@@ -157,6 +220,8 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply:
|
|||||||
// Check if ALL agents have NO_REPLY'd this cycle
|
// Check if ALL agents have NO_REPLY'd this cycle
|
||||||
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
|
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
|
||||||
if (allNoReplied) {
|
if (allNoReplied) {
|
||||||
|
// If override active, restore original order before going dormant
|
||||||
|
restoreOriginalOrder(state);
|
||||||
// Go dormant
|
// Go dormant
|
||||||
state.currentSpeaker = null;
|
state.currentSpeaker = null;
|
||||||
state.noRepliedThisCycle = new Set();
|
state.noRepliedThisCycle = new Set();
|
||||||
@@ -168,7 +233,18 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply:
|
|||||||
state.noRepliedThisCycle = new Set();
|
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,6 +285,7 @@ export function advanceTurn(channelId: string): string | null {
|
|||||||
export function resetTurn(channelId: string): void {
|
export function resetTurn(channelId: string): void {
|
||||||
const state = channelTurns.get(channelId);
|
const state = channelTurns.get(channelId);
|
||||||
if (state) {
|
if (state) {
|
||||||
|
restoreOriginalOrder(state);
|
||||||
state.currentSpeaker = null;
|
state.currentSpeaker = null;
|
||||||
state.noRepliedThisCycle = new Set();
|
state.noRepliedThisCycle = new Set();
|
||||||
state.lastChangedAt = Date.now();
|
state.lastChangedAt = Date.now();
|
||||||
@@ -229,5 +306,8 @@ export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
|||||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||||
lastChangedAt: state.lastChangedAt,
|
lastChangedAt: state.lastChangedAt,
|
||||||
dormant: state.currentSpeaker === null,
|
dormant: state.currentSpeaker === null,
|
||||||
|
hasOverride: !!state.savedTurnOrder,
|
||||||
|
overrideFirstAgent: state.overrideFirstAgent || null,
|
||||||
|
savedTurnOrder: state.savedTurnOrder || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user