feat: split dirigent_tools + human @mention override #14
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
||||
# 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
|
||||
|
||||
- **Project renamed from WhisperGate to Dirigent**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# New Features
|
||||
|
||||
1. 拆分 dirigent-tools:不再用一个主工具管理多个小工具。
|
||||
2. 人类@规则:当来自 humanList 的用户消息包含 <@USER_ID> 时,按被 @ 的 agent 顺序临时覆盖 speaking order 循环;回到首个 agent 后恢复原顺序。
|
||||
1. 拆分 dirigent-tools:不再用一个主工具管理多个小工具。 ✅
|
||||
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
|
||||
- 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",
|
||||
"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": [
|
||||
|
||||
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 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,75 +642,108 @@ 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) }],
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "policy-set-channel") {
|
||||
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 = {
|
||||
@@ -662,9 +759,29 @@ export default {
|
||||
policyState.channelPolicies = prev;
|
||||
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();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
|
||||
@@ -676,29 +793,73 @@ export default {
|
||||
policyState.channelPolicies = prev;
|
||||
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();
|
||||
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-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();
|
||||
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 },
|
||||
);
|
||||
|
||||
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();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
resetTurn(channelId);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
||||
},
|
||||
},
|
||||
{ optional: false },
|
||||
@@ -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);
|
||||
}
|
||||
} 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"}`);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -20,6 +20,11 @@ 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;
|
||||
};
|
||||
|
||||
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.
|
||||
* 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,7 +122,8 @@ 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 without @mentions: restore original order if overridden, activate from first
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = state.turnOrder[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
@@ -141,6 +149,61 @@ 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;
|
||||
|
||||
// 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.
|
||||
* @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
|
||||
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 +233,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,6 +285,7 @@ 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.lastChangedAt = Date.now();
|
||||
@@ -229,5 +306,8 @@ export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||
lastChangedAt: state.lastChangedAt,
|
||||
dormant: state.currentSpeaker === null,
|
||||
hasOverride: !!state.savedTurnOrder,
|
||||
overrideFirstAgent: state.overrideFirstAgent || null,
|
||||
savedTurnOrder: state.savedTurnOrder || null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user