/**
 * SessionActivitySentinel — Monitors running sessions for undigested activity.
 *
 * The sentinel is a background process that runs inside the Instar server,
 * watching for sessions that have accumulated unprocessed activity. It creates
 * mid-session "mini-digests" using an LLM, and produces a synthesis when
 * sessions complete.
 *
 * Trigger points:
 *   1. Periodic scan (every 30-60 min) — checks running sessions
 *   2. Session completion (sessionComplete event) — creates synthesis
 *   3. On-demand (API/CLI) — manual digest trigger
 *
 * Concurrency:
 *   - Idempotent via hash(sessionId + startedAt + endedAt) digest keys
 *   - Dormant sessions skipped (lastActivity <= lastDigest)
 *   - Minimum activity threshold prevents noisy digests
 *
 * LLM failure handling:
 *   - Failed digests saved to pending queue
 *   - Exponential backoff: 1min, 5min, 15min
 *   - After 3 retries, raw content archived
 *
 * Implements Phase 3 of PROP-memory-architecture.md v3.1.
 */
import fs from 'node:fs';
import path from 'node:path';
import { EpisodicMemory } from '../memory/EpisodicMemory.js';
import { ActivityPartitioner } from '../memory/ActivityPartitioner.js';
import { DegradationReporter } from './DegradationReporter.js';
/** Valid entity types (must match EntityType in src/core/types.ts). */
const VALID_ENTITY_TYPES = [
    'fact', 'person', 'project', 'tool', 'pattern', 'decision', 'lesson',
];
/** Valid relation types (must match RelationType in src/core/types.ts). */
const VALID_RELATION_TYPES = [
    'related_to', 'built_by', 'learned_from', 'depends_on', 'supersedes',
    'contradicts', 'part_of', 'used_in', 'knows_about', 'caused', 'verified_by',
];
/**
 * Resolve the periodic-scan interval in milliseconds from config.
 *
 * Returns `null` when the periodic scan is disabled (enabled === false).
 * Otherwise returns the interval clamped to a 5-minute floor (a faster
 * cadence wastes LLM budget — scan() skips dormant sessions and enforces a
 * minimum-activity threshold anyway, so there's no value in scanning more
 * often than every few minutes). Default is 30 minutes.
 *
 * Extracted as a pure function so the clamp / default / disabled logic is
 * unit-testable without standing up the full server bootstrap.
 */
export function resolveSentinelScanIntervalMs(cfg) {
    if (cfg?.enabled === false)
        return null;
    const minutes = Math.max(5, cfg?.scanIntervalMinutes ?? 30);
    return minutes * 60_000;
}
// ─── SessionActivitySentinel ────────────────────────────────────────
export class SessionActivitySentinel {
    config;
    episodicMemory;
    partitioner;
    maxRetries;
    constructor(config) {
        this.config = config;
        this.episodicMemory = new EpisodicMemory({ stateDir: config.stateDir });
        this.partitioner = new ActivityPartitioner();
        this.maxRetries = config.maxRetries ?? 3;
    }
    // ─── Public API ─────────────────────────────────────────────────
    /**
     * Scan all running sessions for undigested activity.
     * Called periodically (every 30-60 min) by the scheduler.
     */
    async scan() {
        const report = {
            scannedAt: new Date().toISOString(),
            sessionsScanned: 0,
            digestsCreated: 0,
            sessionsSkipped: 0,
            errors: [],
        };
        const sessions = this.config.getActiveSessions();
        const state = this.episodicMemory.getSentinelState();
        // Process any pending retries first
        await this.retryPending(state);
        for (const session of sessions) {
            report.sessionsScanned++;
            try {
                const sessionState = state.sessions[session.id];
                const lastDigestedAt = sessionState?.lastDigestedAt;
                // Check if there's new activity since last digest
                if (lastDigestedAt && sessionState?.lastActivityAt) {
                    if (sessionState.lastActivityAt <= lastDigestedAt) {
                        report.sessionsSkipped++;
                        continue;
                    }
                }
                const digests = await this.digestActivity(session, lastDigestedAt);
                report.digestsCreated += digests.length;
                // Update sentinel state
                if (digests.length > 0) {
                    const latest = digests[digests.length - 1];
                    state.sessions[session.id] = {
                        lastDigestedAt: latest.endedAt,
                        lastActivityAt: latest.endedAt,
                        digestCount: (sessionState?.digestCount ?? 0) + digests.length,
                    };
                }
            }
            catch (err) {
                DegradationReporter.getInstance().report({
                    feature: 'SessionActivitySentinel.scan',
                    primary: `Digest activity for session ${session.id}`,
                    fallback: 'Session activity undigested, will retry next scan',
                    reason: `Scan error: ${err instanceof Error ? err.message : String(err)}`,
                    impact: `Activity for session ${session.id} may be lost if not retried`,
                });
                report.errors.push({ sessionId: session.id, error: err.message });
            }
        }
        state.lastScanAt = report.scannedAt;
        this.episodicMemory.saveSentinelState(state);
        return report;
    }
    /**
     * Digest a specific session's recent activity.
     * Returns activity digests created.
     */
    async digestActivity(session, lastDigestedAt) {
        // Capture session output
        const sessionOutput = this.config.captureSessionOutput(session.tmuxSession);
        if (!sessionOutput || sessionOutput.trim().length === 0)
            return [];
        // Get linked Telegram messages if available
        let telegramMessages;
        if (this.config.getTopicForSession && this.config.getTelegramMessages) {
            const topicId = this.config.getTopicForSession(session.tmuxSession);
            if (topicId) {
                telegramMessages = this.config.getTelegramMessages(topicId, lastDigestedAt);
            }
        }
        // Partition into activity units
        const units = this.partitioner.partition({
            sessionOutput,
            telegramMessages,
            lastDigestedAt,
        });
        if (units.length === 0)
            return [];
        // Digest each unit via LLM
        const digests = [];
        const topicId = this.config.getTopicForSession?.(session.tmuxSession) ?? undefined;
        for (const unit of units) {
            try {
                const digest = await this.digestUnit(session, unit, topicId);
                if (digest) {
                    digests.push(digest);
                }
            }
            catch (err) {
                // Save to pending queue for retry
                const rawContent = this.formatUnitForPending(session, unit);
                this.episodicMemory.savePending(session.id, rawContent);
            }
        }
        return digests;
    }
    /**
     * Synthesize all mini-digests into a session-level summary.
     * Called when a session completes.
     */
    async synthesizeSession(session) {
        const report = {
            sessionId: session.id,
            digestCount: 0,
            synthesisCreated: false,
        };
        // First, create any remaining digests for unprocessed activity
        const state = this.episodicMemory.getSentinelState();
        const lastDigestedAt = state.sessions[session.id]?.lastDigestedAt;
        try {
            const finalDigests = await this.digestActivity(session, lastDigestedAt);
            report.digestCount += finalDigests.length;
        }
        catch {
            // Continue to synthesis even if final digest fails
        }
        // Get all digests for this session
        const allDigests = this.episodicMemory.getSessionActivities(session.id);
        report.digestCount = allDigests.length;
        if (allDigests.length === 0)
            return report;
        // Synthesize
        try {
            const synthesis = await this.buildSynthesis(session, allDigests);
            this.episodicMemory.saveSynthesis(synthesis);
            report.synthesisCreated = true;
        }
        catch (err) {
            report.error = err.message;
        }
        return report;
    }
    /**
     * Get the underlying EpisodicMemory instance (for route wiring).
     */
    getEpisodicMemory() {
        return this.episodicMemory;
    }
    // ─── LLM Digestion ─────────────────────────────────────────────
    async digestUnit(session, unit, telegramTopicId) {
        const prompt = this.buildDigestPrompt(session, unit);
        const response = await this.config.intelligence.evaluate(prompt, {
            model: 'fast', // Haiku tier for cost efficiency
            maxTokens: 1500,
            temperature: 0.3,
            attribution: { component: 'SessionActivitySentinel' }, // attribution for /metrics/features
        });
        const parsed = this.parseDigestResponse(response);
        if (!parsed)
            return null;
        // Materialize extracted entities into SemanticMemory when wired.
        // Failures here MUST NOT block digest persistence — the digest is still
        // useful as a summary record even if the entity graph can't be populated
        // for this particular unit. The digest's `entities: []` field falls back
        // to an empty array on any materialization error.
        let entityIds = [];
        if (this.config.semanticMemory && parsed.entities.length > 0) {
            try {
                entityIds = this.materializeEntities(session.id, parsed.entities);
            }
            catch (err) {
                // @silent-fallback-ok — entity extraction is best-effort; the
                // digest is the canonical record. Log and continue so future scans
                // get another chance to extract entities from related content.
                console.warn(`[ActivitySentinel] entity materialization failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        const digestId = this.episodicMemory.saveDigest({
            sessionId: session.id,
            sessionName: session.name,
            startedAt: unit.startedAt,
            endedAt: unit.endedAt,
            telegramTopicId,
            summary: parsed.summary,
            actions: parsed.actions,
            entities: entityIds,
            learnings: parsed.learnings,
            significance: parsed.significance,
            themes: parsed.themes,
            boundarySignal: unit.boundarySignal,
        });
        return this.episodicMemory.getDigest(session.id, digestId);
    }
    /**
     * Materialize extracted entities into SemanticMemory.
     *
     * Two-pass algorithm:
     *   1. For each entity, dedupe against the existing graph via findByName.
     *      If the entity exists, reuse its ID. Otherwise call remember() to
     *      create it. Build a name→id map covering both reused and newly-created
     *      entities in this batch.
     *   2. For each entity's relationships, resolve the target by name. Check
     *      the batch map first (intra-digest references), then findByName for
     *      cross-digest references. Unresolved targets are dropped silently —
     *      they'll naturally resolve when a future digest mentions both names.
     *
     * Confidence is set to 0.7 — matching the MEMORY.md migration default. This
     * reflects observation-grade certainty (the LLM extracted from real
     * conversation content), not user-asserted certainty (which would be 0.95).
     *
     * Returns the array of entity IDs (existing + newly-created) for
     * traceability via ActivityDigest.entities.
     */
    materializeEntities(sessionId, entities) {
        const sm = this.config.semanticMemory;
        if (!sm)
            return [];
        const now = new Date().toISOString();
        const sourceTag = `session:${sessionId}`;
        const nameToId = new Map();
        const allIds = [];
        // Pass 1 — remember entities, build name→id map.
        for (const e of entities) {
            const existing = sm.findByName(e.name, e.type);
            let id;
            if (existing) {
                id = existing.id;
            }
            else {
                id = sm.remember({
                    type: e.type,
                    name: e.name,
                    content: e.content,
                    confidence: 0.7,
                    lastVerified: now,
                    source: sourceTag,
                    sourceSession: sessionId,
                    tags: [],
                });
            }
            nameToId.set(e.name.toLowerCase(), id);
            allIds.push(id);
        }
        // Pass 2 — connect relationships. Resolve targets via batch map first,
        // then fall through to the cross-digest lookup.
        for (const e of entities) {
            const fromId = nameToId.get(e.name.toLowerCase());
            if (!fromId)
                continue;
            for (const rel of e.relationships) {
                const targetKey = rel.to.toLowerCase();
                let toId = nameToId.get(targetKey);
                if (!toId) {
                    const found = sm.findByName(rel.to);
                    if (found) {
                        toId = found.id;
                        nameToId.set(targetKey, toId);
                    }
                }
                if (!toId)
                    continue; // unresolved cross-digest target — drop
                if (toId === fromId)
                    continue; // self-loops aren't meaningful here
                sm.connect(fromId, toId, rel.relation, `digest:${sessionId}`);
            }
        }
        return allIds;
    }
    buildDigestPrompt(session, unit) {
        const lines = [];
        lines.push('You are analyzing a chunk of an AI agent\'s work session to create a concise activity digest.');
        lines.push('');
        lines.push(`Session: ${session.name}`);
        if (session.jobSlug)
            lines.push(`Job: ${session.jobSlug}`);
        lines.push(`Activity period: ${unit.startedAt} to ${unit.endedAt}`);
        lines.push(`Boundary signal: ${unit.boundarySignal}`);
        lines.push('');
        if (unit.telegramContent) {
            lines.push('=== CONVERSATION (human + agent) ===');
            lines.push(this.truncate(unit.telegramContent, 6000));
            lines.push('');
        }
        if (unit.sessionContent) {
            lines.push('=== SESSION OUTPUT (agent actions) ===');
            lines.push(this.truncate(unit.sessionContent, 6000));
            lines.push('');
        }
        lines.push('Respond in this EXACT JSON format (no other text):');
        lines.push('{');
        lines.push('  "summary": "2-3 sentence overview of what happened in this activity unit",');
        lines.push('  "actions": ["key action 1", "key action 2"],');
        lines.push('  "learnings": ["insight or lesson learned"],');
        lines.push('  "significance": 5,');
        lines.push('  "themes": ["topic1", "topic2"],');
        lines.push('  "entities": [');
        lines.push('    {');
        lines.push('      "type": "decision",');
        lines.push('      "name": "short canonical name",');
        lines.push('      "content": "1-3 sentence description with enough context to recall later",');
        lines.push('      "relationships": [');
        lines.push('        {"to": "other entity name", "relation": "part_of"}');
        lines.push('      ]');
        lines.push('    }');
        lines.push('  ]');
        lines.push('}');
        lines.push('');
        lines.push('RULES:');
        lines.push('- significance is 1-10 (1=trivial, 5=normal, 8+=major milestone)');
        lines.push('- themes are 1-3 word topic tags');
        lines.push('- actions are specific: "committed migration engine", not "wrote code"');
        lines.push('- learnings are insights, not descriptions: "FTS5 requires sync triggers", not "worked on FTS5"');
        lines.push('- entities are durable things worth remembering across sessions: people mentioned, projects discussed, decisions made, tools introduced, patterns observed, lessons learned, hard facts. NOT every noun — only what an agent would want to recall weeks later.');
        lines.push('- entity type MUST be one of: fact, person, project, tool, pattern, decision, lesson');
        lines.push('- entity name is short and canonical (the form you would use to refer back to it)');
        lines.push('- entity content captures enough context to ground a future recall (not just the name)');
        lines.push('- relationship "to" references another entity by its name (must match one in this same entities array or a prior digest)');
        lines.push('- relation MUST be one of: related_to, built_by, learned_from, depends_on, supersedes, contradicts, part_of, used_in, knows_about, caused, verified_by');
        lines.push('- omit the entities array entirely or use [] if nothing in this activity unit is worth durable memory');
        return lines.join('\n');
    }
    parseDigestResponse(response) {
        try {
            // Extract JSON from response (may have surrounding text)
            const jsonMatch = response.match(/\{[\s\S]*\}/);
            if (!jsonMatch)
                return null;
            const parsed = JSON.parse(jsonMatch[0]);
            return {
                summary: String(parsed.summary || ''),
                actions: Array.isArray(parsed.actions) ? parsed.actions.map(String) : [],
                learnings: Array.isArray(parsed.learnings) ? parsed.learnings.map(String) : [],
                significance: Math.min(10, Math.max(1, Number(parsed.significance) || 5)),
                themes: Array.isArray(parsed.themes) ? parsed.themes.map(String) : [],
                entities: this.parseExtractedEntities(parsed.entities),
            };
        }
        catch {
            // @silent-fallback-ok — LLM response may not be valid JSON; caller handles null gracefully
            return null;
        }
    }
    /**
     * Validate and normalize the entities array from the LLM response.
     *
     * Malformed entries are dropped (logged at warn level) rather than failing
     * the whole digest. The digest summary remains useful even when entity
     * extraction degrades. Type and relation values not in the enums are
     * filtered out at this step rather than at materialization, so the
     * SemanticMemory.remember() call never receives an invalid type.
     */
    parseExtractedEntities(raw) {
        if (!Array.isArray(raw))
            return [];
        const out = [];
        for (const item of raw) {
            if (!item || typeof item !== 'object')
                continue;
            const o = item;
            const type = String(o.type ?? '').toLowerCase();
            const name = String(o.name ?? '').trim();
            const content = String(o.content ?? '').trim();
            if (!name || !content)
                continue;
            if (!VALID_ENTITY_TYPES.includes(type))
                continue;
            const relationships = Array.isArray(o.relationships)
                ? o.relationships
                    .map((r) => ({
                    to: String(r?.to ?? '').trim(),
                    relation: String(r?.relation ?? '').toLowerCase(),
                }))
                    .filter((r) => r.to && VALID_RELATION_TYPES.includes(r.relation))
                : [];
            out.push({ type, name, content, relationships });
        }
        return out;
    }
    // ─── Session Synthesis ──────────────────────────────────────────
    async buildSynthesis(session, digests) {
        const prompt = this.buildSynthesisPrompt(session, digests);
        const response = await this.config.intelligence.evaluate(prompt, {
            model: 'fast',
            maxTokens: 2000,
            temperature: 0.3,
            attribution: { component: 'SessionActivitySentinel' }, // attribution for /metrics/features
        });
        const parsed = this.parseSynthesisResponse(response);
        const topicId = this.config.getTopicForSession?.(session.tmuxSession) ?? undefined;
        return {
            sessionId: session.id,
            sessionName: session.name,
            startedAt: session.startedAt,
            endedAt: session.endedAt || new Date().toISOString(),
            jobSlug: session.jobSlug,
            telegramTopicId: topicId,
            activityDigestIds: digests.map(d => d.id),
            summary: parsed.summary,
            keyOutcomes: parsed.keyOutcomes,
            allEntities: [...new Set(digests.flatMap(d => d.entities))],
            allLearnings: [...new Set(digests.flatMap(d => d.learnings))],
            significance: parsed.significance,
            themes: [...new Set(digests.flatMap(d => d.themes))],
            followUp: parsed.followUp,
        };
    }
    buildSynthesisPrompt(session, digests) {
        const lines = [];
        lines.push('You are creating a coherent session synthesis from multiple activity digests.');
        lines.push('');
        lines.push(`Session: ${session.name}`);
        if (session.jobSlug)
            lines.push(`Job: ${session.jobSlug}`);
        lines.push(`Duration: ${session.startedAt} to ${session.endedAt || 'ongoing'}`);
        lines.push(`Activity units: ${digests.length}`);
        lines.push('');
        lines.push('=== ACTIVITY DIGESTS (chronological) ===');
        for (const digest of digests) {
            lines.push(`\n--- ${digest.startedAt} [${digest.boundarySignal}] (significance: ${digest.significance}) ---`);
            lines.push(digest.summary);
            if (digest.actions.length > 0)
                lines.push(`Actions: ${digest.actions.join(', ')}`);
            if (digest.learnings.length > 0)
                lines.push(`Learnings: ${digest.learnings.join(', ')}`);
        }
        lines.push('');
        lines.push('Respond in this EXACT JSON format (no other text):');
        lines.push('{');
        lines.push('  "summary": "3-5 sentence coherent overview of the entire session",');
        lines.push('  "keyOutcomes": ["outcome 1", "outcome 2"],');
        lines.push('  "significance": 7,');
        lines.push('  "followUp": "What the next session should do (or null if nothing pending)"');
        lines.push('}');
        return lines.join('\n');
    }
    parseSynthesisResponse(response) {
        try {
            const jsonMatch = response.match(/\{[\s\S]*\}/);
            if (!jsonMatch) {
                return { summary: response.slice(0, 500), keyOutcomes: [], significance: 5 };
            }
            const parsed = JSON.parse(jsonMatch[0]);
            return {
                summary: String(parsed.summary || ''),
                keyOutcomes: Array.isArray(parsed.keyOutcomes) ? parsed.keyOutcomes.map(String) : [],
                significance: Math.min(10, Math.max(1, Number(parsed.significance) || 5)),
                followUp: parsed.followUp ? String(parsed.followUp) : undefined,
            };
        }
        catch {
            return { summary: response.slice(0, 500), keyOutcomes: [], significance: 5 };
        }
    }
    // ─── Pending Retry ──────────────────────────────────────────────
    async retryPending(state) {
        // Check all sessions for pending items
        const pendingDir = path.join(this.config.stateDir, 'episodes', 'pending');
        if (!fs.existsSync(pendingDir))
            return;
        for (const sessionId of fs.readdirSync(pendingDir)) {
            const items = this.episodicMemory.getPending(sessionId);
            for (const item of items) {
                if (item.retryCount >= this.maxRetries) {
                    // Archive and remove — exceeded max retries
                    this.episodicMemory.removePending(sessionId, item.id);
                    continue;
                }
                // Exponential backoff: 1min, 5min, 15min
                const backoffMs = [60_000, 300_000, 900_000][item.retryCount] ?? 900_000;
                const lastAttempt = new Date(item.createdAt).getTime() + (backoffMs * item.retryCount);
                if (Date.now() < lastAttempt)
                    continue;
                // Retry the digestion
                try {
                    const response = await this.config.intelligence.evaluate(item.content, {
                        model: 'fast',
                        maxTokens: 1500,
                        temperature: 0.3,
                        attribution: { component: 'SessionActivitySentinel' }, // attribution for /metrics/features
                    });
                    const parsed = this.parseDigestResponse(response);
                    if (parsed) {
                        this.episodicMemory.removePending(sessionId, item.id);
                    }
                    else {
                        this.episodicMemory.incrementPendingRetry(sessionId, item.id);
                    }
                }
                catch {
                    this.episodicMemory.incrementPendingRetry(sessionId, item.id);
                }
            }
        }
    }
    // ─── Helpers ────────────────────────────────────────────────────
    formatUnitForPending(session, unit) {
        return this.buildDigestPrompt(session, unit);
    }
    truncate(text, maxChars) {
        if (text.length <= maxChars)
            return text;
        return text.slice(0, maxChars) + '\n... [truncated]';
    }
}
//# sourceMappingURL=SessionActivitySentinel.js.map