#!/usr/bin/env node
/**
 * One-shot migration from legacy memory history files to the structured
 * `store` tool. Run once per workspace at deploy time.
 *
 * Usage (after `npm run build`):
 *   node dist/scripts/migrate-history-to-store.js <workspace-path> [...more]
 *
 * Example on dipcoin host:
 *   ssh dipcoin@... \
 *     "node /opt/homebrew/lib/node_modules/dipclaw/dist/scripts/migrate-history-to-store.js \
 *       /Users/dipcoin/dipclaw/workspace-dipcoin001 \
 *       /Users/dipcoin/dipclaw/workspace-dipcoin002"
 *
 * For each workspace:
 *   1. Scans memory/x-quoted-history*.md  → store collection "x-quoted"
 *   2. Scans memory/x-replied-history*.md → store collection "x-replied"
 *   3. Extracts statusIds via /status/(\d{15,20}) regex.
 *   4. Bulk-inserts unique ids into the matching store collection.
 *   5. Archives the originals to memory/_archive-pre-store/<timestamp>/.
 *
 * Safe to re-run: archived files are no longer scanned; if a re-run finds
 * no source files it exits with "nothing to migrate".
 */
import fs from "node:fs";
import path from "node:path";
import { StoreManager } from "../agent/store.js";
const SPECS = [
    {
        glob: /^x-quoted-history(?:-\d+)?\.md$/,
        collection: "x-quoted",
        label: "quoted",
    },
    {
        glob: /^x-replied-history(?:-\d+)?\.md$/,
        collection: "x-replied",
        label: "replied",
    },
];
/**
 * Patterns the migration recognizes as statusId carriers in legacy
 * markdown files. Real production files mix several shapes:
 *
 *   - URL form:           `https://x.com/<user>/status/2057919297909916052`
 *   - Key form:           `statusId: 2057919297909916052`
 *   - Plain bullet:       `- 2057919297909916052 — @user — 2026-05-22 — ...`
 *   - Pipe-delimited:     `- 2057919297909916052 | user | 2026-05-22 | ...`
 *   - Date-first:         `- 2026-05-26 | 2032758876962242796 | @user | ...`
 *
 * X statusIds are decimal strings 15–20 digits long. The "bare" pattern
 * uses word boundaries so it won't fragment longer runs of digits.
 *
 * Each pattern's capture group 1 is the id. We iterate all patterns and
 * union the results into a Set, so a file containing more than one shape
 * still gets fully captured.
 */
const STATUS_ID_PATTERNS = [
    /(?:\/status\/|statusId[:\s"']*)(\d{15,20})/g,
    /\b(\d{15,20})\b/g,
];
/**
 * Files this large or larger with zero extracted ids are treated as a
 * format-drift failure: source has content, regex missed everything,
 * archiving would silently lose structured history. Pure-frontmatter stubs
 * (e.g. `--- name: ... ---\n`) sit well under this.
 */
const SUSPICIOUS_BYTE_THRESHOLD = 200;
function listMemoryFiles(memoryDir) {
    if (!fs.existsSync(memoryDir))
        return [];
    return fs
        .readdirSync(memoryDir)
        .filter((f) => f.endsWith(".md") && f !== "MEMORY.md");
}
function extractStatusIds(content) {
    const ids = new Set();
    for (const re of STATUS_ID_PATTERNS) {
        re.lastIndex = 0;
        let m;
        while ((m = re.exec(content)) !== null) {
            ids.add(m[1]);
        }
    }
    return Array.from(ids);
}
function migrateWorkspace(workspace) {
    const memoryDir = path.join(workspace, "memory");
    const report = {
        workspace,
        sourceFiles: [],
        perCollection: [],
        archived: null,
        suspicious: [],
    };
    if (!fs.existsSync(memoryDir)) {
        report.skipped = "no memory/ directory";
        return report;
    }
    const files = listMemoryFiles(memoryDir);
    const sourcesToArchive = [];
    // Group source files by target collection.
    const bySpec = new Map();
    for (const spec of SPECS)
        bySpec.set(spec, []);
    for (const f of files) {
        for (const spec of SPECS) {
            if (spec.glob.test(f)) {
                bySpec.get(spec).push(f);
                sourcesToArchive.push(f);
                report.sourceFiles.push(f);
                break;
            }
        }
    }
    if (sourcesToArchive.length === 0) {
        report.skipped = "no legacy history files";
        return report;
    }
    // --- pass 1: per-file extraction, check format drift before touching disk ---
    // We extract from each source file individually so a single file that the
    // regex can't parse fails the whole workspace migration. The alternative
    // (aggregate across the collection's files) would let an empty file ride
    // on its siblings' ids and get archived anyway — exactly the silent-loss
    // case the migration is meant to prevent.
    const perFileIds = new Map();
    for (const f of sourcesToArchive) {
        const filePath = path.join(memoryDir, f);
        const content = fs.readFileSync(filePath, "utf-8");
        const ids = extractStatusIds(content);
        perFileIds.set(f, ids);
        const bytes = Buffer.byteLength(content, "utf-8");
        if (ids.length === 0 && bytes >= SUSPICIOUS_BYTE_THRESHOLD) {
            report.suspicious.push({ file: f, bytes });
        }
    }
    if (report.suspicious.length > 0) {
        // Bail out before writing anything or archiving. The operator must
        // inspect the source files and either update the regex / SPECS or
        // hand-migrate before re-running.
        report.skipped =
            `format-drift detected — ${report.suspicious.length} source file(s) have content but yielded zero statusIds. ` +
                `No writes, no archiving. Inspect the files listed under "suspicious" and either ` +
                `update extractStatusIds() / SPECS, or hand-migrate, then re-run.`;
        return report;
    }
    // --- pass 2: bulk write to store ---
    const store = new StoreManager(workspace);
    for (const [spec, fileList] of bySpec) {
        if (fileList.length === 0)
            continue;
        const allIds = new Set();
        for (const f of fileList) {
            for (const id of perFileIds.get(f) ?? [])
                allIds.add(id);
        }
        const entries = Array.from(allIds).map((id) => ({
            id,
            meta: { source: "legacy-migration", from: fileList.join(",") },
        }));
        const result = store.bulkPut(spec.collection, entries);
        report.perCollection.push({
            collection: spec.collection,
            uniqueIds: allIds.size,
            ...result,
        });
    }
    // Refuse to archive if any collection had even one rejection. Reasons
    // bulkPut can reject: id too long, MAX_ENTRIES_PER_COLLECTION cap reached,
    // empty id. If we archive anyway, the rejected ids are lost from the
    // structured history and the LLM will repeat work on them. Better to bail and
    // make the operator deal with it.
    const partial = report.perCollection.filter((c) => c.rejected > 0);
    if (partial.length > 0) {
        report.skipped =
            `partial-write detected — ${partial.length} collection(s) rejected some entries during bulkPut. ` +
                `No archiving. Inspect collection caps, id length, and re-run after raising the cap or pruning.`;
        return report;
    }
    // Archive originals once data has been written.
    const archiveDir = path.join(memoryDir, "_archive-pre-store", new Date().toISOString().replace(/[:.]/g, "-"));
    fs.mkdirSync(archiveDir, { recursive: true });
    for (const f of sourcesToArchive) {
        const src = path.join(memoryDir, f);
        const dst = path.join(archiveDir, f);
        fs.renameSync(src, dst);
    }
    report.archived = archiveDir;
    // Also prune those entries from MEMORY.md index so the LLM stops seeing them.
    const indexPath = path.join(memoryDir, "MEMORY.md");
    if (fs.existsSync(indexPath)) {
        const archivedSet = new Set(sourcesToArchive);
        const lines = fs.readFileSync(indexPath, "utf-8").split("\n");
        const kept = lines.filter((line) => {
            const m = line.match(/\]\(([^)]+\.md)\)/);
            if (!m)
                return true;
            return !archivedSet.has(m[1]);
        });
        fs.writeFileSync(indexPath, kept.join("\n"), "utf-8");
    }
    return report;
}
function main() {
    const args = process.argv.slice(2);
    if (args.length === 0) {
        console.error("Usage: node migrate-history-to-store.js <workspace-path> [<workspace-path> ...]");
        process.exit(2);
    }
    for (const ws of args) {
        const abs = path.resolve(ws);
        console.log(`\n=== ${abs} ===`);
        try {
            const report = migrateWorkspace(abs);
            if (report.suspicious.length > 0) {
                console.error(`  ABORTED — format drift suspected:`);
                for (const s of report.suspicious) {
                    console.error(`    ${s.file}  (${s.bytes} bytes, 0 statusIds extracted)`);
                }
                console.error(`  ${report.skipped}`);
                process.exitCode = 1;
                continue;
            }
            const rejectedCollections = report.perCollection.filter((c) => c.rejected > 0);
            if (rejectedCollections.length > 0) {
                console.error(`  ABORTED — partial-write detected:`);
                for (const c of rejectedCollections) {
                    console.error(`    ${c.collection}: ${c.uniqueIds} unique ids found; added=${c.added} updated=${c.updated} rejected=${c.rejected}`);
                }
                console.error(`  ${report.skipped}`);
                process.exitCode = 1;
                continue;
            }
            if (report.skipped) {
                console.log(`  skipped: ${report.skipped}`);
                continue;
            }
            console.log(`  source files: ${report.sourceFiles.join(", ")}`);
            for (const c of report.perCollection) {
                console.log(`  ${c.collection}: ${c.uniqueIds} unique ids found; added=${c.added} updated=${c.updated} rejected=${c.rejected}`);
            }
            console.log(`  archived to: ${report.archived}`);
        }
        catch (err) {
            console.error(`  FAILED: ${err.message}`);
            process.exitCode = 1;
        }
    }
}
main();
//# sourceMappingURL=migrate-history-to-store.js.map