/**
 * Heartbeat Service — periodic conversation analysis for knowledge extraction.
 *
 * Every N minutes, for each chat scope (each folder + workspace) with
 * new human messages, routes a single prompt through the agent orchestrator.
 * The agent decides per item:
 *   - AUTO-CAPTURE (call create_entity directly, emits [auto-captured:...])
 *   - SUGGEST (emits [capture:...] line only, user must click to capture)
 *
 * State persists in SQLite (analysis_markers table) so restarts don't drop
 * analysis windows. An in-memory activity counter (humanMessageCount) skips
 * ticks for scopes with no new activity — zero LLM cost when no one posts.
 */
import { CURATED_ENTITY_TYPES } from '../core/entity-types-catalog.js';
import { log } from '../utils/log.js';
const DEFAULT_INTERVAL_MS = 3 * 60_000; // 3 minutes
function folderKey(name) { return `folder:${name}`; }
const WORKSPACE_KEY = 'workspace';
function buildFolderSummaries(wm) {
    const names = wm.getRepoNames();
    const lines = [];
    for (const name of names) {
        try {
            const graph = wm.getGraph(name);
            const entities = graph.search({});
            if (entities.length === 0) {
                lines.push(`- ${name}: (empty)`);
                continue;
            }
            const types = new Map();
            for (const e of entities)
                types.set(e.entityType, (types.get(e.entityType) ?? 0) + 1);
            const typeSummary = [...types.entries()].map(([t, c]) => `${c} ${t}`).join(', ');
            lines.push(`- ${name}: ${typeSummary}`);
        }
        catch { /* skip */ }
    }
    return lines.join('\n');
}
function buildAnalysisPrompt(args) {
    const { scopeLabel, isWorkspace, transcript, previousSuggestions, folderSummaries, today } = args;
    const alreadySuggested = previousSuggestions.length > 0
        ? `\nALREADY SUGGESTED (do NOT repeat or suggest similar items):\n${previousSuggestions.map(s => `- ${s}`).join('\n')}\n`
        : '';
    const folderSection = isWorkspace
        ? `\nAVAILABLE FOLDERS (route each auto-capture to the best match by name; if nothing fits, skip the item entirely):\n${folderSummaries}\n`
        : '';
    const scopeInstruction = isWorkspace
        ? `You are analyzing the workspace-wide home chat. Captures must be routed to the correct folder — pass the folder name as repo when calling create_entity. Only AUTO-CAPTURE in workspace scope; do NOT emit [capture:...] suggestions here.`
        : `You are analyzing the conversation in the "${scopeLabel}" folder. Any captures should be created in this folder (repo="${scopeLabel}").`;
    return `${scopeInstruction}

Today is ${today}. Extract organizational context that belongs in the knowledge graph.

For each extractable item, decide:

AUTO-CAPTURE — call create_entity directly when the item is unambiguous:
  - A decision stated clearly with owner or date
  - A task with an assignee AND a date or deadline
  - A new person mentioned by full name with role or organization
  - A new deal/project stated with client and stage
  - An event (meeting/workshop/call) confirmed with attendees and date

SUGGEST — emit only a [capture:type:id] line (no tool call) when the item needs human judgment:
  - Implicit decisions or aspirational statements
  - Tasks without owner or date
  - Entity updates where the target entity is unclear

Be very selective. Surface at most 2–3 items. Skip anything trivial.
${alreadySuggested}${folderSection}
After any create_entity tool calls, write a FINAL MESSAGE with one line per item, in this exact format:

  Auto-captured (${isWorkspace ? 'workspace — include target folder' : 'folder'}):
    ${isWorkspace
        ? '- Captured [[entity-id]] in [[folder-name]] — Undo [auto-captured:entity_type:entity-id:folder-name]'
        : '- Captured [[entity-id]] — Undo [auto-captured:entity_type:entity-id]'}
  ${isWorkspace ? '' : `Suggested (low-confidence):\n    - Capture {type} on {short label} [capture:entity_type:entity-id]`}

Use kebab-case entity IDs. Allowed types: ${CURATED_ENTITY_TYPES.join(', ')}.

Your response must be EITHER capture/auto-capture lines (one per item, no narrative) OR the single word NONE. Do not add explanation, analysis, or preamble. If nothing is worth capturing, output only: NONE

Conversation:
${transcript}`;
}
export class HeartbeatService {
    messageService;
    workspaceManager;
    wsHub;
    agent;
    interval = null;
    activity = new Map();
    running = false;
    constructor(messageService, workspaceManager, wsHub, agent) {
        this.messageService = messageService;
        this.workspaceManager = workspaceManager;
        this.wsHub = wsHub;
        this.agent = agent;
    }
    /** Called from the folder POST handler when a human message is sent. */
    trackMessage(folder, _messageId) {
        const key = folderKey(folder);
        const existing = this.activity.get(key);
        if (existing) {
            existing.humanMessageCount++;
        }
        else {
            this.activity.set(key, { humanMessageCount: 1 });
        }
    }
    /** Called from the workspace POST handler when a human message is sent to home chat. */
    trackWorkspaceMessage(_messageId) {
        const existing = this.activity.get(WORKSPACE_KEY);
        if (existing) {
            existing.humanMessageCount++;
        }
        else {
            this.activity.set(WORKSPACE_KEY, { humanMessageCount: 1 });
        }
    }
    start(intervalMs = DEFAULT_INTERVAL_MS) {
        if (this.interval)
            return;
        if (!this.agent) {
            log.warn('[heartbeat] No orchestrator provided — heartbeat disabled');
            return;
        }
        log.info(`[heartbeat] Starting with ${intervalMs / 1000}s interval`);
        this.interval = setInterval(() => this.tick(), intervalMs);
    }
    stop() {
        if (this.interval) {
            clearInterval(this.interval);
            this.interval = null;
        }
    }
    async tick() {
        if (this.running)
            return; // skip if previous tick is still running
        this.running = true;
        try {
            for (const [key, state] of this.activity) {
                if (state.humanMessageCount === 0)
                    continue;
                try {
                    await this.analyzeScope(key);
                }
                catch (err) {
                    log.warn(`[heartbeat] Analysis failed for ${key}:`, err.message);
                }
                // Reset counter regardless of success/failure
                state.humanMessageCount = 0;
            }
        }
        finally {
            this.running = false;
        }
    }
    async analyzeScope(scopeKey) {
        if (!this.agent)
            return;
        const isWorkspace = scopeKey === WORKSPACE_KEY;
        const folder = isWorkspace ? null : scopeKey.slice('folder:'.length);
        const scopeLabel = folder ?? 'workspace';
        const lastAnalyzed = this.messageService.getAnalysisMarker(scopeKey);
        const allMessages = isWorkspace
            ? this.messageService.getWorkspaceMessages({ limit: 50 })
            : this.messageService.getMessages(folder, { limit: 50 });
        const newMessages = allMessages.filter(m => m.id > lastAnalyzed);
        if (newMessages.length === 0)
            return;
        // Build previous-suggestion list (last 50 messages' capture markers)
        const captureRe = /\[(capture|captured|capturing|auto-captured|auto-capture-undone):([a-z-]+):([a-z0-9-]+)\]/g;
        const previousSuggestions = [];
        for (const msg of allMessages) {
            if (msg.role !== 'agent')
                continue;
            for (const match of msg.content.matchAll(captureRe)) {
                const lineRe = new RegExp(`[-•*]\\s*(.+?)\\s*\\[${match[1]}:${match[2]}:${match[3]}\\]`);
                const lineMatch = msg.content.match(lineRe);
                if (lineMatch)
                    previousSuggestions.push(lineMatch[1].trim());
            }
        }
        // Sort oldest-first for readable transcript
        const sorted = [...allMessages].sort((a, b) => a.id - b.id);
        const transcript = sorted.slice(-30).map(m => {
            const role = m.role === 'agent' ? 'Agent' : m.userDisplayName;
            return `[${m.createdAt}] ${role}: ${m.content}`;
        }).join('\n\n');
        const folderSummaries = isWorkspace ? buildFolderSummaries(this.workspaceManager) : '';
        const today = new Date().toISOString().slice(0, 10);
        const prompt = buildAnalysisPrompt({
            scopeLabel,
            isWorkspace,
            transcript,
            previousSuggestions,
            folderSummaries,
            today,
        });
        const sessionId = `heartbeat:${scopeKey}`;
        const response = await this.agent.chatInSession(sessionId, prompt, {
            user: null,
            fallbackHistory: [{ role: 'user', content: prompt }],
        });
        // Advance marker regardless of whether text was produced — we've analyzed up to this point.
        const maxId = Math.max(...newMessages.map(m => m.id));
        this.messageService.setAnalysisMarker(scopeKey, maxId);
        const text = response.content?.trim();
        if (!text)
            return;
        // Skip if the response has no capture markers at all — the agent either wrote
        // NONE or produced narrative without actionable output. Either way, nothing
        // worth posting to the thread.
        const hasMarker = /\[(capture|auto-captured):[a-z-]+:[a-z0-9-]+/.test(text);
        if (!hasMarker)
            return;
        // Post the agent's final message so any [auto-captured:...]/[capture:...] markers render.
        if (isWorkspace) {
            const msg = this.messageService.postWorkspaceMessage(0, 'Agent', 'agent', text);
            this.wsHub.broadcast({
                type: 'workspace_message',
                message: msg,
                timestamp: new Date().toISOString(),
            });
        }
        else {
            const msg = this.messageService.postMessage(folder, 0, 'Agent', 'agent', text);
            this.wsHub.broadcast({
                type: 'folder_message',
                repo: folder,
                message: msg,
                timestamp: new Date().toISOString(),
            });
        }
    }
}
//# sourceMappingURL=heartbeat.js.map