/**
 * HumanAsDetectorLog — treats every human-caught coherence break as a
 * first-class diagnostic signal about which automated layer FAILED.
 *
 * The insight (Dawn's 2026-04-26 Lesson, "Justin Pointing Things Out =
 * Guardian Failure"): when a human has to surface something wrong — a stale
 * claim, a contradiction, a state incoherence — that is not a normal input to
 * be quietly fixed. It is evidence that some monitor/gate/guardian that was
 * supposed to catch it didn't. Logging these over time produces a heat map of
 * "where the human is doing the system's job," which tells you which automated
 * layers are dead weight and which coverage gaps are real.
 *
 * Instar already has CoherenceMonitor, CoherenceGate, and DegradationReporter —
 * but all of those watch the system's OWN state. None of them treat a human
 * correction as a signal. This closes that gap.
 *
 * Pattern mirrors DegradationReporter: singleton, configure() at startup,
 * append-only JSONL persistence, best-effort and never throws into the caller.
 *
 * Usage:
 *   const log = HumanAsDetectorLog.getInstance();
 *   log.configure({ stateDir, agentName });
 *   // On every inbound human message:
 *   log.observe({ text, topicId, messageId, source: 'telegram' });
 *
 * Ported from Dawn's human-as-detector.jsonl infrastructure.
 */
import fs from 'node:fs';
import path from 'node:path';
/**
 * Categories that feed the Correction & Preference Learning Sentinel rather
 * than the guardian-failure heat map. They are tagged here (single source of
 * truth) and excluded from {@link HumanAsDetectorLog.summarizeByLayer}. The
 * correction loop keys on these to decide whether a message is a learning
 * candidate. Spec §3.2.
 */
export const LEARNING_ONLY_CATEGORIES = new Set([
    'preference',
    'frustration',
]);
/** Suspected-layer sentinel value for learning-only signals (never a real guardian). */
const LEARNING_ONLY_LAYER = 'n/a (correction-learning signal — not a guardian failure)';
// Conservative signal set — biased toward precision over recall. A missed
// signal is a lost data point; a false positive pollutes the heat map.
const SIGNAL_RULES = [
    { pattern: /\bthat'?s (not right|wrong|incorrect|false)\b/, category: 'factual-correction', suspectedLayer: 'CoherenceGate / output-sanity', weight: 3, label: "that's wrong" },
    { pattern: /\b(this|that) is (wrong|incorrect|false)\b/, category: 'factual-correction', suspectedLayer: 'CoherenceGate / output-sanity', weight: 3, label: 'this is wrong' },
    { pattern: /\byou (said|claimed|told me)\b.*\b(but|however|actually|not)\b/, category: 'contradiction', suspectedLayer: 'CoherenceMonitor (self-claim consistency)', weight: 3, label: 'you said X but' },
    { pattern: /\bthat contradicts\b|\bcontradicts what\b/, category: 'contradiction', suspectedLayer: 'CoherenceMonitor (self-claim consistency)', weight: 3, label: 'contradicts' },
    { pattern: /\b(out of date|outdated|stale|no longer true|already (resolved|fixed|done))\b/, category: 'staleness', suspectedLayer: 'freshness-gate / state-of-truth registry', weight: 3, label: 'stale/outdated' },
    { pattern: /\bthe (record|registry|state|file) says\b/, category: 'source-of-truth-drift', suspectedLayer: 'source-of-truth registry sync', weight: 2, label: 'record says' },
    { pattern: /\bi (already (told|said)|just told you)\b/, category: 'repeat-ask', suspectedLayer: 'commitment/memory recall', weight: 2, label: 'I already told you' },
    { pattern: /\bwhy (didn'?t|did no|wasn'?t).*(catch|caught|detect|flag|notice)\b/, category: 'meta-failure', suspectedLayer: 'guardian coverage (no owner)', weight: 3, label: 'why not caught' },
    { pattern: /\bactually,?\s/, category: 'factual-correction', suspectedLayer: 'CoherenceGate / output-sanity', weight: 1, label: 'actually,' },
    { pattern: /\bthat'?s not (what|how)\b/, category: 'factual-correction', suspectedLayer: 'CoherenceGate / output-sanity', weight: 2, label: "that's not what/how" },
    // ── Correction & Preference Learning Sentinel (Slice 1b, spec §3.2) ──
    // Distinctly-tagged signal families. suspectedLayer is the LEARNING_ONLY
    // sentinel so summarizeByLayer() (the guardian-failure heat map) excludes
    // them. Strong (weight ≥3) phrasings cross the deterministic-provenance
    // threshold the analyzer keys on; weak ones still surface to the loop but
    // never alone admit a record to the recurrence gate.
    // preference family:
    { pattern: /\bfrom now on\b/, category: 'preference', suspectedLayer: LEARNING_ONLY_LAYER, weight: 3, label: 'from now on' },
    { pattern: /\b(please )?always\b/, category: 'preference', suspectedLayer: LEARNING_ONLY_LAYER, weight: 2, label: 'always' },
    // "i prefer", "i'd rather", "i would rather" — note "i'd" has no space.
    { pattern: /\b(i prefer|i'?d rather|i would rather)\b/, category: 'preference', suspectedLayer: LEARNING_ONLY_LAYER, weight: 3, label: 'I prefer / I’d rather' },
    { pattern: /\bdon'?t (use|ask|do|send|give)\b/, category: 'preference', suspectedLayer: LEARNING_ONLY_LAYER, weight: 2, label: "don't X" },
    { pattern: /\b(please )?(just )?keep it\b/, category: 'preference', suspectedLayer: LEARNING_ONLY_LAYER, weight: 2, label: 'keep it' },
    { pattern: /\bno (need to|more)\b/, category: 'preference', suspectedLayer: LEARNING_ONLY_LAYER, weight: 1, label: 'no need to / no more' },
    // frustration family — "you keep" requires a following gerund/verb so a
    // benign "you keep it plain" is NOT misread as frustration.
    { pattern: /\byou keep (asking|doing|saying|telling|sending|giving|making|getting|\w+ing)\b/, category: 'frustration', suspectedLayer: LEARNING_ONLY_LAYER, weight: 3, label: 'you keep …ing' },
    { pattern: /\bevery (time|session)\b/, category: 'frustration', suspectedLayer: LEARNING_ONLY_LAYER, weight: 3, label: 'every time/session' },
    { pattern: /\bi keep having to\b/, category: 'frustration', suspectedLayer: LEARNING_ONLY_LAYER, weight: 3, label: 'I keep having to' },
    { pattern: /\bstop asking me\b/, category: 'frustration', suspectedLayer: LEARNING_ONLY_LAYER, weight: 3, label: 'stop asking me' },
    { pattern: /\bagain\?(\s|$)/, category: 'frustration', suspectedLayer: LEARNING_ONLY_LAYER, weight: 2, label: 'again?' },
];
/** Deterministic-weight at/above which a learning signal is "full confidence"
 *  for the analyzer's code-determined provenance filter (spec §3.5). A lone
 *  weak rule (weight < this) surfaces but never alone admits a record. */
export const LEARNING_DETERMINISTIC_THRESHOLD = 3;
const PREVIEW_MAX = 220;
export class HumanAsDetectorLog {
    static instance = null;
    stateDir = null;
    agentName = 'unknown';
    /** In-memory ring of recent signals (for health/summary without disk reads) */
    recent = [];
    constructor() { }
    static getInstance() {
        if (!HumanAsDetectorLog.instance) {
            HumanAsDetectorLog.instance = new HumanAsDetectorLog();
        }
        return HumanAsDetectorLog.instance;
    }
    /** Reset singleton for testing. */
    static resetForTesting() {
        HumanAsDetectorLog.instance = null;
    }
    configure(opts) {
        this.stateDir = opts.stateDir;
        if (opts.agentName)
            this.agentName = opts.agentName;
        // Repopulate the in-memory ring from disk so the heat map survives the
        // frequent restarts instar undergoes (auto-update, lifeline coordination).
        this.hydrateFromDisk();
    }
    /**
     * Classify a piece of human text WITHOUT recording it. Pure, deterministic,
     * no I/O — this is the unit-testable core.
     *
     * Returns null if the text doesn't look like a coherence-break correction.
     */
    classify(text) {
        if (!text || typeof text !== 'string')
            return null;
        const lower = text.toLowerCase();
        const matched = [];
        for (const rule of SIGNAL_RULES) {
            if (rule.pattern.test(lower))
                matched.push(rule);
        }
        if (matched.length === 0)
            return null;
        // Strongest-weight rule decides the category & suspected layer.
        const top = matched.reduce((a, b) => (b.weight > a.weight ? b : a));
        const totalWeight = matched.reduce((sum, r) => sum + r.weight, 0);
        // A lone weight-1 signal ("actually,") is too weak on its own.
        if (totalWeight < 2)
            return null;
        const confidence = totalWeight >= 5 ? 'high' : totalWeight >= 3 ? 'medium' : 'low';
        const learningKind = top.category === 'preference' || top.category === 'frustration' ? top.category : null;
        return {
            category: top.category,
            suspectedFailedLayer: top.suspectedLayer,
            confidence,
            matchedSignals: matched.map((r) => r.label),
            deterministicWeight: totalWeight,
            learningKind,
        };
    }
    /**
     * Observe an inbound human message. If it looks like a coherence-break
     * correction, record it. Returns the recorded signal, or null if the message
     * wasn't a correction. Never throws.
     */
    observe(input) {
        try {
            const verdict = this.classify(input.text);
            if (!verdict)
                return null;
            const signal = {
                ts: new Date().toISOString(),
                source: input.source,
                topicId: input.topicId ?? null,
                messageId: input.messageId ?? null,
                category: verdict.category,
                suspectedFailedLayer: verdict.suspectedFailedLayer,
                confidence: verdict.confidence,
                matchedSignals: verdict.matchedSignals,
                messagePreview: input.text.slice(0, PREVIEW_MAX),
            };
            this.recent.push(signal);
            if (this.recent.length > 200)
                this.recent = this.recent.slice(-200);
            this.persist(signal);
            // Visible, never silent — mirrors DegradationReporter's console signal.
            console.warn(`[HUMAN-AS-DETECTOR] ${signal.category} (${signal.confidence}) — ` +
                `suspected layer that should have caught it: ${signal.suspectedFailedLayer}`);
            return signal;
        }
        catch {
            // Best-effort — a logging failure must never break message handling.
            return null;
        }
    }
    /** Recent in-memory signals (for health checks / summary endpoint). */
    getRecent() {
        return [...this.recent];
    }
    // ── Drift canary (spec §3.2) ───────────────────────────────────────
    // A small in-memory counter: when the correction loop's drift canary samples
    // an UN-classified message through the LLM and the LLM thinks it WAS a
    // correction, that is a regex-recall miss — recorded here so the broadened
    // Layer-0 rule set's recall drift is observable over time without persisting
    // any raw text. Metadata-only; never serialized into /health by default.
    driftSampled = 0;
    driftMismatches = 0;
    /** Record one drift-canary sample outcome. `mismatch` = the LLM judged the
     *  un-classified message to actually be a correction (a regex miss). */
    recordDriftSample(mismatch) {
        this.driftSampled += 1;
        if (mismatch)
            this.driftMismatches += 1;
    }
    /** Drift-canary counters (sampled / mismatches / miss-rate). Read-only. */
    getDriftCanary() {
        return {
            sampled: this.driftSampled,
            mismatches: this.driftMismatches,
            missRate: this.driftSampled > 0 ? this.driftMismatches / this.driftSampled : 0,
        };
    }
    /**
     * Summarize signals grouped by suspected failed layer — the heat map of
     * "where the human is doing the system's job."
     */
    summarizeByLayer() {
        const byLayer = new Map();
        for (const s of this.recent) {
            // Correction & Preference Learning Sentinel (spec §3.2): preference /
            // frustration signals feed the correction loop, NOT the guardian-failure
            // heat map. Excluding them here keeps the heat map's precision contract
            // intact (a unit test pins that they don't change these counts).
            if (LEARNING_ONLY_CATEGORIES.has(s.category))
                continue;
            const entry = byLayer.get(s.suspectedFailedLayer) ?? { count: 0, categories: new Set() };
            entry.count++;
            entry.categories.add(s.category);
            byLayer.set(s.suspectedFailedLayer, entry);
        }
        return [...byLayer.entries()]
            .map(([layer, v]) => ({ layer, count: v.count, categories: [...v.categories] }))
            .sort((a, b) => b.count - a.count);
    }
    // ── Internal ──────────────────────────────────────────────
    persist(signal) {
        if (!this.stateDir)
            return;
        try {
            const dir = path.join(this.stateDir, 'metrics');
            fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
            const filePath = path.join(dir, 'human-as-detector.jsonl');
            // Persist METADATA ONLY — never the user's raw words. messagePreview
            // stays in the in-memory ring for this session's heat map, but is kept
            // OFF disk so a secret/PII a user types mid-correction can't leak into
            // the append-only audit trail. Mode 0600 is defense-in-depth on the
            // metadata that remains (file mode applies only on creation).
            const { messagePreview: _omitFromDisk, ...metadata } = signal;
            fs.appendFileSync(filePath, JSON.stringify({ ...metadata, agentName: this.agentName }) + '\n', { mode: 0o600 });
        }
        catch {
            // Disk persistence is best-effort; the console.warn is the safety net.
        }
    }
    /**
     * Best-effort: repopulate the in-memory ring from the last 200 persisted
     * records. The heat map (summarizeByLayer + recent) reads only the ring, so
     * without this it would silently empty on every restart even though the full
     * history is on disk. Persisted records carry no messagePreview (kept off
     * disk by persist()), so hydrated entries have an empty preview — the byLayer
     * counts, which are the point of the heat map, are fully restored.
     */
    hydrateFromDisk() {
        if (!this.stateDir)
            return;
        try {
            const filePath = path.join(this.stateDir, 'metrics', 'human-as-detector.jsonl');
            if (!fs.existsSync(filePath))
                return;
            const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter((l) => l.trim());
            const hydrated = [];
            for (const line of lines.slice(-200)) {
                try {
                    const r = JSON.parse(line);
                    if (!r.category || !r.suspectedFailedLayer)
                        continue;
                    hydrated.push({
                        ts: r.ts ?? '',
                        source: r.source ?? 'unknown',
                        topicId: r.topicId ?? null,
                        messageId: r.messageId ?? null,
                        category: r.category,
                        suspectedFailedLayer: r.suspectedFailedLayer,
                        confidence: r.confidence ?? 'low',
                        matchedSignals: r.matchedSignals ?? [],
                        messagePreview: r.messagePreview ?? '',
                    });
                }
                catch {
                    // skip a malformed line — best-effort hydration
                }
            }
            this.recent = hydrated;
        }
        catch {
            // unreadable file → start with an empty ring; never throw at startup
        }
    }
}
/**
 * The gating decision used where the log is chained onto a message adapter's
 * onMessageLogged: observe ONLY inbound HUMAN messages that carry text. Agent
 * messages and empty entries are skipped. Extracted from the server wiring so
 * the inbound-human gate is unit-testable (wiring-integrity), not buried inline.
 */
export function observeInboundMessage(log, entry, source = 'telegram') {
    if (!entry.fromUser || !entry.text)
        return null;
    return log.observe({
        text: entry.text,
        source,
        topicId: entry.topicId ?? null,
        messageId: entry.messageId ?? null,
    });
}
//# sourceMappingURL=HumanAsDetectorLog.js.map