/**
 * Conversation Buffer - Rolling verbatim buffer of recent conversation.
 *
 * Tier 1 of the two-tier memory system:
 *   - Tier 1 (this): Last N characters of conversation stored verbatim.
 *     Provides perfect continuity across sessions for in-progress work,
 *     stories, code discussions, etc.
 *   - Tier 2: Compressed long-term memories (memory.jsonl via MemoryManager).
 *
 * The buffer spans across sessions (not per-session). When new content pushes
 * old content out of the window, the evicted text is available for compression
 * into Tier 2 summaries.
 *
 * Storage: ~/.avis/self/memory/conversation-buffer.json
 */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { getConversationBufferPath } from "../config.js";
import { vault } from "./vault.js";
// ============================================================================
// ConversationBuffer
// ============================================================================
export const DEFAULT_BUFFER_SIZE = 5000;
export class ConversationBuffer {
    /** Interval (ms) for flushing partial streaming content to disk */
    static { this.PARTIAL_FLUSH_INTERVAL = 2000; }
    constructor(options) {
        this._state = null;
        /** Partial streaming entry that hasn't been finalized yet */
        this._pendingPartial = null;
        /** Debounce timer for flushing partial content */
        this._partialFlushTimer = null;
        this._filePath = options?.filePath ?? getConversationBufferPath();
        this._maxChars = options?.maxChars ?? DEFAULT_BUFFER_SIZE;
    }
    /** Update the max buffer size (e.g., from settings change) */
    set maxChars(value) {
        this._maxChars = value;
        // Trim if needed
        const state = this._load();
        this._trimToSize(state);
        this._save(state);
    }
    get maxChars() {
        return this._maxChars;
    }
    /**
     * Append a conversation entry to the buffer.
     * Returns any text that was evicted from the buffer (for Tier 2 compression).
     */
    append(role, text) {
        const state = this._load();
        const entry = {
            role,
            text,
            timestamp: new Date().toISOString(),
        };
        state.entries.push(entry);
        state.totalChars += text.length;
        state.lastUpdated = entry.timestamp;
        // Trim and collect evicted entries
        const evicted = this._trimToSize(state);
        this._save(state);
        return evicted;
    }
    /** Get the current buffer entries */
    getEntries() {
        return this._load().entries;
    }
    /** Get total chars in the buffer */
    getTotalChars() {
        return this._load().totalChars;
    }
    /**
     * Format the buffer for injection into the system prompt.
     * Returns empty string if buffer is empty.
     */
    formatForSystemPrompt() {
        const entries = this._load().entries;
        if (entries.length === 0)
            return "";
        // Compute temporal context
        const now = new Date();
        const firstTs = entries[0]?.timestamp ? new Date(entries[0].timestamp) : null;
        const lastTs = entries[entries.length - 1]?.timestamp ? new Date(entries[entries.length - 1].timestamp) : null;
        const timeSinceLast = lastTs ? Math.round((now.getTime() - lastTs.getTime()) / 1000) : null;
        const lines = [
            "\n\n# Recent Conversation (verbatim)",
            "\nLast conversation exchanges for continuity. Use this to seamlessly continue in-progress work:\n",
        ];
        // Add temporal context header
        if (timeSinceLast !== null) {
            const gap = ConversationBuffer._formatDuration(timeSinceLast);
            lines.push(`_Time since last message: ${gap}_`);
            if (firstTs && lastTs && firstTs.getTime() !== lastTs.getTime()) {
                const span = ConversationBuffer._formatDuration(Math.round((lastTs.getTime() - firstTs.getTime()) / 1000));
                lines.push(`_Conversation span in buffer: ${span}_\n`);
            }
        }
        for (const entry of entries) {
            const label = entry.role === "user" ? "User" : "Avis";
            // Format timestamp as short time (e.g., "07:31 PM")
            const timeStr = entry.timestamp
                ? new Date(entry.timestamp).toLocaleTimeString("en-US", {
                    hour: "2-digit",
                    minute: "2-digit",
                    hour12: true,
                })
                : "";
            const timeTag = timeStr ? ` [${timeStr}]` : "";
            // Truncate very long entries in the prompt representation
            const displayText = entry.text.length > 2000 ? `${entry.text.slice(0, 2000)}\n[... truncated]` : entry.text;
            lines.push(`**${label}:**${timeTag} ${displayText}`);
        }
        return lines.join("\n");
    }
    /**
     * Update partial streaming content (call on every message_update).
     * This buffers the partial text in memory and periodically flushes to disk,
     * so if the process dies mid-stream, the last flushed partial is preserved.
     */
    updatePartial(role, text) {
        this._pendingPartial = { role, text };
        // Debounced flush -- write to disk at most every N seconds
        if (!this._partialFlushTimer) {
            this._partialFlushTimer = setTimeout(() => {
                this._flushPartial();
                this._partialFlushTimer = null;
            }, ConversationBuffer.PARTIAL_FLUSH_INTERVAL);
        }
    }
    /**
     * Finalize the partial entry (call on message_end).
     * Clears the pending partial since append() will store the final version.
     */
    finalizePartial() {
        this._pendingPartial = null;
        if (this._partialFlushTimer) {
            clearTimeout(this._partialFlushTimer);
            this._partialFlushTimer = null;
        }
        // Remove the partial entry from disk state if it was flushed
        const state = this._load();
        if (state.entries.length > 0) {
            const last = state.entries[state.entries.length - 1];
            if (last.timestamp === "partial") {
                state.totalChars -= last.text.length;
                state.entries.pop();
                this._save(state);
            }
        }
    }
    /** Flush pending partial to disk (overwrites any existing partial entry) */
    _flushPartial() {
        if (!this._pendingPartial)
            return;
        const state = this._load();
        // Remove previous partial entry if present
        if (state.entries.length > 0) {
            const last = state.entries[state.entries.length - 1];
            if (last.timestamp === "partial") {
                state.totalChars -= last.text.length;
                state.entries.pop();
            }
        }
        // Add current partial
        const entry = {
            role: this._pendingPartial.role,
            text: this._pendingPartial.text,
            timestamp: "partial", // sentinel value to identify partial entries
        };
        state.entries.push(entry);
        state.totalChars += entry.text.length;
        state.lastUpdated = new Date().toISOString();
        this._trimToSize(state);
        this._save(state);
    }
    /** Clear the buffer */
    clear() {
        this._pendingPartial = null;
        if (this._partialFlushTimer) {
            clearTimeout(this._partialFlushTimer);
            this._partialFlushTimer = null;
        }
        this._save({ entries: [], totalChars: 0, lastUpdated: new Date().toISOString() });
    }
    // =========================================================================
    // Internal
    // =========================================================================
    /**
     * Trim the buffer to fit within maxChars.
     * Removes oldest entries first. Returns the text of evicted entries.
     */
    _trimToSize(state) {
        const evicted = [];
        // Evict oldest entries until we fit, but always keep at least one
        while (state.totalChars > this._maxChars && state.entries.length > 1) {
            const removed = state.entries.shift();
            state.totalChars -= removed.text.length;
            evicted.push(`[${removed.role}] ${removed.text}`);
        }
        // Edge case: single entry larger than buffer - keep it but truncate
        if (state.entries.length === 1 && state.totalChars > this._maxChars) {
            const entry = state.entries[0];
            const overflow = state.totalChars - this._maxChars;
            // Keep the tail (most recent part)
            const evictedText = entry.text.slice(0, overflow);
            entry.text = entry.text.slice(overflow);
            state.totalChars = entry.text.length;
            if (evictedText) {
                evicted.push(`[${entry.role}] ${evictedText}`);
            }
        }
        return evicted;
    }
    _load() {
        if (this._state)
            return this._state;
        if (!existsSync(this._filePath)) {
            this._state = { entries: [], totalChars: 0, lastUpdated: "" };
            return this._state;
        }
        try {
            const raw = vault.ready ? vault.readFileSync(this._filePath) : readFileSync(this._filePath, "utf-8");
            this._state = JSON.parse(raw);
            return this._state;
        }
        catch {
            this._state = { entries: [], totalChars: 0, lastUpdated: "" };
            return this._state;
        }
    }
    /** Format seconds into a human-readable duration string */
    static _formatDuration(seconds) {
        if (seconds < 60)
            return `${seconds}s`;
        if (seconds < 3600) {
            const m = Math.floor(seconds / 60);
            const s = seconds % 60;
            return s > 0 ? `${m}m ${s}s` : `${m}m`;
        }
        if (seconds < 86400) {
            const h = Math.floor(seconds / 3600);
            const m = Math.floor((seconds % 3600) / 60);
            return m > 0 ? `${h}h ${m}m` : `${h}h`;
        }
        const d = Math.floor(seconds / 86400);
        const h = Math.floor((seconds % 86400) / 3600);
        return h > 0 ? `${d}d ${h}h` : `${d}d`;
    }
    _save(state) {
        this._state = state;
        try {
            const dir = dirname(this._filePath);
            if (!existsSync(dir))
                mkdirSync(dir, { recursive: true });
            if (vault.ready) {
                vault.writeFileSync(this._filePath, JSON.stringify(state, null, 2));
            }
            else {
                writeFileSync(this._filePath, JSON.stringify(state, null, 2));
            }
        }
        catch {
            // Best-effort
        }
    }
}
//# sourceMappingURL=conversation-buffer.js.map