refactor: new design — sidecar services, moderator Gateway client, tool execute API
- 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>
This commit is contained in:
@@ -1,20 +1,29 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(process.cwd(), '..');
|
||||
const pluginDir = path.join(root, 'plugin');
|
||||
const required = ['index.ts', 'rules.ts', 'openclaw.plugin.json', 'README.md', 'package.json'];
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const checks = [
|
||||
// Core plugin files
|
||||
path.join(root, 'plugin', 'index.ts'),
|
||||
path.join(root, 'plugin', 'turn-manager.ts'),
|
||||
path.join(root, 'plugin', 'openclaw.plugin.json'),
|
||||
path.join(root, 'plugin', 'package.json'),
|
||||
// Sidecar
|
||||
path.join(root, 'services', 'main.mjs'),
|
||||
path.join(root, 'services', 'no-reply-api', 'server.mjs'),
|
||||
path.join(root, 'services', 'moderator', 'index.mjs'),
|
||||
];
|
||||
|
||||
let ok = true;
|
||||
for (const f of required) {
|
||||
const p = path.join(pluginDir, f);
|
||||
for (const p of checks) {
|
||||
if (!fs.existsSync(p)) {
|
||||
ok = false;
|
||||
console.error(`missing: ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestPath = path.join(pluginDir, 'openclaw.plugin.json');
|
||||
const manifestPath = path.join(root, 'plugin', 'openclaw.plugin.json');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
for (const k of ['id', 'entry', 'configSchema']) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
docker compose down
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[dirigent] building/starting no-reply API container"
|
||||
docker compose up -d --build dirigent-no-reply-api
|
||||
|
||||
echo "[dirigent] health check"
|
||||
curl -sS http://127.0.0.1:8787/health
|
||||
|
||||
echo "[dirigent] done"
|
||||
@@ -120,7 +120,7 @@ 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}/v1`;
|
||||
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) {
|
||||
@@ -143,10 +143,11 @@ if (mode === "install") {
|
||||
|
||||
step(1, 7, "build dist assets");
|
||||
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
||||
const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api");
|
||||
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(noReplySrc, path.join(distPlugin, "no-reply-api"));
|
||||
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
|
||||
ok("dist assets built");
|
||||
|
||||
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
|
||||
@@ -187,17 +188,10 @@ if (mode === "install") {
|
||||
}
|
||||
setIfMissing("plugins.entries.dirigent.enabled", true);
|
||||
const cp = "plugins.entries.dirigent.config";
|
||||
setIfMissing(`${cp}.enabled`, true);
|
||||
setIfMissing(`${cp}.discordOnly`, true);
|
||||
setIfMissing(`${cp}.listMode`, "human-list");
|
||||
setIfMissing(`${cp}.humanList`, []);
|
||||
setIfMissing(`${cp}.agentList`, []);
|
||||
setIfMissing(`${cp}.channelPoliciesFile`, path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"));
|
||||
setIfMissing(`${cp}.endSymbols`, ["🔚"]);
|
||||
setIfMissing(`${cp}.schedulingIdentifier`, "➡️");
|
||||
setIfMissing(`${cp}.scheduleIdentifier`, "➡️");
|
||||
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
|
||||
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
|
||||
setIfMissing(`${cp}.noReplyPort`, NO_REPLY_PORT);
|
||||
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");
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const pluginDir = path.join(root, "plugin");
|
||||
const outDir = path.join(root, "dist", "dirigent");
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "moderator-presence.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
|
||||
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
|
||||
}
|
||||
|
||||
console.log(`packaged plugin to ${outDir}`);
|
||||
@@ -1,32 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8787}"
|
||||
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
||||
|
||||
AUTH_HEADER=()
|
||||
if [[ -n "$AUTH_TOKEN" ]]; then
|
||||
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
||||
fi
|
||||
# Smoke-tests the no-reply API endpoint exposed by the sidecar service.
|
||||
# The sidecar must already be running (it starts automatically with openclaw-gateway).
|
||||
# Default base URL matches the sidecar's no-reply prefix.
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8787/no-reply}"
|
||||
|
||||
echo "[1] health"
|
||||
curl -sS "${BASE_URL}/health" | sed -n '1,3p'
|
||||
curl -fsS "${BASE_URL}/health"
|
||||
echo ""
|
||||
|
||||
echo "[2] models"
|
||||
curl -sS "${BASE_URL}/v1/models" "${AUTH_HEADER[@]}" | sed -n '1,8p'
|
||||
curl -fsS "${BASE_URL}/v1/models" | head -c 200
|
||||
echo ""
|
||||
|
||||
echo "[3] chat/completions"
|
||||
curl -sS -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
curl -fsS -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \
|
||||
| sed -n '1,20p'
|
||||
-d '{"model":"no-reply","messages":[{"role":"user","content":"hello"}]}' \
|
||||
| head -c 300
|
||||
echo ""
|
||||
|
||||
echo "[4] responses"
|
||||
curl -sS -X POST "${BASE_URL}/v1/responses" \
|
||||
curl -fsS -X POST "${BASE_URL}/v1/responses" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
-d '{"model":"dirigent-no-reply-v1","input":"hello"}' \
|
||||
| sed -n '1,20p'
|
||||
-d '{"model":"no-reply","input":"hello"}' \
|
||||
| head -c 300
|
||||
echo ""
|
||||
|
||||
echo "smoke ok"
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const BASE = "http://127.0.0.1:18787";
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function waitForHealth(retries = 30) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const r = await fetch(`${BASE}/health`);
|
||||
if (r.ok) return true;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const token = "test-token";
|
||||
const child = spawn("node", ["no-reply-api/server.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, PORT: "18787", AUTH_TOKEN: token, NO_REPLY_MODEL: "wg-test-model" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", () => {});
|
||||
child.stderr.on("data", () => {});
|
||||
|
||||
try {
|
||||
const ok = await waitForHealth();
|
||||
assert(ok, "health check failed");
|
||||
|
||||
const unauth = await fetch(`${BASE}/v1/models`);
|
||||
assert(unauth.status === 401, `expected 401, got ${unauth.status}`);
|
||||
|
||||
const models = await fetch(`${BASE}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert(models.ok, "authorized /v1/models failed");
|
||||
const modelsJson = await models.json();
|
||||
assert(modelsJson?.data?.[0]?.id === "wg-test-model", "model id mismatch");
|
||||
|
||||
const cc = await fetch(`${BASE}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ model: "wg-test-model", messages: [{ role: "user", content: "hi" }] }),
|
||||
});
|
||||
assert(cc.ok, "chat completions failed");
|
||||
const ccJson = await cc.json();
|
||||
assert(ccJson?.choices?.[0]?.message?.content === "NO_REPLY", "chat completion not NO_REPLY");
|
||||
|
||||
const rsp = await fetch(`${BASE}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ model: "wg-test-model", input: "hi" }),
|
||||
});
|
||||
assert(rsp.ok, "responses failed");
|
||||
const rspJson = await rsp.json();
|
||||
assert(rspJson?.output?.[0]?.content?.[0]?.text === "NO_REPLY", "responses not NO_REPLY");
|
||||
|
||||
console.log("test-no-reply-api: ok");
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`test-no-reply-api: fail: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user