feat: split dirigent_tools + human @mention override #14

Merged
hzhang merged 37 commits from feat/split-tools-and-mention-override into main 2026-03-08 08:02:28 +00:00
3 changed files with 76 additions and 72 deletions
Showing only changes of commit e258e99ed2 - Show all commits

View File

@@ -0,0 +1,23 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { DirigentConfig } from "../rules.js";
export function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig {
const root = (api.config as Record<string, unknown>) || {};
const plugins = (root.plugins as Record<string, unknown>) || {};
const entries = (plugins.entries as Record<string, unknown>) || {};
const entry = (entries.dirigent as Record<string, unknown>) || (entries.whispergate as Record<string, unknown>) || {};
const cfg = (entry.config as Record<string, unknown>) || {};
if (Object.keys(cfg).length > 0) {
return {
enableDiscordControlTool: true,
enableDirigentPolicyTool: true,
discordControlApiBaseUrl: "http://127.0.0.1:8790",
enableDebugLogs: false,
debugLogChannelIds: [],
schedulingIdentifier: "➡️",
waitIdentifier: "👤",
...cfg,
} as DirigentConfig;
}
return fallback;
}

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path"; 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 type { Decision, DirigentConfig } from "./rules.js";
import { initTurnOrder } from "./turn-manager.js"; import { initTurnOrder } from "./turn-manager.js";
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
import { registerMessageReceivedHook } from "./hooks/message-received.js"; import { registerMessageReceivedHook } from "./hooks/message-received.js";
@@ -12,6 +12,8 @@ import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js"
import { registerMessageSentHook } from "./hooks/message-sent.js"; import { registerMessageSentHook } from "./hooks/message-sent.js";
import { registerDirigentCommand } from "./commands/dirigent-command.js"; import { registerDirigentCommand } from "./commands/dirigent-command.js";
import { registerDirigentTools } from "./tools/register-tools.js"; import { registerDirigentTools } from "./tools/register-tools.js";
import { getLivePluginConfig } from "./core/live-config.js";
import { ensurePolicyStateLoaded, persistPolicies, policyState } from "./policy/store.js";
// ── No-Reply API child process lifecycle ────────────────────────────── // ── No-Reply API child process lifecycle ──────────────────────────────
let noReplyProcess: ChildProcess | null = null; let noReplyProcess: ChildProcess | null = null;
@@ -64,11 +66,6 @@ type DecisionRecord = {
needsRestore?: boolean; needsRestore?: boolean;
}; };
type PolicyState = {
filePath: string;
channelPolicies: Record<string, ChannelPolicy>;
};
type DebugConfig = { type DebugConfig = {
enableDebugLogs?: boolean; enableDebugLogs?: boolean;
debugLogChannelIds?: string[]; debugLogChannelIds?: string[];
@@ -97,11 +94,6 @@ function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): str
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.`; 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()) { function pruneDecisionMap(now = Date.now()) {
for (const [k, v] of sessionDecision.entries()) { for (const [k, v] of sessionDecision.entries()) {
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
@@ -116,56 +108,6 @@ function pruneDecisionMap(now = Date.now()) {
} }
} }
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 */ /** Resolve agentId → Discord accountId from config bindings */
function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined { function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {}; const root = (api.config as Record<string, unknown>) || {};
@@ -374,17 +316,6 @@ async function sendModeratorMessage(token: string, channelId: string, content: s
} }
} }
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>) { function pickDefined(input: Record<string, unknown>) {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) { for (const [k, v] of Object.entries(input)) {

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

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