- Replace standalone no-reply-api Docker service with unified sidecar (services/main.mjs) that routes /no-reply/* and /moderator/* and starts/stops with openclaw-gateway - Add moderator Discord Gateway client (services/moderator/index.mjs) for real-time MESSAGE_CREATE push instead of polling; notifies plugin via HTTP callback - Add plugin HTTP routes (plugin/web/dirigent-api.ts) for moderator → plugin callbacks (wake-from-dormant, interrupt tail-match) - Fix tool registration format: AgentTool requires execute: not handler:; factory form for tools needing ctx - Rename no-reply-process.ts → sidecar-process.ts, startNoReplyApi → startSideCar - Remove dead config fields from openclaw.plugin.json (humanList, agentList, listMode, channelPoliciesFile, endSymbols, waitIdentifier, multiMessage*, bypassUserIds, etc.) - Rename noReplyPort → sideCarPort - Remove docker-compose.yml, dev-up/down scripts, package-plugin.mjs, test-no-reply-api.mjs - Update install.mjs: clean dist before build, copy services/, drop dead config writes - Update README, Makefile, smoke script for new architecture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
11 KiB
JavaScript
Executable File
259 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { execFileSync } from "node:child_process";
|
|
|
|
// === Skill merge utilities ===
|
|
function extractGuildTable(skillMdContent) {
|
|
const tableMatch = skillMdContent.match(/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/);
|
|
if (!tableMatch) return null;
|
|
const lines = tableMatch[0].split("\n");
|
|
const dataRows = [];
|
|
for (const line of lines) {
|
|
if (line.includes("guild-id") && line.includes("description")) continue;
|
|
if (/^\|[-\s|]+\|$/.test(line)) continue;
|
|
const match = line.match(/^\| \s*(\d+) \s*\| \s*(.+?) \s*\|$/);
|
|
if (match) dataRows.push({ guildId: match[1].trim(), description: match[2].trim() });
|
|
}
|
|
return dataRows;
|
|
}
|
|
|
|
function buildGuildTable(rows) {
|
|
if (rows.length === 0) return "| guild-id | description |\n|----------|-------------|";
|
|
const header = "| guild-id | description |\n|----------|-------------|";
|
|
const dataLines = rows.map(r => `| ${r.guildId} | ${r.description} |`).join("\n");
|
|
return `${header}\n${dataLines}`;
|
|
}
|
|
|
|
function mergeGuildTables(existingRows, newRows) {
|
|
const seen = new Set();
|
|
const merged = [];
|
|
for (const row of existingRows) {
|
|
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
|
|
}
|
|
for (const row of newRows) {
|
|
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function installSkillWithMerge(skillName, pluginSkillDir, openClawSkillsDir) {
|
|
const targetSkillDir = path.join(openClawSkillsDir, skillName);
|
|
const sourceSkillMd = path.join(pluginSkillDir, "SKILL.md");
|
|
|
|
if (fs.existsSync(targetSkillDir)) {
|
|
const existingSkillMd = path.join(targetSkillDir, "SKILL.md");
|
|
if (fs.existsSync(existingSkillMd) && fs.existsSync(sourceSkillMd)) {
|
|
const existingContent = fs.readFileSync(existingSkillMd, "utf8");
|
|
const newContent = fs.readFileSync(sourceSkillMd, "utf8");
|
|
const existingRows = extractGuildTable(existingContent) || [];
|
|
const newRows = extractGuildTable(newContent) || [];
|
|
|
|
if (existingRows.length > 0 || newRows.length > 0) {
|
|
const mergedRows = mergeGuildTables(existingRows, newRows);
|
|
const mergedTable = buildGuildTable(mergedRows);
|
|
const finalContent = newContent.replace(
|
|
/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/,
|
|
mergedTable
|
|
);
|
|
fs.rmSync(targetSkillDir, { recursive: true, force: true });
|
|
fs.mkdirSync(targetSkillDir, { recursive: true });
|
|
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true });
|
|
fs.writeFileSync(path.join(targetSkillDir, "SKILL.md"), finalContent, "utf8");
|
|
return { merged: true, rowCount: mergedRows.length };
|
|
}
|
|
}
|
|
}
|
|
|
|
fs.mkdirSync(openClawSkillsDir, { recursive: true });
|
|
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true, force: true });
|
|
return { merged: false };
|
|
}
|
|
// === End skill merge utilities ===
|
|
|
|
const VALID_MODES = new Set(["--install", "--uninstall", "--update"]);
|
|
let modeArg = null;
|
|
let argOpenClawDir = null;
|
|
let argNoReplyPort = 8787;
|
|
|
|
for (let i = 2; i < process.argv.length; i++) {
|
|
const arg = process.argv[i];
|
|
if (VALID_MODES.has(arg)) modeArg = arg;
|
|
else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) argOpenClawDir = process.argv[++i];
|
|
else if (arg.startsWith("--openclaw-profile-path=")) argOpenClawDir = arg.split("=").slice(1).join("=");
|
|
else if (arg === "--no-reply-port" && i + 1 < process.argv.length) argNoReplyPort = Number(process.argv[++i]);
|
|
else if (arg.startsWith("--no-reply-port=")) argNoReplyPort = Number(arg.split("=").slice(1).join("="));
|
|
}
|
|
|
|
if (!modeArg) {
|
|
console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path <path>] [--no-reply-port <port>]");
|
|
process.exit(2);
|
|
}
|
|
|
|
const mode = modeArg.slice(2);
|
|
const C = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m" };
|
|
function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; }
|
|
function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); }
|
|
function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); }
|
|
function ok(msg) { console.log(color(`\t✓ ${msg}`, "green")); }
|
|
|
|
function resolveOpenClawDir() {
|
|
if (argOpenClawDir) {
|
|
const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir());
|
|
if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`);
|
|
return dir;
|
|
}
|
|
const fallback = path.join(os.homedir(), ".openclaw");
|
|
if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir");
|
|
return fallback;
|
|
}
|
|
|
|
const OPENCLAW_DIR = resolveOpenClawDir();
|
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
|
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
|
|
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
|
|
const SKILLS_DIR = path.join(OPENCLAW_DIR, "skills");
|
|
const PLUGIN_SKILLS_DIR = path.join(REPO_ROOT, "skills");
|
|
|
|
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent";
|
|
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
|
|
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort);
|
|
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/no-reply/v1`;
|
|
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
|
|
|
|
function runOpenclaw(args, allowFail = false) {
|
|
try { return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); }
|
|
catch (e) { if (allowFail) return null; throw e; }
|
|
}
|
|
function getJson(pathKey) {
|
|
const out = runOpenclaw(["config", "get", pathKey, "--json"], true);
|
|
if (!out || out === "undefined") return undefined;
|
|
try { return JSON.parse(out); } catch { return undefined; }
|
|
}
|
|
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); }
|
|
function syncDirRecursive(src, dest) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
}
|
|
|
|
if (mode === "install") {
|
|
title("Install Dirigent with Skills");
|
|
|
|
step(1, 7, "build dist assets");
|
|
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
|
const sidecarSrc = path.resolve(REPO_ROOT, "services");
|
|
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
|
|
fs.rmSync(distPlugin, { recursive: true, force: true });
|
|
syncDirRecursive(pluginSrc, distPlugin);
|
|
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
|
|
ok("dist assets built");
|
|
|
|
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
|
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
|
|
ok("plugin files installed");
|
|
|
|
step(3, 7, "install skills with merge");
|
|
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
|
|
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => {
|
|
const full = path.join(PLUGIN_SKILLS_DIR, d);
|
|
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, "SKILL.md"));
|
|
});
|
|
for (const skillName of skills) {
|
|
const pluginSkillDir = path.join(PLUGIN_SKILLS_DIR, skillName);
|
|
const result = installSkillWithMerge(skillName, pluginSkillDir, SKILLS_DIR);
|
|
if (result.merged) {
|
|
ok(`skill ${skillName} installed (merged ${result.rowCount} guild entries)`);
|
|
} else {
|
|
ok(`skill ${skillName} installed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
step(4, 7, "configure plugin entry");
|
|
// Plugin load path — safe to read/write (not sensitive)
|
|
const loadPaths = getJson("plugins.load.paths") || [];
|
|
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) {
|
|
loadPaths.push(PLUGIN_INSTALL_DIR);
|
|
setJson("plugins.load.paths", loadPaths);
|
|
}
|
|
// For each config field: only write the default if the field has no value.
|
|
// Sensitive fields (e.g. moderatorBotToken) are never touched — user sets them manually.
|
|
// `getJson` returns undefined if the field is unset; __OPENCLAW_REDACTED__ counts as "set".
|
|
function setIfMissing(pathKey, defaultVal) {
|
|
const existing = getJson(pathKey);
|
|
if (existing === undefined || existing === null) setJson(pathKey, defaultVal);
|
|
}
|
|
setIfMissing("plugins.entries.dirigent.enabled", true);
|
|
const cp = "plugins.entries.dirigent.config";
|
|
setIfMissing(`${cp}.scheduleIdentifier`, "➡️");
|
|
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
|
|
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
|
|
setIfMissing(`${cp}.sideCarPort`, NO_REPLY_PORT);
|
|
// moderatorBotToken: intentionally not touched — set manually via:
|
|
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
|
|
ok("plugin configured");
|
|
|
|
step(5, 7, "configure no-reply provider");
|
|
const providers = getJson("models.providers") || {};
|
|
providers[NO_REPLY_PROVIDER_ID] = {
|
|
baseUrl: NO_REPLY_BASE_URL, apiKey: NO_REPLY_API_KEY, api: "openai-completions",
|
|
models: [{ id: NO_REPLY_MODEL_ID, name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192 }],
|
|
};
|
|
setJson("models.providers", providers);
|
|
ok("provider configured");
|
|
|
|
step(6, 7, "add no-reply model to allowlist");
|
|
const agentsDefaultsModels = getJson("agents.defaults.models") || {};
|
|
agentsDefaultsModels[`${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`] = {};
|
|
setJson("agents.defaults.models", agentsDefaultsModels);
|
|
ok("model allowlisted");
|
|
|
|
step(7, 7, "enable plugin in allowlist");
|
|
const allow = getJson("plugins.allow") || [];
|
|
if (!allow.includes("dirigent")) { allow.push("dirigent"); setJson("plugins.allow", allow); }
|
|
ok(`installed (no-reply port: ${NO_REPLY_PORT})`);
|
|
console.log("↻ restart gateway: openclaw gateway restart");
|
|
process.exit(0);
|
|
}
|
|
|
|
if (mode === "uninstall") {
|
|
title("Uninstall");
|
|
step(1, 4, "remove allowlist + plugin entry");
|
|
const allow = getJson("plugins.allow") || [];
|
|
const idx = allow.indexOf("dirigent");
|
|
if (idx >= 0) { allow.splice(idx, 1); setJson("plugins.allow", allow); ok("removed from plugins.allow"); }
|
|
runOpenclaw(["config", "unset", "plugins.entries.dirigent"], true);
|
|
ok("removed plugin entry");
|
|
|
|
step(2, 4, "remove plugin load path");
|
|
const plugins = getJson("plugins") || {};
|
|
const paths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
|
|
plugins.load = plugins.load || {}; plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR);
|
|
setJson("plugins", plugins);
|
|
ok("removed load path");
|
|
|
|
step(3, 4, "remove installed files");
|
|
if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true });
|
|
ok("removed plugin files");
|
|
|
|
step(4, 4, "remove skills");
|
|
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
|
|
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => fs.statSync(path.join(PLUGIN_SKILLS_DIR, d)).isDirectory());
|
|
for (const skillName of skills) {
|
|
const targetSkillDir = path.join(SKILLS_DIR, skillName);
|
|
if (fs.existsSync(targetSkillDir)) {
|
|
fs.rmSync(targetSkillDir, { recursive: true, force: true });
|
|
ok(`removed skill ${skillName}`);
|
|
}
|
|
}
|
|
}
|
|
console.log("↻ restart gateway: openclaw gateway restart");
|
|
process.exit(0);
|
|
}
|
|
|
|
console.error("Unknown mode:", mode);
|
|
process.exit(2);
|