diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8bd0912..96e748d 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -9,6 +9,10 @@ WhisperGate evaluates in strict order: 3. message ending symbol check 4. fallback to no-reply model override +Additional prompt behavior: +- when decision is `bypass_sender` or `end_symbol:*`, plugin prepends: + - `你的这次发言必须以🔚作为结尾。` + ## Why before_model_resolve - deterministic diff --git a/plugin/README.md b/plugin/README.md index c7388f3..2373162 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -4,6 +4,9 @@ - `message:received` caches a per-session decision from deterministic rules. - `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) diff --git a/plugin/index.ts b/plugin/index.ts index e5a00c0..5310078 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -9,6 +9,7 @@ type DecisionRecord = { const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; +const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; function normalizeChannel(ctx: Record): string { 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 { id: "whispergate", name: "WhisperGate", @@ -87,10 +92,10 @@ export default { return; } - // one-shot decision per inbound turn - sessionDecision.delete(key); if (!rec.decision.shouldUseNoReply) return; + // no-reply path is consumed here + sessionDecision.delete(key); api.logger.info( `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, }; }); + + 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, + }; + }); }, };