/**
 * Convert restructured JSONL records to the OpenAI chat completion format.
 *
 * The output format uses an OpenAI chat-completion-style messages array:
 *   system → user → (assistant → tool)* → assistant
 *
 * Reads:
 *   <output-dir>/restructured-*.jsonl
 *   <output-dir>/workspace/system-prompt.md
 *
 * Writes:
 *   <out>/openai-chat-clean.jsonl
 *   <out>/openai-chat-interrupted.jsonl
 *   <out>/openai-chat-heartbeat.jsonl
 */
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { randomUUID } from "node:crypto";
// ---------------------------------------------------------------------------
// Turn grouping helpers
// ---------------------------------------------------------------------------
/**
 * Remove internal pipeline fields (`_archive_id`, `_recovery`, etc.) from
 * an image block so the output conforms to the OpenAI schema.
 */
function sanitizeImageBlock(block) {
    const clean = {};
    for (const [k, v] of Object.entries(block)) {
        if (k.startsWith("_"))
            continue; // strip internal fields
        clean[k] = v;
    }
    return clean;
}
/**
 * Strip internal pipeline fields from image blocks embedded inside a JSON
 * content-block string (e.g. tool_result content that was already serialised
 * with `_archive_id`/`_recovery` during the restructure stage).
 */
function sanitizeContentString(content) {
    const trimmed = content.trim();
    if (!trimmed.startsWith("["))
        return content;
    try {
        const blocks = JSON.parse(trimmed);
        if (!Array.isArray(blocks))
            return content;
        // Quick check: does any block have internal _-prefixed fields?
        const raw = JSON.stringify(blocks);
        if (!raw.includes('"_'))
            return content;
        const cleaned = blocks.map((block) => {
            if (typeof block !== "object" || block === null)
                return block;
            return sanitizeImageBlock(block);
        });
        return JSON.stringify(cleaned);
    }
    catch {
        return content;
    }
}
/**
 * Build content string, appending any image metadata as a JSON content-block
 * array so that image file references are preserved in the output.
 *
 * If the entry has `metadata.images`, the result is a JSON array of content
 * blocks (text + image), matching the multimodal format.  Otherwise the
 * plain text content is returned as-is.
 *
 * Internal pipeline fields (`_archive_id`, `_recovery`) are stripped from
 * all image blocks so the output conforms to the OpenAI chat schema.
 */
function buildContentWithImages(content, metadata) {
    const images = metadata?.images;
    if (!Array.isArray(images) || images.length === 0) {
        return sanitizeContentString(content);
    }
    const blocks = [];
    if (content) {
        blocks.push({ type: "text", text: content });
    }
    for (const img of images) {
        if (typeof img === "object" && img !== null) {
            blocks.push(sanitizeImageBlock(img));
        }
        else {
            blocks.push(img);
        }
    }
    return JSON.stringify(blocks);
}
/**
 * Group trajectory entries into chat-style turns.
 *
 * Grouping rules:
 *   - Consecutive `thinking`, `assistant`, `tool_call` entries are merged
 *     into a single `assistant` message.
 *   - Each `tool_result` becomes a separate `tool` message.
 *   - `user_message` entries become `user` messages.
 *   - `context` entries become `user` messages (system-injected context).
 */
function groupTrajectoryIntoMessages(trajectory) {
    const messages = [];
    // Accumulator for a single assistant turn
    let thinkingBuf = "";
    let contentBuf = "";
    let toolCalls = [];
    let toolCallCounter = 0;
    function flushAssistant() {
        if (!thinkingBuf && !contentBuf && toolCalls.length === 0)
            return;
        const msg = {
            role: "assistant",
            content: contentBuf,
        };
        if (thinkingBuf) {
            msg.reasoning_content = thinkingBuf;
        }
        if (toolCalls.length > 0) {
            msg.tool_calls = [...toolCalls];
        }
        messages.push(msg);
        thinkingBuf = "";
        contentBuf = "";
        toolCalls = [];
    }
    for (const entry of trajectory) {
        switch (entry.role) {
            case "thinking":
                // Thinking can appear before assistant/tool_call, accumulate
                if (thinkingBuf)
                    thinkingBuf += "\n";
                thinkingBuf += entry.content;
                break;
            case "assistant":
                // Assistant text content
                if (contentBuf)
                    contentBuf += "\n";
                contentBuf += entry.content;
                break;
            case "tool_call": {
                // Parse tool call metadata (keys: name, input, tool_use_id)
                const meta = entry.metadata ?? {};
                const toolName = meta.name ?? entry.content;
                const toolInput = meta.input !== undefined && meta.input !== null
                    ? (typeof meta.input === "string" ? meta.input : JSON.stringify(meta.input))
                    : "";
                const toolUseId = meta.tool_use_id ?? `call_${++toolCallCounter}`;
                toolCalls.push({
                    id: toolUseId,
                    type: "function",
                    function: {
                        name: toolName,
                        arguments: toolInput,
                    },
                });
                break;
            }
            case "tool_result": {
                // Flush pending assistant turn before adding tool result
                flushAssistant();
                const resultMeta = entry.metadata ?? {};
                const resultToolUseId = resultMeta.tool_use_id ?? `call_${toolCallCounter}`;
                messages.push({
                    role: "tool",
                    content: buildContentWithImages(entry.content, entry.metadata),
                    tool_call_id: resultToolUseId,
                });
                break;
            }
            case "user_message":
                // Flush any pending assistant turn
                flushAssistant();
                messages.push({
                    role: "user",
                    content: entry.content,
                });
                break;
            case "context":
                // Flush any pending assistant turn
                flushAssistant();
                messages.push({
                    role: "user",
                    content: buildContentWithImages(entry.content, entry.metadata),
                });
                break;
            default:
                // Unknown role — skip
                break;
        }
    }
    // Flush any remaining assistant content
    flushAssistant();
    return messages;
}
// ---------------------------------------------------------------------------
// Prior context → user message (with image stripping)
// ---------------------------------------------------------------------------
/**
 * Strip image blocks from a content string that may be a JSON content-block
 * array (e.g. `[{"type":"text","text":"..."}, {"type":"image",...}]`).
 *
 * Returns the text-only content.  If the content is not a JSON array or
 * contains no image blocks it is returned unchanged.
 */
function stripImagesFromContent(content) {
    const trimmed = content.trim();
    if (!trimmed.startsWith("["))
        return content;
    try {
        const blocks = JSON.parse(trimmed);
        if (!Array.isArray(blocks))
            return content;
        const textParts = [];
        let hadImage = false;
        for (const block of blocks) {
            if (typeof block !== "object" || block === null)
                continue;
            const b = block;
            if (b.type === "image") {
                hadImage = true;
                continue; // skip image blocks
            }
            if (b.type === "text" && typeof b.text === "string") {
                textParts.push(b.text);
            }
        }
        if (!hadImage)
            return content; // no images found, return original
        return textParts.join("\n");
    }
    catch {
        return content; // not valid JSON, return as-is
    }
}
function buildPriorContextContent(priorContext, prompt) {
    const parts = [];
    if (priorContext?.compaction_summary) {
        parts.push(`[Prior Context Summary]\n${priorContext.compaction_summary}`);
    }
    if (priorContext?.prior_messages && priorContext.prior_messages.length > 0) {
        const filtered = [];
        for (const m of priorContext.prior_messages) {
            const cleaned = stripImagesFromContent(m.content);
            if (cleaned.trim()) {
                filtered.push(`[${m.role}] ${cleaned}`);
            }
            // Drop entries that are empty after image stripping
        }
        if (filtered.length > 0) {
            parts.push(`[Prior Messages]\n${filtered.join("\n")}`);
        }
    }
    parts.push(prompt);
    return parts.join("\n\n");
}
// ---------------------------------------------------------------------------
// Per-record system prompt with resolved time
// ---------------------------------------------------------------------------
/**
 * Derive a human-readable time string and IANA-style timezone label from a
 * record's timestamp (e.g. "2026-03-22T10:05:12.614-07:00").
 */
function resolveTimeFromRecord(record) {
    const ts = record.prompt_meta.timestamp || record.wake_cycle_id;
    if (!ts) {
        return { timeStr: "[CURRENT_TIME]", tzLabel: "[TIMEZONE]" };
    }
    const d = new Date(ts);
    if (isNaN(d.getTime())) {
        return { timeStr: "[CURRENT_TIME]", tzLabel: "[TIMEZONE]" };
    }
    // Extract UTC offset from the original timestamp string (e.g. "-07:00", "+08:00", "Z")
    const offsetMatch = /([+-]\d{2}:\d{2}|Z)$/.exec(ts);
    let offsetMinutes = 0;
    let tzLabel = "UTC";
    if (offsetMatch) {
        const tz = offsetMatch[1];
        if (tz === "Z") {
            offsetMinutes = 0;
            tzLabel = "UTC";
        }
        else {
            const sign = tz.startsWith("-") ? -1 : 1;
            const h = Number(tz.slice(1, 3));
            const m = Number(tz.slice(4, 6));
            offsetMinutes = sign * (h * 60 + m);
            tzLabel = `UTC${tz}`;
        }
    }
    // Compute local time by applying offset to UTC
    const localMs = d.getTime() + offsetMinutes * 60000;
    const local = new Date(localMs);
    const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
    const months = ["January", "February", "March", "April", "May", "June",
        "July", "August", "September", "October", "November", "December"];
    const dayName = days[local.getUTCDay()];
    const monthName = months[local.getUTCMonth()];
    const day = local.getUTCDate();
    const year = local.getUTCFullYear();
    const hours = local.getUTCHours();
    const minutes = local.getUTCMinutes();
    const seconds = local.getUTCSeconds();
    const ampm = hours >= 12 ? "PM" : "AM";
    const h12 = hours % 12 || 12;
    const timeStr = `${dayName}, ${monthName} ${day}, ${year} at ${h12}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")} ${ampm} ${tzLabel}`;
    return { timeStr, tzLabel };
}
// ---------------------------------------------------------------------------
// Convert a single OutputRecord → ChatRecord
// ---------------------------------------------------------------------------
function convertRecord(record, fallbackSystemPrompt, seq) {
    const messages = [];
    // 1. System message — prefer per-record dynamic prompt, fall back to static file
    const basePrompt = record.system_prompt ?? fallbackSystemPrompt;
    const { timeStr, tzLabel } = resolveTimeFromRecord(record);
    const resolvedPrompt = basePrompt
        .replace("[CURRENT_TIME]", timeStr)
        .replace("[TIMEZONE]", tzLabel);
    messages.push({
        role: "system",
        content: resolvedPrompt,
    });
    // 2. User message (prior context + prompt)
    const userContent = buildPriorContextContent(record.prior_context, record.prompt);
    messages.push({
        role: "user",
        content: userContent,
    });
    // 3. Trajectory → grouped messages
    const trajectoryMessages = groupTrajectoryIntoMessages(record.trajectory);
    messages.push(...trajectoryMessages);
    // Build a unique uid: prefer wake_cycle_id but append a sequence number
    // to disambiguate multiple records from the same wake cycle (e.g. session
    // switches produce several records sharing one wake_cycle_id).
    let uid;
    if (record.wake_cycle_id) {
        uid = seq === 0 ? record.wake_cycle_id : `${record.wake_cycle_id}#${seq}`;
    }
    else {
        uid = randomUUID();
    }
    return {
        uid,
        agent_type: "main",
        subagent_index: null,
        prompt: record.prompt,
        success: record.complete,
        messages,
    };
}
async function processJsonlFile(inputPath, outputPath, systemPrompt, maxSamples = 3) {
    if (!fs.existsSync(inputPath)) {
        // Always write an empty file for consistency
        fs.writeFileSync(outputPath, "");
        return { count: 0, samples: [] };
    }
    const rl = readline.createInterface({
        input: fs.createReadStream(inputPath),
        crlfDelay: Infinity,
    });
    const outStream = fs.createWriteStream(outputPath);
    let count = 0;
    const samples = [];
    const uidSeqMap = new Map();
    for await (const line of rl) {
        if (!line.trim())
            continue;
        try {
            const record = JSON.parse(line);
            // Track how many records share the same wake_cycle_id to generate unique uids
            const wcId = record.wake_cycle_id ?? "";
            const seq = uidSeqMap.get(wcId) ?? 0;
            uidSeqMap.set(wcId, seq + 1);
            const chatRec = convertRecord(record, systemPrompt, seq);
            outStream.write(JSON.stringify(chatRec) + "\n");
            if (samples.length < maxSamples) {
                samples.push(chatRec);
            }
            count++;
        }
        catch {
            // Skip malformed lines
        }
    }
    outStream.end();
    await new Promise((resolve) => {
        outStream.on("close", resolve);
    });
    return { count, samples };
}
export async function convertOpenaiChat(opts) {
    const { outputDir } = opts;
    const chatOutputDir = opts.chatOutputDir ?? outputDir;
    // Read system prompt
    const systemPromptPath = path.join(outputDir, "workspace", "system-prompt.md");
    if (!fs.existsSync(systemPromptPath)) {
        console.error(`System prompt not found: ${systemPromptPath}`);
        console.error("Run the pipeline with system-prompt export first.");
        process.exit(1);
    }
    const systemPrompt = fs.readFileSync(systemPromptPath, "utf-8");
    fs.mkdirSync(chatOutputDir, { recursive: true });
    // Convert each category
    const categories = ["clean", "interrupted", "incomplete", "heartbeat"];
    let totalRecords = 0;
    const allSamples = [];
    for (const cat of categories) {
        const inputFile = path.join(outputDir, `restructured-${cat}.jsonl`);
        const outputFile = path.join(chatOutputDir, `openai-chat-${cat}.jsonl`);
        const result = await processJsonlFile(inputFile, outputFile, systemPrompt);
        console.log(`  ${path.basename(outputFile)}: ${result.count} records`);
        totalRecords += result.count;
        if (result.samples.length > 0) {
            allSamples.push(...result.samples);
            const samplesFile = path.join(chatOutputDir, `openai-chat-samples-${cat}.json`);
            fs.writeFileSync(samplesFile, JSON.stringify(result.samples, null, 2));
            console.log(`  ${path.basename(samplesFile)}: ${result.samples.length} samples`);
        }
    }
    // Write combined samples
    if (allSamples.length > 0) {
        const combinedSamples = allSamples.slice(0, 3);
        const combinedPath = path.join(chatOutputDir, "openai-chat-samples-all.json");
        fs.writeFileSync(combinedPath, JSON.stringify(combinedSamples, null, 2));
        console.log(`  ${path.basename(combinedPath)}: ${combinedSamples.length} samples`);
    }
    // Prune images not referenced by any openai-chat JSONL file
    const imagesDir = path.join(chatOutputDir, "images");
    if (fs.existsSync(imagesDir)) {
        const referencedImages = new Set();
        for (const cat of categories) {
            const chatFile = path.join(chatOutputDir, `openai-chat-${cat}.jsonl`);
            if (!fs.existsSync(chatFile))
                continue;
            const content = fs.readFileSync(chatFile, "utf-8");
            // Match local image paths: "path":"images/xxx.ext" (possibly JSON-escaped)
            // Handles 0, 1, or 2 backslashes before quotes for varying JSON escape depth
            // This avoids matching external URLs that happen to contain images/ in the path
            const matches = content.matchAll(/\\{0,2}"path\\{0,2}"\s*:\s*\\{0,2}"images\/([a-zA-Z0-9_.+-]+\.\w+)/g);
            for (const m of matches) {
                referencedImages.add(`images/${m[1]}`);
            }
        }
        const diskImages = fs.readdirSync(imagesDir);
        let pruned = 0;
        for (const img of diskImages) {
            if (!referencedImages.has(`images/${img}`)) {
                fs.unlinkSync(path.join(imagesDir, img));
                pruned++;
            }
        }
        if (pruned > 0) {
            console.log(`  Pruned ${pruned} unreferenced images (${diskImages.length - pruned} kept)`);
        }
    }
    console.log(`  Total: ${totalRecords} records`);
}
//# sourceMappingURL=convert-openai-chat.js.map