/** * Recall gate — pre-retrieval triage for auto-recall traffic. * * Borrowed from memory-lancedb-pro's adaptive-retrieval, reshaped per Codex * review (2026-07-17) into a three-way verdict instead of a binary SKIP/FORCE: * * - "full-recall": message explicitly reaches for memory ("记得/上次/my * preference") — always run resume + focused search. * - "resume-only": bare continuity nudges ("继续", "下一步", "开始吧") — * they need checkpoint/continuity, not an embedding search * over their own two characters. * - "skip-all": greetings, slash commands, heartbeats, whole-message * acks, pure emoji, bare CLI invocations — no memory value. * - "pass": no rule matched — default full pipeline. * * Rollout is governed by RECALLNEST_RECALL_GATE (observe-before-enforce, * shared-behaviors §5): "observe" (default) computes the verdict and appends a * shadow-log line but never changes behavior; "enforce" acts on the verdict; * "off" disables even the shadow logging. Shadow entries carry rule id, * message length, and decision — never the message text itself. */ import { appendFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { dataDir } from "./env-config.js"; export type RecallGateDecision = "full-recall" | "resume-only" | "skip-all" | "pass"; export interface RecallGateResult { decision: RecallGateDecision; /** Stable rule identifier, e.g. "force:memory-cue", "skip:ack", "pass:long". */ ruleId: string; } export type RecallGateMode = "observe" | "enforce" | "off"; /** RECALLNEST_RECALL_GATE: unset/invalid → "observe" (shadow-only default). */ export function resolveRecallGateMode(env: NodeJS.ProcessEnv = process.env): RecallGateMode { const raw = (env.RECALLNEST_RECALL_GATE || "").trim().toLowerCase(); if (raw === "enforce") return "enforce"; if (raw === "off") return "off"; return "observe"; } // Short-text rules below only ever fire on messages up to this length; longer // messages carry enough content to always deserve the full pipeline. const SHORT_TEXT_MAX_CHARS = 80; // --- full-recall cues (substring, any position, force-first per review) --- const FORCE_CUES_ZH = [ "记得", "回忆", "想起", "上次", "上回", "之前讨论", "之前说", "之前聊", "之前提", "我的偏好", "我说过", "你说过", "说过吗", "存过", "记过", "提醒过", ]; // Word-bounded and hyphen/underscore-guarded: "recall" must not fire on // "auto-recall" or "recall-gate.test.ts" (identifiers, not memory intent). const FORCE_CUES_EN = [ /(? 1 && tokens.every((t) => ACK_WORDS.has(stripTrailingFluff(t))); } /** * Classify one auto-recall message. Pure and deterministic — call order is * force cues → length guard → structural skips → whole-message skips → * resume nudges → default pass. */ export function classifyRecallGate(message: string): RecallGateResult { const text = normalize(message); if (!text) return { decision: "skip-all", ruleId: "skip:empty" }; const lower = text.toLowerCase(); // 1. Force cues outrank every short-text rule ("可以按上次方案做" must not // be swallowed by the ack rule). for (const cue of FORCE_CUES_ZH) { if (text.includes(cue)) return { decision: "full-recall", ruleId: "force:memory-cue-zh" }; } for (const cue of FORCE_CUES_EN) { if (cue.test(lower)) return { decision: "full-recall", ruleId: "force:memory-cue-en" }; } // 2. Long messages always deserve the full pipeline. if (text.length > SHORT_TEXT_MAX_CHARS) return { decision: "pass", ruleId: "pass:long" }; // 3. Structural skips. if (/^\/\S+/.test(text)) return { decision: "skip-all", ruleId: "skip:slash" }; if (/^HEARTBEAT/i.test(text)) return { decision: "skip-all", ruleId: "skip:heartbeat" }; if (EMOJI_UNIT.test(text.replace(/\s+/g, ""))) { return { decision: "skip-all", ruleId: "skip:emoji" }; } if (text.length <= 60 && CLI_SHAPE.test(text)) { return { decision: "skip-all", ruleId: "skip:cli" }; } // 4. Whole-message conversational skips. const strippedLower = stripTrailingFluff(lower); if (GREETINGS.has(strippedLower)) return { decision: "skip-all", ruleId: "skip:greeting" }; if (isAckMessage(lower)) return { decision: "skip-all", ruleId: "skip:ack" }; // 5. Continuity nudges → resume context without a focused search. if (RESUME_PHRASES.has(strippedLower)) { return { decision: "resume-only", ruleId: "resume:continuity" }; } return { decision: "pass", ruleId: "pass:default" }; } // --- shadow log (observe-before-enforce evidence trail) --- export interface RecallGateShadowEntry { ts: string; decision: RecallGateDecision; ruleId: string; msgLen: number; mode: Exclude; /** Caller-provided origin, e.g. "api:/v1/auto-recall". Never message text. */ source?: string; } export const RECALL_GATE_SHADOW_FILE = "recall-gate-shadow.jsonl"; /** * Append one shadow-log line under dataDir(). Must never block or throw — * gate telemetry failing is strictly better than recall failing. */ export function logRecallGateShadow(entry: RecallGateShadowEntry): void { try { const dir = dataDir(); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); appendFileSync(join(dir, RECALL_GATE_SHADOW_FILE), `${JSON.stringify(entry)}\n`, "utf8"); } catch { // Shadow logging is best-effort by design. } }