Compare commits
4 Commits
cae2fd3952
...
e020f1f026
| Author | SHA1 | Date | |
|---|---|---|---|
| e020f1f026 | |||
| 7728892d15 | |||
| 1140a928f3 | |||
| f3662457bc |
28
README.md
28
README.md
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
Rule-based no-reply gate for OpenClaw.
|
Rule-based no-reply gate for OpenClaw.
|
||||||
|
|
||||||
## Status
|
## What it does
|
||||||
|
|
||||||
Initial scaffold.
|
WhisperGate adds a deterministic gate **before model selection**:
|
||||||
|
|
||||||
|
1. If message is not from Discord → skip gate
|
||||||
|
2. If sender is in bypass user list → skip gate
|
||||||
|
3. If message ends with configured end-symbol → skip gate
|
||||||
|
4. Otherwise switch this turn to a no-reply model/provider
|
||||||
|
|
||||||
|
The no-reply provider returns `NO_REPLY` for any input.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
- `plugin/` — OpenClaw plugin (before_model_resolve hook)
|
||||||
|
- `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY`
|
||||||
|
- `docs/` — rollout and configuration notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development plan (incremental commits)
|
||||||
|
|
||||||
|
- [x] Task 1: project docs + structure
|
||||||
|
- [ ] Task 2: no-reply API MVP
|
||||||
|
- [ ] Task 3: plugin MVP with rule chain
|
||||||
|
- [ ] Task 4: sample config + quick verification scripts
|
||||||
|
|||||||
28
docs/CONFIG.example.json
Normal file
28
docs/CONFIG.example.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"load": {
|
||||||
|
"paths": ["/path/to/WhisperGate/plugin"]
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"whispergate": {
|
||||||
|
"enabled": true,
|
||||||
|
"config": {
|
||||||
|
"enabled": true,
|
||||||
|
"discordOnly": true,
|
||||||
|
"bypassUserIds": ["561921120408698910"],
|
||||||
|
"endSymbols": ["。", "!", "?", ".", "!", "?"],
|
||||||
|
"noReplyProvider": "openai",
|
||||||
|
"noReplyModel": "whispergate-no-reply-v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"providers": {
|
||||||
|
"openai": {
|
||||||
|
"apiKey": "sk-xxxx",
|
||||||
|
"baseURL": "http://127.0.0.1:8787/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
docs/IMPLEMENTATION.md
Normal file
21
docs/IMPLEMENTATION.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# WhisperGate Implementation Notes
|
||||||
|
|
||||||
|
## Decision path
|
||||||
|
|
||||||
|
WhisperGate evaluates in strict order:
|
||||||
|
|
||||||
|
1. channel check (discord-only)
|
||||||
|
2. bypass sender check
|
||||||
|
3. message ending symbol check
|
||||||
|
4. fallback to no-reply model override
|
||||||
|
|
||||||
|
## Why before_model_resolve
|
||||||
|
|
||||||
|
- deterministic
|
||||||
|
- no LLM dependency
|
||||||
|
- low overhead
|
||||||
|
- uses built-in override path (`providerOverride` + `modelOverride`)
|
||||||
|
|
||||||
|
## Known limitation
|
||||||
|
|
||||||
|
This does not fully skip OpenClaw prompt assembly. It reduces provider-side LLM usage by routing no-reply turns to a deterministic API.
|
||||||
30
docs/VERIFY.md
Normal file
30
docs/VERIFY.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# WhisperGate Quick Verification
|
||||||
|
|
||||||
|
## 1) Start no-reply API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd no-reply-api
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Validate API behavior
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:8787/health
|
||||||
|
curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hi"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected assistant text: `NO_REPLY`
|
||||||
|
|
||||||
|
## 3) Enable plugin
|
||||||
|
|
||||||
|
Set OpenClaw plugin path to `plugin/` and apply `docs/CONFIG.example.json` values.
|
||||||
|
|
||||||
|
## 4) Discord logic check
|
||||||
|
|
||||||
|
- non-discord session -> normal model
|
||||||
|
- discord + bypass user -> normal model
|
||||||
|
- discord + non-bypass + ending punctuation -> normal model
|
||||||
|
- discord + non-bypass + no ending punctuation -> no-reply model override
|
||||||
9
no-reply-api/package.json
Normal file
9
no-reply-api/package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "whispergate-no-reply-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
no-reply-api/server.mjs
Normal file
82
no-reply-api/server.mjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 8787);
|
||||||
|
const modelName = process.env.NO_REPLY_MODEL || "whispergate-no-reply-v1";
|
||||||
|
|
||||||
|
function sendJson(res, status, payload) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function noReplyChatCompletion(reqBody) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl_whispergate_${Date.now()}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: reqBody?.model || modelName,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: { role: "assistant", content: "NO_REPLY" },
|
||||||
|
finish_reason: "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function noReplyResponses(reqBody) {
|
||||||
|
return {
|
||||||
|
id: `resp_whispergate_${Date.now()}`,
|
||||||
|
object: "response",
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
model: reqBody?.model || modelName,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "NO_REPLY" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === "GET" && req.url === "/health") {
|
||||||
|
return sendJson(res, 200, { ok: true, service: "whispergate-no-reply-api" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > 1_000_000) req.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
let parsed = {};
|
||||||
|
try {
|
||||||
|
parsed = body ? JSON.parse(body) : {};
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 400, { error: "invalid_json" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === "/v1/chat/completions") {
|
||||||
|
return sendJson(res, 200, noReplyChatCompletion(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === "/v1/responses") {
|
||||||
|
return sendJson(res, 200, noReplyResponses(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`[whispergate-no-reply-api] listening on :${port}`);
|
||||||
|
});
|
||||||
105
plugin/index.ts
Normal file
105
plugin/index.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
enabled?: boolean;
|
||||||
|
discordOnly?: boolean;
|
||||||
|
bypassUserIds?: string[];
|
||||||
|
endSymbols?: string[];
|
||||||
|
noReplyProvider: string;
|
||||||
|
noReplyModel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Decision = {
|
||||||
|
shouldUseNoReply: boolean;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionDecision = new Map<string, Decision>();
|
||||||
|
|
||||||
|
function getLastChar(input: string): string {
|
||||||
|
const t = input.trim();
|
||||||
|
return t.length ? t[t.length - 1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseNoReply(params: {
|
||||||
|
config: Config;
|
||||||
|
channel?: string;
|
||||||
|
senderId?: string;
|
||||||
|
content?: string;
|
||||||
|
}): Decision {
|
||||||
|
const { config } = params;
|
||||||
|
|
||||||
|
if (config.enabled === false) {
|
||||||
|
return { shouldUseNoReply: false, reason: "disabled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = (params.channel || "").toLowerCase();
|
||||||
|
if (config.discordOnly !== false && channel !== "discord") {
|
||||||
|
return { shouldUseNoReply: false, reason: "non_discord" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) {
|
||||||
|
return { shouldUseNoReply: false, reason: "bypass_sender" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastChar = getLastChar(params.content || "");
|
||||||
|
if (lastChar && (config.endSymbols || []).includes(lastChar)) {
|
||||||
|
return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "whispergate",
|
||||||
|
name: "WhisperGate",
|
||||||
|
register(api: OpenClawPluginApi) {
|
||||||
|
const config = (api.pluginConfig || {}) as Config;
|
||||||
|
|
||||||
|
api.registerHook("message:received", async (event, ctx) => {
|
||||||
|
try {
|
||||||
|
const c = (ctx || {}) as Record<string, unknown>;
|
||||||
|
const e = (event || {}) as Record<string, unknown>;
|
||||||
|
const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined;
|
||||||
|
if (!sessionKey) return;
|
||||||
|
|
||||||
|
const senderId =
|
||||||
|
typeof c.senderId === "string"
|
||||||
|
? c.senderId
|
||||||
|
: typeof e.from === "string"
|
||||||
|
? e.from
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const content = typeof e.content === "string" ? e.content : "";
|
||||||
|
const channel =
|
||||||
|
typeof c.commandSource === "string"
|
||||||
|
? c.commandSource
|
||||||
|
: typeof c.channelId === "string"
|
||||||
|
? c.channelId
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const decision = shouldUseNoReply({ config, channel, senderId, content });
|
||||||
|
sessionDecision.set(sessionKey, decision);
|
||||||
|
api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`);
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.on("before_model_resolve", async (_event, ctx) => {
|
||||||
|
const key = ctx.sessionKey;
|
||||||
|
if (!key) return;
|
||||||
|
const decision = sessionDecision.get(key);
|
||||||
|
if (!decision?.shouldUseNoReply) return;
|
||||||
|
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${decision.reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerOverride: config.noReplyProvider,
|
||||||
|
modelOverride: config.noReplyModel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
20
plugin/openclaw.plugin.json
Normal file
20
plugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "whispergate",
|
||||||
|
"name": "WhisperGate",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Rule-based no-reply gate with provider/model override",
|
||||||
|
"entry": "./index.ts",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean", "default": true },
|
||||||
|
"discordOnly": { "type": "boolean", "default": true },
|
||||||
|
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
|
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["。", "!", "?", ".", "!", "?"] },
|
||||||
|
"noReplyProvider": { "type": "string" },
|
||||||
|
"noReplyModel": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["noReplyProvider", "noReplyModel"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user