/**
* Approved adaptation rules at work: a prompt-side rule is injected when its
* trigger matches the user's prompt; a reply-side rule sends a matching reply
* back once at stop. Every fire is logged so a rule's effect can be measured.
*/
import { appendFileSync } from "node:fs";
import { type AdaptationRule, type RuleTrigger, readRules } from "./adaptation-rules";
import { alreadySentBack } from "./claim-log";
import { hookFinalReply, hookSessionId } from "./hook-turn";
import { replyEnd } from "./interaction-samples";
import { ruleEventsPath } from "./rule-effect";
import { isEnabled } from "./settings";
import type { StopTurnPayload } from "./stop";
const REMINDER_MAX = 1500;
const REMINDER_HEADER =
"Rules the user approved from past corrections — follow them on this turn:";
type Side = RuleTrigger["side"];
function triggers(rule: AdaptationRule, side: Side, text: string): boolean {
if (rule.status !== "approved" || rule.trigger.side !== side) return false;
try {
return new RegExp(rule.trigger.pattern, "i").test(text);
} catch {
return false;
}
}
function firingRules(side: Side, text: string): AdaptationRule[] {
if (!isEnabled("adaptationRules") || !text) return [];
return readRules().filter((rule) => triggers(rule, side, text));
}
function logFire(
rules: AdaptationRule[],
side: Side,
session: string,
now: Date,
sentBack?: boolean
): void {
const lines = rules.map((rule) =>
JSON.stringify({ ts: now.toISOString(), rule: rule.id, side, session, sentBack })
);
appendFileSync(ruleEventsPath(), `${lines.join("\n")}\n`);
}
function withinBudget(rules: AdaptationRule[]): AdaptationRule[] {
let size = REMINDER_HEADER.length + "\n\n".length;
return rules.filter((rule) => {
size += rule.steering.length + 3;
return size <= REMINDER_MAX;
});
}
export function promptRulesReminder(
prompt: string,
sessionId?: string,
now: Date = new Date()
): string | null {
const fired = withinBudget(firingRules("prompt", prompt));
if (fired.length === 0) return null;
logFire(fired, "prompt", sessionId ?? "unknown", now);
return [
"",
REMINDER_HEADER,
...fired.map((rule) => `- ${rule.steering}`),
"",
].join("\n");
}
function sendBackReason(rules: AdaptationRule[]): string {
const steering = rules.map((rule) => rule.steering).join(" ");
return `Your reply matches a rule the user approved from past corrections. ${steering}`;
}
export function watchReplyRules(
payload: StopTurnPayload | null,
now: Date = new Date()
): string | null {
const fired = firingRules("reply", replyEnd(hookFinalReply(payload) ?? ""));
if (!payload || fired.length === 0) return null;
const sentBack = !alreadySentBack(payload);
logFire(fired, "reply", hookSessionId(payload) ?? "unknown", now, sentBack);
return sentBack ? sendBackReason(fired) : null;
}