/**
 * review-tools — admin MCP tools for the proactive log review cadence.
 *
 * Registers seven tools on the admin MCP server:
 *
 *   review-rules-list       — dump current rules + runtime state (from Neo4j)
 *   review-rules-suppress   — silence a rule for N hours
 *   review-rules-unsuppress — lift a suppression immediately
 *   review-rules-add        — add a new rule from a structured spec
 *   review-rules-remove     — delete a rule from the config
 *   review-alerts-recent    — read-only view of recent ReviewAlert nodes
 *   review-digest-compose   — schedule-triggered: author daily digest + watchdog
 *
 * Coordination with the in-process detector happens entirely via the rules
 * file on disk and the Neo4j ReviewAlert graph. No shared memory, no IPC.
 *
 * See app/lib/review-detector/ in maxy-ui for the detector implementation
 * and .docs/workflows.md § Review Cadence for the wider architecture.
 */
import { z } from "zod";
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, openSync, readSync, closeSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import neo4j from "neo4j-driver";
import { getSession } from "./neo4j.js";
// review.log grows at roughly 17,280 cycle events / day at a 5s interval.
// Over a month that's ~500k lines. The digest only needs the last 24h, so
// cap the read at the tail of the file (8 MB is enough for several days
// of dense activity including all matches and suppressions). A file that
// has grown past this size still works — we just read from the tail.
const MAX_DIGEST_READ_BYTES = 8 * 1024 * 1024;
function rulesFilePath(configDir) {
    return resolve(configDir, "review-rules.json");
}
function reviewLogPath(configDir) {
    return resolve(configDir, "logs", "review.log");
}
function loadRulesFile(configDir) {
    const path = rulesFilePath(configDir);
    if (!existsSync(path)) {
        throw new Error(`rules file missing at ${path}. The detector must boot at least once to materialise defaults.`);
    }
    const raw = readFileSync(path, "utf-8");
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed.scanIntervalMs !== "number" || !Array.isArray(parsed.rules)) {
        throw new Error("rules file is malformed — run review-rules-list to see the loader error");
    }
    return parsed;
}
function saveRulesFile(configDir, file) {
    const path = rulesFilePath(configDir);
    mkdirSync(dirname(path), { recursive: true });
    const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
    writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", "utf-8");
    renameSync(tmp, path);
}
/**
 * Append a structured event to the review log. Admin tool actions are
 * audit-logged here so `logs-read.sh --tail review` shows both detector
 * activity and every admin mutation.
 */
function reviewLog(configDir, event) {
    const path = reviewLogPath(configDir);
    try {
        mkdirSync(dirname(path), { recursive: true });
        const line = `${new Date().toISOString()} [review] ${JSON.stringify({ source: "admin-tool", ...event })}\n`;
        appendFileSync(path, line, "utf-8");
    }
    catch (err) {
        console.error(`[review-tool] failed to write review log: ${err instanceof Error ? err.message : String(err)}`);
    }
}
export function registerReviewTools(server, ctx) {
    const { configDir, accountId } = ctx;
    // -------------------------------------------------------------------------
    // review-rules-list
    // -------------------------------------------------------------------------
    server.tool("review-rules-list", "List all declarative log review rules (from review-rules.json) with their current state. For each rule: id, name, type, log source, pattern, thresholds, suggested action, and any active suppression window. Use this before calling review-rules-suppress or review-rules-remove so you know the exact rule id.", {}, async () => {
        try {
            const file = loadRulesFile(configDir);
            const now = Date.now();
            const summary = file.rules.map((r) => {
                const suppressed = r.suppressedUntil && Date.parse(r.suppressedUntil) > now
                    ? `until ${r.suppressedUntil}`
                    : null;
                return {
                    id: r.id,
                    name: r.name,
                    type: r.type,
                    logSource: r.logSource,
                    pattern: r.pattern,
                    thresholdCount: r.thresholdCount,
                    thresholdWindowMinutes: r.thresholdWindowMinutes,
                    suggestedAction: r.suggestedAction,
                    watchPath: r.watchPath ?? null,
                    staleHours: r.staleHours ?? null,
                    suppressedUntil: suppressed,
                };
            });
            return {
                content: [{ type: "text", text: JSON.stringify({ scanIntervalMs: file.scanIntervalMs, rules: summary }, null, 2) }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] failed to load rules: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    // -------------------------------------------------------------------------
    // review-rules-suppress
    // -------------------------------------------------------------------------
    server.tool("review-rules-suppress", "Silence a review rule for a given number of hours. The detector will skip evaluating the rule entirely until the suppression window expires. Use this when you know a matching condition is temporary (e.g. 'suppress reconnect-loop for 24h while I fix Task 384'). Reason is recorded in the review log.", {
        ruleId: z.string().describe("Rule id — get it from review-rules-list."),
        durationHours: z.number().min(0.1).max(720).describe("Suppression window in hours, 0.1 to 720."),
        reason: z.string().min(1).describe("Why the rule is being suppressed — recorded in the audit log."),
    }, async ({ ruleId, durationHours, reason }) => {
        try {
            const file = loadRulesFile(configDir);
            const rule = file.rules.find((r) => r.id === ruleId);
            if (!rule) {
                return {
                    content: [{ type: "text", text: `[review-tool] no rule with id "${ruleId}"` }],
                    isError: true,
                };
            }
            const until = new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString();
            rule.suppressedUntil = until;
            saveRulesFile(configDir, file);
            // Suppression must propagate to the Neo4j ReviewAlert node too,
            // otherwise loadSessionContext keeps surfacing the alert on every
            // admin turn for up to 24h — the user asked the agent to silence
            // this rule and the agent would still be shouting about it.
            // Best-effort: a failure here is logged but does not fail the tool,
            // because the on-disk suppression is already in effect (the detector
            // will skip evaluating the rule on its next cycle, so no NEW alert
            // will be created — only an existing one could linger).
            const neoSession = getSession();
            try {
                await neoSession.run(`MATCH (a:ReviewAlert { ruleId: $ruleId, accountId: $accountId })
             WHERE a.resolvedAt IS NULL
             SET a.suppressedUntil = datetime($until)`, { ruleId, accountId, until });
            }
            catch (err) {
                reviewLog(configDir, {
                    event: "rule-suppress-graph-sync-failed",
                    ruleId,
                    error: err instanceof Error ? err.message : String(err),
                });
            }
            finally {
                await neoSession.close();
            }
            reviewLog(configDir, { event: "rule-suppressed", ruleId, until, reason });
            return {
                content: [{ type: "text", text: `Suppressed ${ruleId} until ${until}. Reason: ${reason}` }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] suppress failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    // -------------------------------------------------------------------------
    // review-rules-unsuppress
    // -------------------------------------------------------------------------
    server.tool("review-rules-unsuppress", "Lift an active suppression on a review rule immediately. The detector will start evaluating the rule again on its next scan cycle.", {
        ruleId: z.string().describe("Rule id — get it from review-rules-list."),
    }, async ({ ruleId }) => {
        try {
            const file = loadRulesFile(configDir);
            const rule = file.rules.find((r) => r.id === ruleId);
            if (!rule) {
                return {
                    content: [{ type: "text", text: `[review-tool] no rule with id "${ruleId}"` }],
                    isError: true,
                };
            }
            rule.suppressedUntil = null;
            saveRulesFile(configDir, file);
            // Clear suppression on the Neo4j ReviewAlert node too so
            // loadSessionContext surfaces the alert again immediately.
            // Best-effort: same rationale as in review-rules-suppress.
            const neoSession = getSession();
            try {
                await neoSession.run(`MATCH (a:ReviewAlert { ruleId: $ruleId, accountId: $accountId })
             WHERE a.resolvedAt IS NULL
             SET a.suppressedUntil = null`, { ruleId, accountId });
            }
            catch (err) {
                reviewLog(configDir, {
                    event: "rule-unsuppress-graph-sync-failed",
                    ruleId,
                    error: err instanceof Error ? err.message : String(err),
                });
            }
            finally {
                await neoSession.close();
            }
            reviewLog(configDir, { event: "rule-unsuppressed", ruleId });
            return {
                content: [{ type: "text", text: `Unsuppressed ${ruleId}. Rule will fire on next match.` }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] unsuppress failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    // -------------------------------------------------------------------------
    // review-rules-add
    // -------------------------------------------------------------------------
    server.tool("review-rules-add", "Add a new declarative log review rule. The agent is responsible for translating a natural-language request from the user into the structured fields below. The pattern field is a JavaScript regex. For file-write-storm and stale-log, supply watchPath (relative to the config directory); stale-log additionally requires staleHours. Threshold zero means 'fire on every match'.", {
        id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).describe("Unique kebab-case id used for suppression and storage."),
        name: z.string().min(1).describe("Human-readable display name shown in chat alerts."),
        type: z.enum(["reconnect-loop", "repeated-error", "silent-catch", "file-write-storm", "stale-log", "rate-limit"]).describe("Rule evaluator type."),
        logSource: z.enum(["any", "server", "vnc", "system", "error", "session", "public", "mcp", "config-dir"]).describe("Which log stream(s) to evaluate against. 'any' means every text log."),
        pattern: z.string().describe("JavaScript regex. Empty for file-write-storm and stale-log (they use watchPath instead)."),
        thresholdCount: z.number().min(0).describe("Matches required within the window. 0 fires on first match."),
        thresholdWindowMinutes: z.number().min(0).describe("Window length in minutes. 0 disables windowing."),
        suggestedAction: z.string().min(1).describe("One-sentence remediation guidance shown in the alert."),
        watchPath: z.string().optional().describe("Relative to configDir. Required for file-write-storm and stale-log."),
        staleHours: z.number().optional().describe("Staleness threshold in hours. Required for stale-log."),
    }, async (params) => {
        try {
            const file = loadRulesFile(configDir);
            if (file.rules.some((r) => r.id === params.id)) {
                return {
                    content: [{ type: "text", text: `[review-tool] a rule with id "${params.id}" already exists` }],
                    isError: true,
                };
            }
            if (params.pattern.length > 0) {
                try {
                    new RegExp(params.pattern);
                }
                catch (err) {
                    return {
                        content: [{ type: "text", text: `[review-tool] pattern is not a valid regex: ${err instanceof Error ? err.message : String(err)}` }],
                        isError: true,
                    };
                }
            }
            if ((params.type === "file-write-storm" || params.type === "stale-log") && !params.watchPath) {
                return {
                    content: [{ type: "text", text: `[review-tool] ${params.type} rules require watchPath` }],
                    isError: true,
                };
            }
            if (params.type === "stale-log" && (params.staleHours === undefined || params.staleHours <= 0)) {
                return {
                    content: [{ type: "text", text: `[review-tool] stale-log rules require a positive staleHours` }],
                    isError: true,
                };
            }
            const rule = {
                id: params.id,
                name: params.name,
                type: params.type,
                logSource: params.logSource,
                pattern: params.pattern,
                thresholdCount: params.thresholdCount,
                thresholdWindowMinutes: params.thresholdWindowMinutes,
                suggestedAction: params.suggestedAction,
                watchPath: params.watchPath,
                staleHours: params.staleHours,
                suppressedUntil: null,
            };
            file.rules.push(rule);
            saveRulesFile(configDir, file);
            reviewLog(configDir, { event: "rule-added", ruleId: rule.id, ruleName: rule.name, type: rule.type });
            return {
                content: [{ type: "text", text: `Added rule "${rule.id}". The detector will pick it up within one scan cycle.` }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] add failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    // -------------------------------------------------------------------------
    // review-rules-remove
    // -------------------------------------------------------------------------
    server.tool("review-rules-remove", "Delete a review rule permanently. Any active ReviewAlert for that rule in Neo4j is also marked resolved so it stops appearing in the admin system prompt. Use review-rules-suppress instead when you want a temporary silence.", {
        ruleId: z.string().describe("Rule id — get it from review-rules-list."),
    }, async ({ ruleId }) => {
        try {
            const file = loadRulesFile(configDir);
            const idx = file.rules.findIndex((r) => r.id === ruleId);
            if (idx === -1) {
                return {
                    content: [{ type: "text", text: `[review-tool] no rule with id "${ruleId}"` }],
                    isError: true,
                };
            }
            file.rules.splice(idx, 1);
            saveRulesFile(configDir, file);
            // Also mark any open ReviewAlert as resolved so it drops out of
            // loadSessionContext on the next admin turn.
            const session = getSession();
            try {
                await session.run(`MATCH (a:ReviewAlert { ruleId: $ruleId, accountId: $accountId })
             WHERE a.resolvedAt IS NULL
             SET a.resolvedAt = datetime()`, { ruleId, accountId });
            }
            finally {
                await session.close();
            }
            reviewLog(configDir, { event: "rule-removed", ruleId });
            return {
                content: [{ type: "text", text: `Removed rule "${ruleId}" and resolved any open alerts.` }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] remove failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    // -------------------------------------------------------------------------
    // review-alerts-recent
    // -------------------------------------------------------------------------
    server.tool("review-alerts-recent", "Return the most recent ReviewAlert records for this account, including resolved and suppressed ones. Useful for debugging false positives, confirming a rule fired after a known-bad event, or auditing what the detector has been surfacing. Does NOT modify state.", {
        limit: z.number().min(1).max(100).default(20).describe("Max alerts to return, default 20."),
        includeResolved: z.boolean().default(false).describe("Include alerts that have been resolved."),
    }, async ({ limit, includeResolved }) => {
        const session = getSession();
        try {
            const query = includeResolved
                ? `MATCH (a:ReviewAlert {accountId: $accountId})
             RETURN a.alertId AS alertId, a.ruleId AS ruleId, a.ruleName AS ruleName,
                    a.firstMatchAt AS firstMatchAt, a.lastMatchAt AS lastMatchAt,
                    a.cumulativeMatchCount AS count, a.sampleEvidence AS sampleEvidence,
                    a.suggestedAction AS suggestedAction,
                    a.suppressedUntil AS suppressedUntil, a.resolvedAt AS resolvedAt
             ORDER BY a.lastMatchAt DESC LIMIT $limit`
                : `MATCH (a:ReviewAlert {accountId: $accountId})
             WHERE a.resolvedAt IS NULL
             RETURN a.alertId AS alertId, a.ruleId AS ruleId, a.ruleName AS ruleName,
                    a.firstMatchAt AS firstMatchAt, a.lastMatchAt AS lastMatchAt,
                    a.cumulativeMatchCount AS count, a.sampleEvidence AS sampleEvidence,
                    a.suggestedAction AS suggestedAction,
                    a.suppressedUntil AS suppressedUntil, a.resolvedAt AS resolvedAt
             ORDER BY a.lastMatchAt DESC LIMIT $limit`;
            const result = await session.run(query, { accountId, limit: neo4j.int(limit) });
            const alerts = result.records.map((r) => ({
                alertId: r.get("alertId"),
                ruleId: r.get("ruleId"),
                ruleName: r.get("ruleName"),
                firstMatchAt: r.get("firstMatchAt")?.toString?.() ?? null,
                lastMatchAt: r.get("lastMatchAt")?.toString?.() ?? null,
                count: r.get("count")?.toNumber?.() ?? 0,
                sampleEvidence: r.get("sampleEvidence"),
                suggestedAction: r.get("suggestedAction"),
                suppressedUntil: r.get("suppressedUntil")?.toString?.() ?? null,
                resolvedAt: r.get("resolvedAt")?.toString?.() ?? null,
            }));
            return {
                content: [{ type: "text", text: JSON.stringify({ count: alerts.length, alerts }, null, 2) }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] recent alerts query failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
        finally {
            await session.close();
        }
    });
    // -------------------------------------------------------------------------
    // review-digest-compose
    // -------------------------------------------------------------------------
    //
    // Triggered by the scheduled Event (cron 0 8 * * *) via check-due-events.
    // The tool reads the last 24h of review.log and the current ReviewAlert
    // set, authors a short digest, and persists it as a CreativeWork node
    // with title "Review Digest YYYY-MM-DD". loadSessionContext surfaces it on
    // the next admin turn via its existing CreativeWork digest query.
    //
    // Also runs the detector-silence watchdog: if the review log has not been
    // written in more than 2× the detector's scan interval, the tool emits a
    // `[review:watchdog] detector silent` line to server.log so the silence
    // is itself detectable by someone reading server.log.
    server.tool("review-digest-compose", "Compose and persist the daily review digest (scheduled — invoked by check-due-events). Reads the last 24h of review.log and ReviewAlert records, authors a CreativeWork digest, and runs the detector-silence watchdog. Users should not call this directly; the platform schedules it via an Event.", {}, async () => {
        const digestDate = new Date().toISOString().slice(0, 10);
        const title = `Review Digest ${digestDate}`;
        const now = Date.now();
        const windowStartMs = now - 24 * 60 * 60 * 1000;
        // 1. Read the last 24h of review.log. To keep memory bounded on a Pi
        //    where review.log can grow to hundreds of megabytes over time, cap
        //    the read at MAX_DIGEST_READ_BYTES from the end of the file. The
        //    24h filter below then drops anything older than the cutoff.
        const logPath = reviewLogPath(configDir);
        let recentLines = [];
        let detectorMtime = null;
        if (existsSync(logPath)) {
            try {
                const st = statSync(logPath);
                detectorMtime = st.mtimeMs;
                const size = st.size;
                const readFrom = Math.max(0, size - MAX_DIGEST_READ_BYTES);
                const bufSize = size - readFrom;
                let raw = "";
                if (bufSize > 0) {
                    const fd = openSync(logPath, "r");
                    try {
                        const buf = Buffer.alloc(bufSize);
                        readSync(fd, buf, 0, bufSize, readFrom);
                        raw = buf.toString("utf-8");
                    }
                    finally {
                        closeSync(fd);
                    }
                }
                // When we start mid-file, the first line will almost always be a
                // partial — drop it so we don't try to parse corrupt JSON.
                const all = raw.split("\n").filter((l) => l.length > 0);
                if (readFrom > 0 && all.length > 0)
                    all.shift();
                // Filter by timestamp prefix. Lines start with ISO 8601 timestamps so
                // a lexicographic prefix comparison against the cutoff ISO string is
                // correct and cheap.
                const cutoffIso = new Date(windowStartMs).toISOString();
                recentLines = all.filter((l) => l >= cutoffIso);
            }
            catch (err) {
                reviewLog(configDir, {
                    event: "digest-read-failed",
                    error: err instanceof Error ? err.message : String(err),
                });
            }
        }
        // 2. Count events by type for the summary.
        const eventCounts = {};
        const ruleMatches = {};
        for (const line of recentLines) {
            const jsonStart = line.indexOf("{");
            if (jsonStart === -1)
                continue;
            try {
                const event = JSON.parse(line.slice(jsonStart));
                if (event.event) {
                    eventCounts[event.event] = (eventCounts[event.event] ?? 0) + 1;
                }
                if (event.event === "match" && event.ruleId) {
                    ruleMatches[event.ruleId] = (ruleMatches[event.ruleId] ?? 0) + 1;
                }
            }
            catch {
                // Not every line is pure JSON — skip.
            }
        }
        // 3. Query Neo4j for active alerts to cross-reference.
        let activeAlerts = [];
        try {
            const session = getSession();
            try {
                const result = await session.run(`MATCH (a:ReviewAlert {accountId: $accountId})
             WHERE a.resolvedAt IS NULL
               AND (a.suppressedUntil IS NULL OR a.suppressedUntil < datetime())
             RETURN a.ruleName AS ruleName, a.cumulativeMatchCount AS count,
                    a.lastMatchAt AS lastMatchAt, a.suggestedAction AS suggestedAction
             ORDER BY a.lastMatchAt DESC LIMIT 20`, { accountId });
                activeAlerts = result.records.map((r) => {
                    const rawCount = r.get("count");
                    const count = typeof rawCount === "number"
                        ? rawCount
                        : rawCount?.toNumber?.() ?? 0;
                    return {
                        ruleName: r.get("ruleName"),
                        count,
                        lastMatchAt: (r.get("lastMatchAt")?.toString?.() ?? ""),
                        suggestedAction: r.get("suggestedAction"),
                    };
                });
            }
            finally {
                await session.close();
            }
        }
        catch (err) {
            reviewLog(configDir, {
                event: "digest-neo4j-failed",
                error: err instanceof Error ? err.message : String(err),
            });
        }
        // 4. Detector silence watchdog. Derive the threshold from the current
        //    rules file's scanIntervalMs — the detector writes one line per
        //    cycle, so silence longer than 2× the configured interval (with
        //    a 30s floor to absorb clock jitter and brief Neo4j delays) means
        //    the detector is not running. We raise a server.log line so this
        //    silence is itself visible on the most-consulted diagnostic surface.
        let watchdogOk = true;
        let watchdogThresholdMs = 30_000;
        try {
            const currentRules = loadRulesFile(configDir);
            watchdogThresholdMs = Math.max(30_000, currentRules.scanIntervalMs * 2);
        }
        catch {
            // Rules file may be missing or malformed — fall back to the 30s floor.
        }
        if (detectorMtime === null) {
            watchdogOk = false;
        }
        else {
            const silentFor = now - detectorMtime;
            if (silentFor > watchdogThresholdMs) {
                watchdogOk = false;
                // server.log is platform-scoped — write directly.
                const serverLogPath = join(configDir, "logs", "server.log");
                try {
                    const entry = `${new Date().toISOString()} [review:watchdog] detector silent for ${Math.round(silentFor / 1000)}s (threshold ${Math.round(watchdogThresholdMs / 1000)}s) — last write at ${new Date(detectorMtime).toISOString()}\n`;
                    appendFileSync(serverLogPath, entry, "utf-8");
                }
                catch {
                    console.error("[review-tool] failed to write watchdog line to server.log");
                }
            }
        }
        // 5. Compose the abstract.
        const lines = [];
        lines.push(`Review cadence report for ${digestDate} (${recentLines.length} review-log lines in the last 24h).`);
        lines.push("");
        if (activeAlerts.length === 0 && Object.keys(ruleMatches).length === 0 && watchdogOk) {
            lines.push("Logs reviewed. No anomalies. Detector is healthy.");
        }
        else {
            if (activeAlerts.length > 0) {
                lines.push(`### Active alerts (${activeAlerts.length})`);
                for (const alert of activeAlerts) {
                    lines.push(`- **${alert.ruleName}** (${alert.count}×, last at ${alert.lastMatchAt.slice(0, 19)})`);
                    lines.push(`  → ${alert.suggestedAction}`);
                }
                lines.push("");
            }
            if (Object.keys(ruleMatches).length > 0) {
                lines.push(`### Rule matches in window`);
                for (const [ruleId, count] of Object.entries(ruleMatches)) {
                    lines.push(`- ${ruleId}: ${count}`);
                }
                lines.push("");
            }
            const notableEvents = ["rate-limit-deferred", "queue-drain", "source-rotated", "source-vanished", "cycle-failed", "rules-reload-failed"];
            const notable = notableEvents.filter((e) => (eventCounts[e] ?? 0) > 0);
            if (notable.length > 0) {
                lines.push(`### Detector events`);
                for (const e of notable) {
                    lines.push(`- ${e}: ${eventCounts[e]}`);
                }
                lines.push("");
            }
            if (!watchdogOk) {
                lines.push(`### ⚠️ Detector watchdog`);
                lines.push(detectorMtime === null
                    ? "- review.log does not exist. The detector may have failed to boot."
                    : `- review.log has been silent since ${new Date(detectorMtime).toISOString()}. Investigate.`);
                lines.push("");
            }
        }
        const abstract = lines.join("\n");
        // 6. Persist as CreativeWork.
        const session = getSession();
        try {
            await session.run(`MERGE (d:CreativeWork {accountId: $accountId, title: $title})
           ON CREATE SET d.createdAt = $createdAt, d.abstract = $abstract
           ON MATCH SET d.createdAt = $createdAt, d.abstract = $abstract`, {
                accountId,
                title,
                createdAt: new Date().toISOString(),
                abstract,
            });
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] digest compose: Neo4j write failed — ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
        finally {
            await session.close();
        }
        // The post-persist steps (audit log + success return) can still fail
        // on a full disk. Wrap them so we always return a structured response
        // rather than propagating an uncaught exception through the MCP layer.
        try {
            reviewLog(configDir, {
                event: "digest-composed",
                title,
                reviewLogLines: recentLines.length,
                activeAlerts: activeAlerts.length,
                ruleMatches: Object.keys(ruleMatches).length,
                watchdogOk,
            });
            return {
                content: [{ type: "text", text: `Composed "${title}". Review log lines: ${recentLines.length}. Active alerts: ${activeAlerts.length}. Watchdog: ${watchdogOk ? "ok" : "silent"}.` }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `[review-tool] digest compose: post-persist step failed — ${err instanceof Error ? err.message : String(err)}. The CreativeWork digest was saved but the audit log line could not be written.` }],
                isError: true,
            };
        }
    });
}
//# sourceMappingURL=review-tools.js.map