import fs from "node:fs";
import path from "node:path";
const MAX_MEMORY_CONTENT_BYTES = 8192;
const DEFAULT_INDEX_MAX_ENTRIES = 30;
const DEFAULT_INDEX_MAX_BYTES = 6144;
const DEFAULT_RELEVANT_MAX_FILES = 5;
const DEFAULT_RELEVANT_MAX_TOTAL_BYTES = 10 * 1024;
const DEFAULT_RELEVANT_PER_FILE_BYTES = 3 * 1024;
/**
 * Normalize a memory key into the on-disk file basename. Strips any trailing
 * `.md` or `_md` suffixes the LLM may have included, then sanitizes
 * characters. Without the strip, `key="foo.md"` produced `foo_md.md`, and on
 * the next call `key="foo_md.md"` produced `foo_md_md.md` etc.
 */
export function keyToSafe(key) {
    let cleaned = key;
    while (/(\.md|_md)$/i.test(cleaned)) {
        cleaned = cleaned.replace(/(\.md|_md)$/i, "");
    }
    return cleaned.replace(/[^a-zA-Z0-9_-]/g, "_");
}
/** Keys that look like daily/per-run logs — rejected at save time. */
function isDateLikeKey(safeKey) {
    return (/\d{8}/.test(safeKey) || // YYYYMMDD anywhere
        /\d{4}-\d{2}-\d{2}/.test(safeKey) || // YYYY-MM-DD
        /-\d{10,}$/.test(safeKey) // unix timestamp suffix (seconds or ms)
    );
}
function byteLength(s) {
    return Buffer.byteLength(s, "utf-8");
}
function truncateToBytes(s, maxBytes) {
    if (maxBytes <= 0)
        return "";
    if (byteLength(s) <= maxBytes)
        return s;
    let out = "";
    let used = 0;
    for (const ch of s) {
        const next = byteLength(ch);
        if (used + next > maxBytes)
            break;
        out += ch;
        used += next;
    }
    return out;
}
function appendNoteWithinBudget(body, note, maxBytes) {
    const sep = body ? "\n\n" : "";
    const full = `${body}${sep}${note}`;
    if (byteLength(full) <= maxBytes)
        return full;
    const noteBytes = byteLength(note);
    const sepBytes = body ? byteLength(sep) : 0;
    const bodyBudget = maxBytes - noteBytes - sepBytes;
    if (bodyBudget <= 0) {
        return truncateToBytes(note, maxBytes);
    }
    return `${truncateToBytes(body, bodyBudget)}${sep}${note}`;
}
export class MemoryManager {
    memoryDir;
    indexFile;
    constructor(workspace) {
        this.memoryDir = path.join(workspace, "memory");
        this.indexFile = path.join(this.memoryDir, "MEMORY.md");
        fs.mkdirSync(this.memoryDir, { recursive: true });
        this.migrateMdSuffixDuplicates();
    }
    /** Load the MEMORY.md index content */
    loadIndex() {
        if (!fs.existsSync(this.indexFile)) {
            return "";
        }
        return fs.readFileSync(this.indexFile, "utf-8");
    }
    /** Load a recent, byte-bounded MEMORY.md index for prompt injection. */
    loadIndexCapped(opts = {}) {
        const maxEntries = opts.maxEntries ?? DEFAULT_INDEX_MAX_ENTRIES;
        const maxBytes = opts.maxBytes ?? DEFAULT_INDEX_MAX_BYTES;
        const index = this.loadIndex();
        if (!index.trim() || maxBytes <= 0 || maxEntries <= 0) {
            return "";
        }
        const { headerLines, entries } = this.parseIndex(index);
        if (entries.length === 0) {
            return truncateToBytes(index, maxBytes);
        }
        const sorted = [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs);
        let shown = sorted.slice(0, maxEntries);
        let rendered = this.renderIndex(headerLines, shown, entries.length - shown.length);
        while (shown.length > 0 && byteLength(rendered) > maxBytes) {
            shown = shown.slice(0, -1);
            rendered = this.renderIndex(headerLines, shown, entries.length - shown.length);
        }
        return truncateToBytes(rendered, maxBytes);
    }
    /** Load memories relevant to a given context (task name, URL, etc.) */
    loadRelevantMemories(keywords, opts = {}) {
        const maxFiles = opts.maxFiles ?? DEFAULT_RELEVANT_MAX_FILES;
        const maxTotalBytes = opts.maxTotalBytes ?? DEFAULT_RELEVANT_MAX_TOTAL_BYTES;
        const perFileBytes = opts.perFileBytes ?? DEFAULT_RELEVANT_PER_FILE_BYTES;
        if (maxFiles <= 0 || maxTotalBytes <= 0 || perFileBytes <= 0) {
            return "";
        }
        const normalizedKeywords = [
            ...new Set(keywords
                .map((kw) => kw.toLowerCase().replace(/[^a-z0-9_-]/g, "").trim())
                .filter((kw) => kw.length > 3)),
        ];
        if (normalizedKeywords.length === 0)
            return "";
        const files = this.listMemoryFiles();
        const matches = files
            .map((file) => {
            const name = path.basename(file, ".md").toLowerCase();
            const score = normalizedKeywords.filter((kw) => name.includes(kw)).length;
            if (score === 0)
                return null;
            const filePath = path.join(this.memoryDir, file);
            const mtimeMs = fs.statSync(filePath).mtimeMs;
            return { file, filePath, key: path.basename(file, ".md"), score, mtimeMs };
        })
            .filter((m) => m !== null)
            .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
        const blocks = [];
        for (const match of matches) {
            if (blocks.length >= maxFiles)
                break;
            let content = fs.readFileSync(match.filePath, "utf-8");
            if (byteLength(content) > perFileBytes) {
                content =
                    truncateToBytes(content, perFileBytes) +
                        `\n\n... [truncated; call memory.read("${match.key}") for full]`;
            }
            const block = `## ${match.file}\n${content}`;
            const candidate = blocks.length > 0
                ? `${blocks.join("\n\n")}\n\n${block}`
                : block;
            if (byteLength(candidate) > maxTotalBytes)
                break;
            blocks.push(block);
        }
        const moreMatches = matches.length - blocks.length;
        const body = blocks.join("\n\n");
        if (moreMatches <= 0)
            return body;
        const note = `... (${moreMatches} more matching memories not loaded; use memory.list to see all keys and memory.read("key") to fetch full content).`;
        return appendNoteWithinBudget(body, note, maxTotalBytes);
    }
    /**
     * Save a memory file and update the index. Scheduled tasks should NOT call
     * this directly — task-runner rejects memory.save at the dispatch layer and
     * funnels structured cross-run state to the store tool instead.
     */
    save(key, title, content) {
        const safeKey = keyToSafe(key);
        if (!safeKey) {
            return `Memory rejected: empty key after normalization.`;
        }
        if (isDateLikeKey(safeKey)) {
            return `Memory rejected: key "${key}" looks date- or timestamp-based. Memory is for stable, reusable knowledge — not per-day logs. Use a stable key (e.g. "task1-rules", "x-login-flow") and overwrite it. For run-by-run results, rely on the execution logger instead of memory.`;
        }
        const contentBytes = byteLength(content);
        if (contentBytes > MAX_MEMORY_CONTENT_BYTES) {
            return `Memory rejected: content is ${contentBytes} bytes, cap is ${MAX_MEMORY_CONTENT_BYTES}. Summarize, split into multiple stable keys, or for growing lists keep only the most recent N items.`;
        }
        const filePath = path.join(this.memoryDir, `${safeKey}.md`);
        const fileContent = `---
name: ${safeKey}
title: ${title}
updated: ${new Date().toISOString()}
---

${content}
`;
        fs.writeFileSync(filePath, fileContent, "utf-8");
        // Update index
        this.updateIndex(safeKey, title);
        return `Memory saved: ${safeKey}`;
    }
    /** Read a specific memory file */
    read(key) {
        const safeKey = keyToSafe(key);
        const filePath = path.join(this.memoryDir, `${safeKey}.md`);
        if (!fs.existsSync(filePath)) {
            return `Memory '${key}' not found.`;
        }
        return fs.readFileSync(filePath, "utf-8");
    }
    /**
     * One-time cleanup: prior to the keyToSafe fix, an LLM passing key="foo.md"
     * produced `foo_md.md`, then `foo_md_md.md` on next call, etc. Detect these
     * `(_md)+\.md$` filenames, merge them with the canonical `foo.md` file
     * (keeping the newest mtime), and prune dead lines from MEMORY.md.
     *
     * Idempotent: a clean directory makes this a no-op.
     */
    migrateMdSuffixDuplicates() {
        let files;
        try {
            files = fs.readdirSync(this.memoryDir);
        }
        catch {
            return;
        }
        // canonicalBase ("foo") -> list of {file, mtimeMs}
        const groups = new Map();
        for (const f of files) {
            if (!f.endsWith(".md") || f === "MEMORY.md")
                continue;
            const base = f.slice(0, -3); // strip ".md"
            const canonical = base.replace(/(_md)+$/i, "");
            if (canonical === base)
                continue; // not a suffix-bug victim, but the canonical sibling may exist
            const stat = fs.statSync(path.join(this.memoryDir, f));
            const list = groups.get(canonical) ?? [];
            list.push({ file: f, mtimeMs: stat.mtimeMs });
            groups.set(canonical, list);
        }
        // Add canonical sibling (if it exists) into each group so the merge picks
        // the freshest file across "foo.md" + "foo_md.md" + "foo_md_md.md".
        for (const [canonical, list] of groups) {
            const canonicalFile = `${canonical}.md`;
            const canonicalPath = path.join(this.memoryDir, canonicalFile);
            if (fs.existsSync(canonicalPath)) {
                const stat = fs.statSync(canonicalPath);
                list.push({ file: canonicalFile, mtimeMs: stat.mtimeMs });
            }
        }
        const archived = [];
        const archiveDir = path.join(this.memoryDir, "_archive_md_duplicates");
        fs.mkdirSync(archiveDir, { recursive: true });
        for (const [canonical, list] of groups) {
            list.sort((a, b) => b.mtimeMs - a.mtimeMs);
            const winner = list[0];
            const canonicalFile = `${canonical}.md`;
            const canonicalPath = path.join(this.memoryDir, canonicalFile);
            if (winner.file !== canonicalFile) {
                // The winner is an `_md` sibling that's about to overwrite canonical.
                // If canonical exists and is part of the group (i.e. lost the mtime
                // race), archive its current content first so we never silently
                // destroy a file that simply lost the race.
                const canonicalLost = list.some((e) => e.file === canonicalFile);
                if (canonicalLost && fs.existsSync(canonicalPath)) {
                    try {
                        const destPath = this.nextArchivePath(archiveDir, canonicalFile);
                        fs.copyFileSync(canonicalPath, destPath);
                        archived.push(canonicalFile);
                    }
                    catch {
                        // best-effort
                    }
                }
                fs.copyFileSync(path.join(this.memoryDir, winner.file), canonicalPath);
            }
            for (const entry of list) {
                if (entry.file === canonicalFile)
                    continue;
                try {
                    const sourcePath = path.join(this.memoryDir, entry.file);
                    const destPath = this.nextArchivePath(archiveDir, entry.file);
                    fs.renameSync(sourcePath, destPath);
                    archived.push(entry.file);
                }
                catch {
                    // best-effort
                }
            }
        }
        if (archived.length > 0) {
            // For each canonical file that now exists, ensure MEMORY.md has an
            // up-to-date line pointing at it (using the survivor's frontmatter
            // title), then drop any remaining lines pointing at deleted files.
            for (const canonical of groups.keys()) {
                const canonicalFile = `${canonical}.md`;
                const canonicalPath = path.join(this.memoryDir, canonicalFile);
                if (!fs.existsSync(canonicalPath))
                    continue;
                const title = this.readFrontmatterTitle(canonicalPath) || canonical;
                this.updateIndex(canonical, title);
            }
            this.pruneIndexOfMissingFiles();
            console.log(`[memory] archived ${archived.length} duplicate file(s) caused by .md key bug to ${archiveDir}: ${archived.join(", ")}`);
        }
    }
    nextArchivePath(archiveDir, file) {
        const parsed = path.parse(file);
        let dest = path.join(archiveDir, file);
        let i = 1;
        while (fs.existsSync(dest)) {
            dest = path.join(archiveDir, `${parsed.name}.${i}${parsed.ext}`);
            i++;
        }
        return dest;
    }
    /** Read the `title:` line from a memory file's YAML frontmatter, if present. */
    readFrontmatterTitle(filePath) {
        try {
            const head = fs.readFileSync(filePath, "utf-8").slice(0, 2048);
            if (!head.startsWith("---"))
                return null;
            const end = head.indexOf("\n---", 3);
            if (end < 0)
                return null;
            const block = head.slice(3, end);
            const m = block.match(/^title:\s*(.+)$/m);
            return m ? m[1].trim() : null;
        }
        catch {
            return null;
        }
    }
    /** Drop lines from MEMORY.md that point to files that no longer exist. */
    pruneIndexOfMissingFiles() {
        if (!fs.existsSync(this.indexFile))
            return;
        const lines = fs.readFileSync(this.indexFile, "utf-8").split("\n");
        const kept = [];
        for (const line of lines) {
            const m = line.match(/\]\(([^)]+\.md)\)/);
            if (m) {
                const target = path.join(this.memoryDir, m[1]);
                if (!fs.existsSync(target))
                    continue;
            }
            kept.push(line);
        }
        fs.writeFileSync(this.indexFile, kept.join("\n"), "utf-8");
    }
    /** List all memory files */
    list() {
        return this.loadIndex() || "(no memories saved)";
    }
    listMemoryFiles() {
        if (!fs.existsSync(this.memoryDir))
            return [];
        return fs
            .readdirSync(this.memoryDir)
            .filter((f) => f.endsWith(".md") && f !== "MEMORY.md");
    }
    parseIndex(index) {
        const headerLines = [];
        const entries = [];
        let seenEntry = false;
        for (const line of index.split("\n")) {
            const m = line.match(/^- \[[^\]]*\]\(([^)]+\.md)\)\s*$/);
            if (!m) {
                if (!seenEntry)
                    headerLines.push(line);
                continue;
            }
            seenEntry = true;
            const file = m[1];
            const filePath = path.join(this.memoryDir, file);
            const mtimeMs = fs.existsSync(filePath) ? fs.statSync(filePath).mtimeMs : 0;
            entries.push({ line, file, mtimeMs });
        }
        return { headerLines, entries };
    }
    renderIndex(headerLines, entries, hiddenCount) {
        const out = [...headerLines];
        while (out.length > 0 && out[out.length - 1].trim() === "") {
            out.pop();
        }
        if (entries.length > 0) {
            if (out.length > 0)
                out.push("");
            out.push(...entries.map((entry) => entry.line));
        }
        if (hiddenCount > 0) {
            out.push("", `... and ${hiddenCount} older memories not shown (use memory.list to see all keys and memory.read("key") to load any full memory).`);
        }
        return out.join("\n");
    }
    updateIndex(key, title) {
        let index = this.loadIndex();
        if (!index.trim()) {
            index = "# Agent Memory\n\n";
        }
        const lines = index.split("\n");
        const entryLine = `- [${title}](${key}.md)`;
        // Check if entry already exists and update it
        const existingIdx = lines.findIndex((l) => l.includes(`](${key}.md)`));
        if (existingIdx >= 0) {
            lines[existingIdx] = entryLine;
        }
        else {
            // Add after header
            const headerEnd = lines.findIndex((l, i) => i > 0 && l.trim() === "");
            if (headerEnd >= 0) {
                lines.splice(headerEnd + 1, 0, entryLine);
            }
            else {
                lines.push(entryLine);
            }
        }
        fs.writeFileSync(this.indexFile, lines.join("\n"), "utf-8");
    }
}
//# sourceMappingURL=memory.js.map