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.
|
||||
|
||||
## 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