feat(plugin): inject 🔚 ending instruction for bypass/end-symbol discord turns

This commit is contained in:
2026-02-25 11:04:01 +00:00
parent 798c402ab0
commit 32405fa3e2
3 changed files with 36 additions and 2 deletions

View File

@@ -9,6 +9,10 @@ WhisperGate evaluates in strict order:
3. message ending symbol check 3. message ending symbol check
4. fallback to no-reply model override 4. fallback to no-reply model override
Additional prompt behavior:
- when decision is `bypass_sender` or `end_symbol:*`, plugin prepends:
- `你的这次发言必须以🔚作为结尾。`
## Why before_model_resolve ## Why before_model_resolve
- deterministic - deterministic

View File

@@ -4,6 +4,9 @@
- `message:received` caches a per-session decision from deterministic rules. - `message:received` caches a per-session decision from deterministic rules.
- `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply. - `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply.
- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is:
- `bypass_sender`
- `end_symbol:*`
## Rules (in order) ## Rules (in order)

View File

@@ -9,6 +9,7 @@ type DecisionRecord = {
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;
const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。";
function normalizeChannel(ctx: Record<string, unknown>): string { function normalizeChannel(ctx: Record<string, unknown>): string {
const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel];
@@ -48,6 +49,10 @@ function pruneDecisionMap(now = Date.now()) {
} }
} }
function shouldInjectEndMarker(reason: string): boolean {
return reason === "bypass_sender" || reason.startsWith("end_symbol:");
}
export default { export default {
id: "whispergate", id: "whispergate",
name: "WhisperGate", name: "WhisperGate",
@@ -87,10 +92,10 @@ export default {
return; return;
} }
// one-shot decision per inbound turn
sessionDecision.delete(key);
if (!rec.decision.shouldUseNoReply) return; if (!rec.decision.shouldUseNoReply) return;
// no-reply path is consumed here
sessionDecision.delete(key);
api.logger.info( api.logger.info(
`whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${rec.decision.reason}`, `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${rec.decision.reason}`,
); );
@@ -100,5 +105,27 @@ export default {
modelOverride: config.noReplyModel, modelOverride: config.noReplyModel,
}; };
}); });
api.on("before_prompt_build", async (_event, ctx) => {
const key = ctx.sessionKey;
if (!key) return;
const rec = sessionDecision.get(key);
if (!rec) return;
if (Date.now() - rec.createdAt > DECISION_TTL_MS) {
sessionDecision.delete(key);
return;
}
// consume non-no-reply paths here to avoid stale carry-over
sessionDecision.delete(key);
if (!shouldInjectEndMarker(rec.decision.reason)) return;
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
return {
prependContext: END_MARKER_INSTRUCTION,
};
});
}, },
}; };