Compare commits
4 Commits
46ea43b3fe
...
875cd66d34
| Author | SHA1 | Date | |
|---|---|---|---|
| 875cd66d34 | |||
| 1746fb33ad | |||
| 8eade446b3 | |||
| 51149dd6a0 |
@@ -18,6 +18,8 @@
|
|||||||
"noReplyModel": "no-reply",
|
"noReplyModel": "no-reply",
|
||||||
"enableDiscordControlTool": true,
|
"enableDiscordControlTool": true,
|
||||||
"enableWhispergatePolicyTool": true,
|
"enableWhispergatePolicyTool": true,
|
||||||
|
"enableDebugLogs": false,
|
||||||
|
"debugLogChannelIds": [],
|
||||||
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
|
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
|
||||||
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
||||||
"discordControlCallerId": "agent-main"
|
"discordControlCallerId": "agent-main"
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ The script:
|
|||||||
- writes via `openclaw config set ... --json`
|
- writes via `openclaw config set ... --json`
|
||||||
- creates config backup first
|
- creates config backup first
|
||||||
- restores backup automatically if any install step fails
|
- restores backup automatically if any install step fails
|
||||||
|
- restarts gateway during install, then validates `whisper-gateway/no-reply` is visible via `openclaw models list/status`
|
||||||
- writes a change record for every install/uninstall:
|
- writes a change record for every install/uninstall:
|
||||||
- directory: `~/.openclaw/whispergate-install-records/`
|
- directory: `~/.openclaw/whispergate-install-records/`
|
||||||
- latest pointer: `~/.openclaw/whispergate-install-record-latest.json`
|
- latest pointer: `~/.openclaw/whispergate-install-record-latest.json`
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ Unified optional tool:
|
|||||||
- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`)
|
- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`)
|
||||||
- `discordControlApiToken`
|
- `discordControlApiToken`
|
||||||
- `discordControlCallerId`
|
- `discordControlCallerId`
|
||||||
|
- `enableDebugLogs` (default false)
|
||||||
|
- `debugLogChannelIds` (default [], empty = all channels when debug enabled)
|
||||||
|
|
||||||
Per-channel policy file example: `docs/channel-policies.example.json`.
|
Per-channel policy file example: `docs/channel-policies.example.json`.
|
||||||
|
|
||||||
@@ -61,3 +63,8 @@ To use it, add tool allowlist entry for either:
|
|||||||
Supported actions:
|
Supported actions:
|
||||||
- Discord: `channel-private-create`, `channel-private-update`, `member-list`
|
- Discord: `channel-private-create`, `channel-private-update`, `member-list`
|
||||||
- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
||||||
|
|
||||||
|
Debug logging:
|
||||||
|
- set `enableDebugLogs: true` to emit detailed hook diagnostics
|
||||||
|
- optionally set `debugLogChannelIds` to only log selected channel IDs
|
||||||
|
- logs include key ctx fields + decision status at `message_received`, `before_model_resolve`, `before_prompt_build`
|
||||||
|
|||||||
176
plugin/index.ts
176
plugin/index.ts
@@ -15,6 +15,11 @@ type PolicyState = {
|
|||||||
channelPolicies: Record<string, ChannelPolicy>;
|
channelPolicies: Record<string, ChannelPolicy>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DebugConfig = {
|
||||||
|
enableDebugLogs?: boolean;
|
||||||
|
debugLogChannelIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const sessionDecision = new Map<string, DecisionRecord>();
|
const sessionDecision = new Map<string, DecisionRecord>();
|
||||||
const MAX_SESSION_DECISIONS = 2000;
|
const MAX_SESSION_DECISIONS = 2000;
|
||||||
const DECISION_TTL_MS = 5 * 60 * 1000;
|
const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||||
@@ -49,6 +54,47 @@ function normalizeSender(event: Record<string, unknown>, ctx: Record<string, unk
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
|
||||||
|
const marker = "Conversation info (untrusted metadata):";
|
||||||
|
const idx = text.indexOf(marker);
|
||||||
|
if (idx < 0) return undefined;
|
||||||
|
const tail = text.slice(idx + marker.length);
|
||||||
|
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
|
||||||
|
if (!m) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(m[1]);
|
||||||
|
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDecisionInputFromAgentCtx(
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
): { channel: string; channelId?: string; senderId?: string; content: string } {
|
||||||
|
const channel = normalizeChannel(ctx);
|
||||||
|
const content = typeof ctx.input === "string" ? ctx.input : "";
|
||||||
|
const conv = extractUntrustedConversationInfo(content) || {};
|
||||||
|
const channelIdRaw =
|
||||||
|
(typeof ctx.channelId === "string" && ctx.channelId) ||
|
||||||
|
(typeof conv.channel_id === "string" && conv.channel_id) ||
|
||||||
|
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")
|
||||||
|
? conv.chat_id.slice("channel:".length)
|
||||||
|
: undefined);
|
||||||
|
const senderIdRaw =
|
||||||
|
(typeof ctx.senderId === "string" && ctx.senderId) ||
|
||||||
|
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||||
|
(typeof conv.sender === "string" && conv.sender) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
channelId: channelIdRaw,
|
||||||
|
senderId: senderIdRaw,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pruneDecisionMap(now = Date.now()) {
|
function pruneDecisionMap(now = Date.now()) {
|
||||||
for (const [k, v] of sessionDecision.entries()) {
|
for (const [k, v] of sessionDecision.entries()) {
|
||||||
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
||||||
@@ -119,6 +165,39 @@ function pickDefined(input: Record<string, unknown>) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
|
||||||
|
if (!cfg.enableDebugLogs) return false;
|
||||||
|
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
|
||||||
|
if (allow.length === 0) return true;
|
||||||
|
if (!channelId) return false;
|
||||||
|
return allow.includes(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
|
||||||
|
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
|
||||||
|
return {
|
||||||
|
sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined,
|
||||||
|
commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined,
|
||||||
|
messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined,
|
||||||
|
channel: typeof ctx.channel === "string" ? ctx.channel : undefined,
|
||||||
|
channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined,
|
||||||
|
senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined,
|
||||||
|
from: typeof ctx.from === "string" ? ctx.from : undefined,
|
||||||
|
metaSenderId:
|
||||||
|
typeof meta.senderId === "string"
|
||||||
|
? meta.senderId
|
||||||
|
: typeof meta.sender_id === "string"
|
||||||
|
? meta.sender_id
|
||||||
|
: undefined,
|
||||||
|
metaUserId:
|
||||||
|
typeof meta.userId === "string"
|
||||||
|
? meta.userId
|
||||||
|
: typeof meta.user_id === "string"
|
||||||
|
? meta.user_id
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
id: "whispergate",
|
id: "whispergate",
|
||||||
name: "WhisperGate",
|
name: "WhisperGate",
|
||||||
@@ -267,29 +346,11 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const c = (ctx || {}) as Record<string, unknown>;
|
const c = (ctx || {}) as Record<string, unknown>;
|
||||||
const e = (event || {}) as Record<string, unknown>;
|
const e = (event || {}) as Record<string, unknown>;
|
||||||
const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined;
|
const preChannelId = typeof c.channelId === "string" ? c.channelId : undefined;
|
||||||
if (!sessionKey) return;
|
const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
|
if (shouldDebugLog(livePre, preChannelId)) {
|
||||||
const senderId = normalizeSender(e, c);
|
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||||
const content = typeof e.content === "string" ? e.content : "";
|
}
|
||||||
const channel = normalizeChannel(c);
|
|
||||||
const channelId = typeof c.channelId === "string" ? c.channelId : undefined;
|
|
||||||
|
|
||||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
|
|
||||||
ensurePolicyStateLoaded(api, live);
|
|
||||||
const decision = evaluateDecision({
|
|
||||||
config: live,
|
|
||||||
channel,
|
|
||||||
channelId,
|
|
||||||
channelPolicies: policyState.channelPolicies,
|
|
||||||
senderId,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
sessionDecision.set(sessionKey, { decision, createdAt: Date.now() });
|
|
||||||
pruneDecisionMap();
|
|
||||||
api.logger.debug?.(
|
|
||||||
`whispergate: session=${sessionKey} sender=${senderId ?? "unknown"} channel=${channel || "unknown"} decision=${decision.reason}`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
@@ -299,17 +360,35 @@ export default {
|
|||||||
const key = ctx.sessionKey;
|
const key = ctx.sessionKey;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
const rec = sessionDecision.get(key);
|
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
if (!rec) return;
|
ensurePolicyStateLoaded(api, live);
|
||||||
if (Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
|
||||||
sessionDecision.delete(key);
|
let rec = sessionDecision.get(key);
|
||||||
return;
|
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||||
|
if (rec) sessionDecision.delete(key);
|
||||||
|
const c = (ctx || {}) as Record<string, unknown>;
|
||||||
|
const derived = deriveDecisionInputFromAgentCtx(c);
|
||||||
|
const decision = evaluateDecision({
|
||||||
|
config: live,
|
||||||
|
channel: derived.channel,
|
||||||
|
channelId: derived.channelId,
|
||||||
|
channelPolicies: policyState.channelPolicies,
|
||||||
|
senderId: derived.senderId,
|
||||||
|
content: derived.content,
|
||||||
|
});
|
||||||
|
rec = { decision, createdAt: Date.now() };
|
||||||
|
sessionDecision.set(key, rec);
|
||||||
|
pruneDecisionMap();
|
||||||
|
if (shouldDebugLog(live, derived.channelId ?? ctx.channelId)) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: debug before_model_resolve recompute session=${key} decision=${decision.reason} ` +
|
||||||
|
`shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rec.decision.shouldUseNoReply) return;
|
if (!rec.decision.shouldUseNoReply) return;
|
||||||
|
|
||||||
sessionDecision.delete(key);
|
|
||||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
|
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
`whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
||||||
);
|
);
|
||||||
@@ -323,16 +402,41 @@ export default {
|
|||||||
api.on("before_prompt_build", async (_event, ctx) => {
|
api.on("before_prompt_build", async (_event, ctx) => {
|
||||||
const key = ctx.sessionKey;
|
const key = ctx.sessionKey;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const rec = sessionDecision.get(key);
|
|
||||||
if (!rec) return;
|
|
||||||
|
|
||||||
if (Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
sessionDecision.delete(key);
|
ensurePolicyStateLoaded(api, live);
|
||||||
return;
|
|
||||||
|
let rec = sessionDecision.get(key);
|
||||||
|
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||||
|
if (rec) sessionDecision.delete(key);
|
||||||
|
const c = (ctx || {}) as Record<string, unknown>;
|
||||||
|
const derived = deriveDecisionInputFromAgentCtx(c);
|
||||||
|
const decision = evaluateDecision({
|
||||||
|
config: live,
|
||||||
|
channel: derived.channel,
|
||||||
|
channelId: derived.channelId,
|
||||||
|
channelPolicies: policyState.channelPolicies,
|
||||||
|
senderId: derived.senderId,
|
||||||
|
content: derived.content,
|
||||||
|
});
|
||||||
|
rec = { decision, createdAt: Date.now() };
|
||||||
|
if (shouldDebugLog(live, derived.channelId ?? ctx.channelId)) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: debug before_prompt_build recompute session=${key} decision=${decision.reason} ` +
|
||||||
|
`shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionDecision.delete(key);
|
sessionDecision.delete(key);
|
||||||
if (!rec.decision.shouldInjectEndMarkerPrompt) return;
|
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||||
|
if (shouldDebugLog(live, ctx.channelId)) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
|
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
|
||||||
return { prependContext: END_MARKER_INSTRUCTION };
|
return { prependContext: END_MARKER_INSTRUCTION };
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
"enableWhispergatePolicyTool": { "type": "boolean", "default": true },
|
"enableWhispergatePolicyTool": { "type": "boolean", "default": true },
|
||||||
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
|
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
|
||||||
"discordControlApiToken": { "type": "string" },
|
"discordControlApiToken": { "type": "string" },
|
||||||
"discordControlCallerId": { "type": "string" }
|
"discordControlCallerId": { "type": "string" },
|
||||||
|
"enableDebugLogs": { "type": "boolean", "default": false },
|
||||||
|
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] }
|
||||||
},
|
},
|
||||||
"required": ["noReplyProvider", "noReplyModel"]
|
"required": ["noReplyProvider", "noReplyModel"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ function runOpenclaw(args, { allowFail = false } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateNoReplyModelAvailable() {
|
||||||
|
const modelRef = `${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`;
|
||||||
|
const list = runOpenclaw(["models", "list"], { allowFail: true }) || "";
|
||||||
|
if (!list.includes(modelRef)) {
|
||||||
|
throw new Error(`post-install validation failed: model not listed: ${modelRef}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = runOpenclaw(["models", "status", "--json"], { allowFail: true }) || "";
|
||||||
|
if (!status.includes(NO_REPLY_PROVIDER_ID)) {
|
||||||
|
throw new Error(`post-install validation failed: provider not visible in models status: ${NO_REPLY_PROVIDER_ID}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getJson(pathKey) {
|
function getJson(pathKey) {
|
||||||
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
|
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
|
||||||
if (out == null || out === "") return { exists: false };
|
if (out == null || out === "") return { exists: false };
|
||||||
@@ -77,6 +90,25 @@ function readRecord(file) {
|
|||||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findLatestInstallRecord() {
|
||||||
|
if (!fs.existsSync(STATE_DIR)) return "";
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(STATE_DIR)
|
||||||
|
.filter((f) => /^whispergate-\d+\.json$/.test(f))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
for (const f of files) {
|
||||||
|
const p = path.join(STATE_DIR, f);
|
||||||
|
try {
|
||||||
|
const rec = readRecord(p);
|
||||||
|
if (rec?.mode === "install") return p;
|
||||||
|
} catch {
|
||||||
|
// ignore broken records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||||
console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`);
|
console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -139,6 +171,9 @@ if (mode === "install") {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
runOpenclaw(["gateway", "restart"]);
|
||||||
|
validateNoReplyModelAvailable();
|
||||||
|
|
||||||
const after = {
|
const after = {
|
||||||
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
|
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
|
||||||
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
|
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
|
||||||
@@ -153,9 +188,9 @@ if (mode === "install") {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const recFile = env.RECORD_FILE || (fs.existsSync(LATEST_RECORD_LINK) ? LATEST_RECORD_LINK : "");
|
const recFile = env.RECORD_FILE || findLatestInstallRecord();
|
||||||
if (!recFile || !fs.existsSync(recFile)) {
|
if (!recFile || !fs.existsSync(recFile)) {
|
||||||
console.error("[whispergate] no record found. set RECORD_FILE=<path> or install first.");
|
console.error("[whispergate] no install record found. set RECORD_FILE=<path> to an install record.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user