Compare commits

...

4 Commits

8 changed files with 321 additions and 2 deletions

View File

@@ -2,6 +2,30 @@
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
View 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
View 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
View 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

View 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
View 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
View 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,
};
});
},
};

View 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"]
}
}