/**
 * Transcript-based structured memory: indexes wake cycle summaries into
 * a SQLite database with FTS5 full-text search and sqlite-vec vector
 * similarity search. Each wake cycle is summarized by an LLM after
 * completion and stored for later retrieval.
 *
 * IMPORTANT — file terminology:
 *  - "source file" (DB column `source_file`): the JSONL file that contains
 *    the raw data for a wake cycle. This is EITHER:
 *      • An engine transcript for live-indexed cycles, such as:
 *        - Claude SDK: `~/.claude/projects/<slug>/<sessionId>.jsonl`
 *        - Pi Coding Agent: `~/.visionclaw/.../pi-agent/<mode>/sessions/<sessionId>.jsonl`
 *      • A **VisionClaw runtime log** (`~/.visionclaw/profiles/<profile>/logs/YYYY-MM-DD.jsonl`)
 *        for backfill-indexed cycles.
 *    These formats differ:
 *      - Claude transcripts: `{type:"user"|"assistant", message:{...}}`
 *      - Pi Coding transcripts: `{type:"message", message:{role, content, ...}}`
 *      - VisionClaw logs: `{timestamp, level, category, message, data}`
 *
 * This module handles:
 *  - SQLite schema creation (wake_cycles + FTS5 + vec0 virtual tables)
 *  - Reading source file lines (both Claude transcripts and VisionClaw logs)
 *  - Generating summaries via Gemini Flash
 *  - Generating embeddings via Gemini Embedding
 *  - Inserting indexed records
 *  - Keyword (FTS5), semantic (vec0 KNN), and hybrid search
 *  - Retrieval of key excerpts and original source file lines
 */
import { DatabaseSync } from "node:sqlite";
import fs from "node:fs";
import readline from "node:readline";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { PassThrough } from "node:stream";
import path from "node:path";
import { GoogleGenAI } from "@google/genai";
import * as sqliteVec from "sqlite-vec";
import { getConfigDir } from "../../config/index.js";
import { resolveGeminiApiKey, GEMINI_SUMMARY_MODEL, GEMINI_EMBEDDING_MODEL, } from "../../gemini-credentials.js";
import { logger } from "../../logger.js";
import { runTranscriptMemoryMigrations } from "./transcript-memory-migrations.js";
import { deriveArchivePath } from "../session-trimmer.js";
import { getTranscriptParser, } from "../transcript-parsers/index.js";
function loadSqliteVec(db) {
    const moduleValue = sqliteVec;
    if (typeof moduleValue !== "object" ||
        moduleValue === null ||
        typeof moduleValue.load !== "function") {
        throw new Error("sqlite-vec module did not expose a callable load() function");
    }
    moduleValue.load(db);
}
// ---------------------------------------------------------------------------
// DB singleton
// ---------------------------------------------------------------------------
let db = null;
let stmtInsert = null;
let stmtInsertVec = null;
let stmtSearchKeyword = null;
let stmtSearchSemantic = null;
let stmtGetById = null;
let vecEnabled = false;
/** Embedding dimension for Gemini Embedding model */
const EMBEDDING_DIM = 768;
const EMBEDDING_BYTE_LENGTH = EMBEDDING_DIM * Float32Array.BYTES_PER_ELEMENT;
function requireStatement(stmt, name) {
    if (!stmt) {
        throw new Error(`Transcript memory statement not initialized: ${name}`);
    }
    return stmt;
}
function getDb() {
    if (db)
        return db;
    const dbPath = path.join(getConfigDir(), "transcript-memory.db");
    db = new DatabaseSync(dbPath, { allowExtension: true });
    db.exec("PRAGMA journal_mode = WAL");
    // Load sqlite-vec extension (best-effort — fall back to keyword-only)
    try {
        loadSqliteVec(db);
        vecEnabled = true;
    }
    catch (err) {
        logger.warn(`[transcript-indexer] sqlite-vec extension not loaded, semantic search disabled: ${err instanceof Error ? err.message : String(err)}`);
        vecEnabled = false;
    }
    // Main table
    db.exec(`
    CREATE TABLE IF NOT EXISTS wake_cycles (
      wake_cycle_id   TEXT PRIMARY KEY,
      session_type    TEXT NOT NULL,
      timestamp_start TEXT NOT NULL,
      timestamp_end   TEXT NOT NULL,
      summary         TEXT NOT NULL,
      key_excerpts    TEXT,
      source_file     TEXT NOT NULL,
      start_line      INTEGER NOT NULL,
      end_line        INTEGER NOT NULL,
      embedding       BLOB
    )
  `);
    // Migration: rename old column `transcript_file` → `source_file`
    // SQLite doesn't support RENAME COLUMN in all versions, but >=3.25.0 does.
    // If the old column exists, rename it.
    try {
        const cols = db.prepare("PRAGMA table_info(wake_cycles)").all();
        const hasOldCol = cols.some((c) => c.name === "transcript_file");
        if (hasOldCol) {
            db.exec("ALTER TABLE wake_cycles RENAME COLUMN transcript_file TO source_file");
            logger.system("[transcript-indexer] Migrated column transcript_file → source_file");
        }
    }
    catch {
        // best-effort — old DBs without the column or already migrated
    }
    // Run explicit SQL migrations (adds usage-reporting columns, etc.)
    runTranscriptMemoryMigrations(db);
    // FTS5 virtual table for keyword search.
    // Using content= external-content mode with the main table.
    db.exec(`
    CREATE VIRTUAL TABLE IF NOT EXISTS wake_cycles_fts USING fts5(
      wake_cycle_id,
      summary,
      content=wake_cycles,
      content_rowid=rowid
    )
  `);
    // Triggers to keep FTS5 in sync with the main table
    db.exec(`
    CREATE TRIGGER IF NOT EXISTS wake_cycles_ai AFTER INSERT ON wake_cycles BEGIN
      INSERT INTO wake_cycles_fts(rowid, wake_cycle_id, summary)
        VALUES (new.rowid, new.wake_cycle_id, new.summary);
    END
  `);
    db.exec(`
    CREATE TRIGGER IF NOT EXISTS wake_cycles_ad AFTER DELETE ON wake_cycles BEGIN
      INSERT INTO wake_cycles_fts(wake_cycles_fts, rowid, wake_cycle_id, summary)
        VALUES ('delete', old.rowid, old.wake_cycle_id, old.summary);
    END
  `);
    db.exec(`
    CREATE TRIGGER IF NOT EXISTS wake_cycles_au AFTER UPDATE ON wake_cycles BEGIN
      INSERT INTO wake_cycles_fts(wake_cycles_fts, rowid, wake_cycle_id, summary)
        VALUES ('delete', old.rowid, old.wake_cycle_id, old.summary);
      INSERT INTO wake_cycles_fts(rowid, wake_cycle_id, summary)
        VALUES (new.rowid, new.wake_cycle_id, new.summary);
    END
  `);
    // vec0 virtual table for vector similarity search (requires sqlite-vec)
    if (vecEnabled) {
        db.exec(`
      CREATE VIRTUAL TABLE IF NOT EXISTS wake_cycles_vec USING vec0(
        wake_cycle_id TEXT PRIMARY KEY,
        embedding float[${EMBEDDING_DIM}]
      )
    `);
        stmtInsertVec = db.prepare(`
      INSERT OR REPLACE INTO wake_cycles_vec (wake_cycle_id, embedding)
      VALUES (?, ?)
    `);
        stmtSearchSemantic = db.prepare(`
      SELECT
        v.wake_cycle_id,
        v.distance
      FROM wake_cycles_vec v
      WHERE v.embedding MATCH ?
        AND k = ?
      ORDER BY v.distance
    `);
        // One-time migration: sync existing embeddings from wake_cycles to vec0.
        // Records inserted before sqlite-vec was available have embeddings in the
        // main table but not in the vec0 table. This backfills them.
        try {
            const missingVec = db.prepare(`
        SELECT wc.wake_cycle_id, wc.embedding
        FROM wake_cycles wc
        LEFT JOIN wake_cycles_vec v ON v.wake_cycle_id = wc.wake_cycle_id
        WHERE wc.embedding IS NOT NULL AND v.wake_cycle_id IS NULL
      `).all();
            if (missingVec.length > 0) {
                let skippedIncompatible = 0;
                for (const row of missingVec) {
                    if (row.embedding.byteLength !== EMBEDDING_BYTE_LENGTH) {
                        skippedIncompatible += 1;
                        continue;
                    }
                    stmtInsertVec.run(row.wake_cycle_id, new Uint8Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength));
                }
                const syncedCount = missingVec.length - skippedIncompatible;
                if (syncedCount > 0) {
                    logger.system(`[transcript-indexer] Synced ${syncedCount} embedding(s) to vec0 table`);
                }
                if (skippedIncompatible > 0) {
                    logger.warn(`[transcript-indexer] Skipped ${skippedIncompatible} incompatible embedding(s) during vec0 sync; they need re-embedding`);
                }
            }
        }
        catch (syncErr) {
            logger.warn(`[transcript-indexer] vec0 sync failed (non-fatal): ${syncErr instanceof Error ? syncErr.message : String(syncErr)}`);
        }
    }
    // Prepared statements
    stmtInsert = db.prepare(`
    INSERT OR REPLACE INTO wake_cycles
      (wake_cycle_id, session_type, timestamp_start, timestamp_end,
       summary, key_excerpts, source_file, start_line, end_line, embedding,
       tool_calls_json, trigger_type, trigger_channel, outcome,
       tool_call_count, tool_calls_truncated, reported_tool_call_count,
       usage_reported)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `);
    stmtSearchKeyword = db.prepare(`
    SELECT
      wc.wake_cycle_id,
      wc.session_type,
      wc.timestamp_start,
      wc.timestamp_end,
      wc.summary,
      bm25(wake_cycles_fts) AS relevance_score
    FROM wake_cycles_fts fts
    JOIN wake_cycles wc ON wc.rowid = fts.rowid
    WHERE wake_cycles_fts MATCH ?
    ORDER BY bm25(wake_cycles_fts)
    LIMIT ?
  `);
    stmtGetById = db.prepare(`SELECT * FROM wake_cycles WHERE wake_cycle_id = ?`);
    return db;
}
// ---------------------------------------------------------------------------
// Source file reading (engine transcripts or VisionClaw logs)
// ---------------------------------------------------------------------------
/**
 * Count lines in a file asynchronously via streaming.
 * Returns 0 if the file does not exist.
 */
export async function countFileLines(filePath) {
    if (!fs.existsSync(filePath))
        return 0;
    const inputStream = fs.createReadStream(filePath, { encoding: "utf-8" });
    const rl = readline.createInterface({
        input: inputStream,
        crlfDelay: Infinity,
    });
    let count = 0;
    for await (const _line of rl) {
        count++;
    }
    return count;
}
/**
 * Read specific line range from a JSONL source file (engine transcript or
 * VisionClaw log). Lines are 1-indexed; range is [startLine, endLine)
 * (exclusive end). Handles both plain .jsonl and .jsonl.gz files via streaming.
 *
 * @param maxLines — optional cap on lines returned. When the range contains
 *   more lines than this limit, the function samples lines evenly across the
 *   range so the start, middle and end of the cycle are all represented.
 */
export async function readSourceFileLines(filePath, startLine, endLine, maxLines) {
    // Try the exact path first, then .gz variant
    let actualPath = filePath;
    let isGz = false;
    if (!fs.existsSync(actualPath)) {
        const gzPath = `${actualPath}.gz`;
        if (fs.existsSync(gzPath)) {
            actualPath = gzPath;
            isGz = true;
        }
        else {
            return [];
        }
    }
    if (actualPath.endsWith(".gz")) {
        isGz = true;
    }
    const lines = [];
    let lineNum = 1;
    let inputStream;
    if (isGz) {
        const fileStream = fs.createReadStream(actualPath);
        const gunzip = createGunzip();
        const passthrough = new PassThrough();
        void pipeline(fileStream, gunzip, passthrough).catch(() => {
            // Stream errors surface when readline tries to read
        });
        inputStream = passthrough;
    }
    else {
        inputStream = fs.createReadStream(actualPath, { encoding: "utf-8" });
    }
    const rl = readline.createInterface({
        input: inputStream,
        crlfDelay: Infinity,
    });
    for await (const line of rl) {
        if (lineNum >= startLine && lineNum < endLine) {
            lines.push(line);
        }
        if (lineNum >= endLine) {
            rl.close();
            break;
        }
        lineNum++;
    }
    if (maxLines && maxLines > 0 && lines.length > maxLines) {
        return sampleLines(lines, maxLines);
    }
    return lines;
}
function sampleLines(lines, maxLines) {
    const sampled = [];
    const step = lines.length / maxLines;
    for (let i = 0; i < maxLines; i++) {
        sampled.push(lines[Math.floor(i * step)]);
    }
    return sampled;
}
/**
 * Read transcript lines for a wake cycle, searching across live and archived
 * transcript files. Used for backfill reconstruction when the original
 * source_file may have been archived due to transcript rollover.
 *
 * Search order:
 *  1. The original source_file path (live transcript)
 *  2. Date-stamped archive for the wake cycle's date
 *  3. Adjacent dates (±1 day) for timezone/boundary cases
 *
 * @param sourceFile - Original source file path from the wake cycle record.
 * @param startLine - 1-indexed start line.
 * @param endLine - 1-indexed exclusive end line.
 * @param wakeCycleTimestamp - ISO timestamp of the wake cycle's start, used
 *   to derive which date-stamped archive to search.
 */
export async function readWakeCycleTranscriptLines(sourceFile, startLine, endLine, wakeCycleTimestamp) {
    // 1. Try the original source_file path (live transcript)
    const lines = await readSourceFileLines(sourceFile, startLine, endLine);
    if (lines.length > 0)
        return lines;
    // 2. Try date-stamped archive for the wake cycle's date
    const cycleDate = new Date(wakeCycleTimestamp);
    if (Number.isNaN(cycleDate.getTime()))
        return [];
    const formatDate = (d) => {
        const year = d.getFullYear();
        const month = String(d.getMonth() + 1).padStart(2, "0");
        const day = String(d.getDate()).padStart(2, "0");
        return `${year}-${month}-${day}`;
    };
    const archivePath = deriveArchivePath(sourceFile, formatDate(cycleDate));
    const archiveLines = await readSourceFileLines(archivePath, startLine, endLine);
    if (archiveLines.length > 0)
        return archiveLines;
    // 3. Try adjacent dates (±1 day) for timezone/boundary cases
    const prevDate = new Date(cycleDate);
    prevDate.setDate(prevDate.getDate() - 1);
    const prevArchive = deriveArchivePath(sourceFile, formatDate(prevDate));
    const prevLines = await readSourceFileLines(prevArchive, startLine, endLine);
    if (prevLines.length > 0)
        return prevLines;
    const nextDate = new Date(cycleDate);
    nextDate.setDate(nextDate.getDate() + 1);
    const nextArchive = deriveArchivePath(sourceFile, formatDate(nextDate));
    const nextLines = await readSourceFileLines(nextArchive, startLine, endLine);
    if (nextLines.length > 0)
        return nextLines;
    // 4. Not found in any expected location
    return [];
}
/** Maximum tool calls to include in a single usage report. */
const MAX_TOOL_CALLS_PER_CYCLE = 500;
const TOOLS_WITH_ACTION_ARG = new Set([
    "manage_email", "manage_calendar", "manage_drive",
    "manage_totp", "manage_skills", "manage_mcp_servers",
    "memory", "stock_data",
]);
function isTimestampWithinCycle(timestamp, context) {
    const timestampMs = Date.parse(timestamp);
    const startMs = Date.parse(context.cycleStartedAt);
    const endMs = Date.parse(context.cycleEndedAt);
    if (Number.isNaN(timestampMs) || Number.isNaN(startMs) || Number.isNaN(endMs)) {
        return true;
    }
    return timestampMs >= startMs && timestampMs <= endMs;
}
/**
 * Extract tool call facts from raw Claude SDK transcript JSONL lines.
 *
 * Matches `tool_use` blocks (from assistant messages) with corresponding
 * `tool_result` blocks (from user messages) via `tool_use_id`. If a
 * tool_use has no matching result, it is closed at `cycleEndedAt` with
 * `success: false` and `errorKind: "missing_tool_result"`.
 *
 * When transcript lines carry timestamps, tool_use events outside the wake
 * cycle window are ignored. This protects backfill/live indexing from
 * over-broad transcript ranges. For untimestamped lines, `startedAt` falls
 * back to `cycleStartedAt` and `endedAt` falls back to the last seen timestamp
 * marker or cycle boundary.
 *
 * @param lines - Raw JSONL transcript lines (Claude transcript format only).
 * @param context - Cycle-level timestamps for fallback.
 */
export function extractToolCallsFromLines(lines, context) {
    const inFlight = new Map();
    const completed = [];
    // Track a running timestamp approximation. Claude transcripts don't have
    // per-line timestamps, so we use the cycle boundary as a fallback. If we
    // ever discover a timestamp field on transcript lines, we can use it here.
    let currentTimestamp = context.cycleStartedAt;
    for (const line of lines) {
        if (!line.trim())
            continue;
        try {
            const parsed = JSON.parse(line);
            const type = parsed.type;
            if (parsed.isMeta === true)
                continue;
            // Check for any timestamp on the line itself
            if (typeof parsed.timestamp === "string") {
                currentTimestamp = parsed.timestamp;
            }
            if (type === "assistant") {
                const message = parsed.message;
                const parentToolUseIdRaw = parsed.parent_tool_use_id;
                const parentToolUseId = typeof parentToolUseIdRaw === "string" && parentToolUseIdRaw.length > 0
                    ? parentToolUseIdRaw
                    : undefined;
                if (message?.content && Array.isArray(message.content)) {
                    for (const block of message.content) {
                        const b = block;
                        if (b.type === "tool_use") {
                            const toolUseId = b.id ??
                                b.tool_use_id;
                            const toolName = b.name;
                            if (toolUseId &&
                                toolName &&
                                isTimestampWithinCycle(currentTimestamp, context)) {
                                let action;
                                const strippedName = toolName
                                    .replace(/^mcp__visionclaw__/, "")
                                    .replace(/^mcp__playwright__/, "");
                                if (TOOLS_WITH_ACTION_ARG.has(strippedName)) {
                                    const input = (b.input ?? b.arguments);
                                    const rawAction = input?.action ?? input?.command;
                                    if (typeof rawAction === "string")
                                        action = rawAction;
                                }
                                inFlight.set(toolUseId, {
                                    toolUseId,
                                    toolName,
                                    parentToolUseId,
                                    startedAt: currentTimestamp,
                                    action,
                                });
                            }
                        }
                    }
                }
            }
            else if (type === "user") {
                const message = parsed.message;
                if (message?.content && Array.isArray(message.content)) {
                    for (const block of message.content) {
                        const b = block;
                        if (b.type === "tool_result") {
                            const toolUseId = b.tool_use_id;
                            if (!toolUseId)
                                continue;
                            const pending = inFlight.get(toolUseId);
                            if (!pending)
                                continue; // unmatched result — skip
                            inFlight.delete(toolUseId);
                            const isError = b.is_error === true;
                            completed.push({
                                toolName: pending.toolName,
                                toolUseId: pending.toolUseId,
                                parentToolUseId: pending.parentToolUseId,
                                startedAt: pending.startedAt,
                                endedAt: currentTimestamp,
                                success: !isError,
                                ...(isError ? { errorKind: "tool_error" } : {}),
                                ...(pending.action ? { action: pending.action } : {}),
                            });
                        }
                    }
                }
            }
        }
        catch {
            // Skip malformed lines
        }
    }
    // Close any remaining in-flight tool calls as missing results
    for (const pending of inFlight.values()) {
        completed.push({
            toolName: pending.toolName,
            toolUseId: pending.toolUseId,
            parentToolUseId: pending.parentToolUseId,
            startedAt: pending.startedAt,
            endedAt: context.cycleEndedAt,
            success: false,
            errorKind: "missing_tool_result",
            ...(pending.action ? { action: pending.action } : {}),
        });
    }
    // Sort by startedAt for consistent ordering
    completed.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
    const originalCount = completed.length;
    const truncated = originalCount > MAX_TOOL_CALLS_PER_CYCLE;
    const toolCalls = truncated
        ? completed.slice(0, MAX_TOOL_CALLS_PER_CYCLE)
        : completed;
    return {
        toolCalls,
        toolCallCount: originalCount,
        truncated,
        reportedToolCallCount: truncated ? toolCalls.length : null,
    };
}
// ---------------------------------------------------------------------------
// Transcript parsing / filtering for summary generation
// ---------------------------------------------------------------------------
//
// Format-specific JSONL parsing lives in `./transcript-parsers/`. The wake
// cycle indexer dispatches to the right parser via `getTranscriptParser`.
// The helpers below are kept as Claude-format-specific public exports for
// tests and any caller that only consumes Claude SDK transcripts; new code
// that handles multiple formats should call `getTranscriptParser(kind)`
// directly.
/**
 * Parse raw JSONL lines from a Claude SDK transcript file into a condensed
 * text representation suitable for summary generation. Thin wrapper around
 * the registered Claude parser.
 */
export function parseTranscriptForSummary(lines) {
    return getTranscriptParser("claude").parseForSummary(lines);
}
/** Backward-compat wrapper around the Claude parser. */
export function isOnlyTrivialToolCalls(lines) {
    return getTranscriptParser("claude").isOnlyTrivialToolCalls(lines);
}
// ---------------------------------------------------------------------------
// Summary generation via Gemini
// ---------------------------------------------------------------------------
const SUMMARY_PROMPT = `You are analyzing a transcript of an AI agent's wake cycle. Generate a structured summary.

Instructions:
- Focus on: what task was performed, key decisions made, user interactions, and outcomes
- For "summary": write a detailed paragraph (200-400 words) covering the main activities and results
- For "key_excerpts": extract 3-8 verbatim quotes from the transcript that capture the most important information (user requests, key decisions, important results)
- Omit routine/trivial details (heartbeat checks with no action, viewing memory with no changes)

Respond with valid JSON only, no markdown fences:
{"summary": "...", "key_excerpts": ["...", "..."]}

TRANSCRIPT:
`;
export async function generateSummary(condensedTranscript, config) {
    const apiKey = resolveGeminiApiKey(config);
    const ai = new GoogleGenAI({ apiKey });
    const maxInputChars = 30_000;
    const truncatedInput = condensedTranscript.length > maxInputChars
        ? condensedTranscript.slice(0, maxInputChars) +
            "\n...[transcript truncated]"
        : condensedTranscript;
    const response = await ai.models.generateContent({
        model: GEMINI_SUMMARY_MODEL,
        contents: SUMMARY_PROMPT + truncatedInput,
    });
    const text = response.text ?? "";
    const jsonMatch = /\{[\s\S]*\}/.exec(text);
    if (!jsonMatch) {
        return { summary: text.slice(0, 2000), key_excerpts: [] };
    }
    try {
        const parsed = JSON.parse(jsonMatch[0]);
        return {
            summary: parsed.summary ?? text.slice(0, 2000),
            key_excerpts: Array.isArray(parsed.key_excerpts)
                ? parsed.key_excerpts
                : [],
        };
    }
    catch {
        return { summary: text.slice(0, 2000), key_excerpts: [] };
    }
}
// ---------------------------------------------------------------------------
// Embedding generation via Gemini
// ---------------------------------------------------------------------------
export async function generateEmbedding(text, config) {
    try {
        const apiKey = resolveGeminiApiKey(config);
        const ai = new GoogleGenAI({ apiKey });
        const result = await ai.models.embedContent({
            model: GEMINI_EMBEDDING_MODEL,
            contents: text,
            config: { outputDimensionality: EMBEDDING_DIM },
        });
        const values = result.embeddings?.[0]?.values;
        if (!values || values.length === 0)
            return null;
        if (values.length !== EMBEDDING_DIM) {
            throw new Error(`Unexpected embedding dimension: got ${values.length}, expected ${EMBEDDING_DIM}`);
        }
        return new Float32Array(values);
    }
    catch (err) {
        logger.warn(`[transcript-indexer] Embedding generation failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
        return null;
    }
}
// ---------------------------------------------------------------------------
// Public API: index a wake cycle
// ---------------------------------------------------------------------------
/** Max lines to read from a transcript for summarization.
 *  Prevents excessive memory usage on very long wake cycles. */
const MAX_LINES_FOR_SUMMARY = 2_000;
/**
 * Index the current wake cycle's engine transcript. Called after each wake
 * cycle completes (fire-and-forget from the main loop).
 *
 * Steps:
 *  1. Read transcript lines for the wake cycle range (capped)
 *  1.5 Extract tool call facts for usage metering
 *  2. Parse and condense with the source-format-specific parser
 *  3. Call Gemini Flash to generate summary + key excerpts
 *  4. Generate embedding for the summary (best-effort)
 *  5. Store in SQLite (wake_cycles + FTS5 auto-synced via triggers)
 */
export async function indexWakeCycle(options) {
    const { sessionType, sourceFile, sourceFormat = "claude", startLine, endLine, timestampStart, timestampEnd, trigger, messages, config, outcome, } = options;
    // Skip wake cycles with no transcript data — these have no usage to record.
    // Post-read skip paths (trivial tool calls, short condensed text) still
    // persist the usage record.
    const lineCount = endLine - startLine;
    if (trigger === "heartbeat" && messages.length === 0 && lineCount < 10) {
        logger.debug("[transcript-indexer] Skipping trivial heartbeat wake cycle");
        return;
    }
    if (lineCount <= 0) {
        logger.debug("[transcript-indexer] Skipping empty wake cycle (no new lines)");
        return;
    }
    const parser = getTranscriptParser(sourceFormat);
    // Step 1: Read full transcript lines (uncapped) for billing-accurate tool
    // call extraction. Long wake cycles need every line to avoid undercounting.
    const allLines = await readSourceFileLines(sourceFile, startLine, endLine);
    if (allLines.length === 0) {
        logger.debug("[transcript-indexer] No source file lines read, skipping");
        return;
    }
    // Step 1.5: Extract tool call facts from the full line set for usage metering.
    // Dispatch through the format-specific parser so Pi Coding transcripts
    // (which use toolCall blocks instead of tool_use) are handled correctly.
    const toolCallExtraction = parser.extractToolCalls(allLines, {
        cycleStartedAt: timestampStart,
        cycleEndedAt: timestampEnd,
    });
    const toolCallsJson = JSON.stringify(toolCallExtraction.toolCalls);
    const firstMessage = messages.length > 0 ? messages[0] : undefined;
    // Steps 2-4: Generate summary + embedding unless the cycle is trivial.
    // Even when skipped, we still persist the usage record below.
    let summary = "";
    let key_excerpts = [];
    let embeddingBlob = null;
    const isTrivial = parser.isOnlyTrivialToolCalls(allLines);
    if (!isTrivial) {
        const lines = allLines.length > MAX_LINES_FOR_SUMMARY
            ? sampleLines(allLines, MAX_LINES_FOR_SUMMARY)
            : allLines;
        const condensed = parser.parseForSummary(lines);
        if (condensed.length >= 50) {
            const result = await generateSummary(condensed, config);
            summary = result.summary;
            key_excerpts = result.key_excerpts;
            const embeddingVec = await generateEmbedding(summary, config);
            embeddingBlob = embeddingVec
                ? Buffer.from(embeddingVec.buffer, embeddingVec.byteOffset, embeddingVec.byteLength)
                : null;
        }
    }
    if (!summary) {
        logger.debug(`[transcript-indexer] Skipping summary generation (trivial/short cycle, format=${sourceFormat})`);
    }
    // Step 5: Always store in SQLite — usage data must be persisted even for
    // cycles that are too trivial to summarize.
    const wakeCycleId = options.wakeCycleId;
    const wakeTriggerType = trigger === "heartbeat"
        ? "heartbeat"
        : (firstMessage?.channel ?? "message");
    const wakeTriggerChannel = trigger === "message" ? (firstMessage?.channel ?? null) : null;
    getDb();
    const insertStmt = requireStatement(stmtInsert, "stmtInsert");
    insertStmt.run(wakeCycleId, sessionType, timestampStart, timestampEnd, summary || null, key_excerpts.length > 0 ? JSON.stringify(key_excerpts) : null, sourceFile, startLine, endLine, embeddingBlob, toolCallsJson, wakeTriggerType, wakeTriggerChannel, outcome ?? null, toolCallExtraction.toolCallCount, toolCallExtraction.truncated ? 1 : 0, toolCallExtraction.reportedToolCallCount, 0);
    // Also insert into vec0 table for semantic search
    if (vecEnabled && stmtInsertVec && embeddingBlob) {
        try {
            stmtInsertVec.run(wakeCycleId, new Uint8Array(embeddingBlob.buffer, embeddingBlob.byteOffset, embeddingBlob.byteLength));
        }
        catch (vecErr) {
            logger.warn(`[transcript-indexer] vec0 insert failed (non-fatal): ${vecErr instanceof Error ? vecErr.message : String(vecErr)}`);
        }
    }
    logger.system(summary
        ? `[transcript-indexer] Indexed wake cycle ${wakeCycleId}: ${allLines.length} lines → ${summary.length} char summary`
        : `[transcript-indexer] Indexed wake cycle ${wakeCycleId}: usage-only (${toolCallExtraction.toolCallCount} tool calls)`);
}
/**
 * Insert a pre-built wake cycle record into the database.
 * Used by the backfill CLI to insert records with known timestamps and IDs.
 *
 * @param record.sourceFile — path to the source JSONL file. For backfilled
 *   data this is a VisionClaw runtime log file; for live data it is a Claude
 *   SDK transcript file.
 */
export function insertWakeCycleRecord(record) {
    getDb();
    const insertStmt = requireStatement(stmtInsert, "stmtInsert");
    insertStmt.run(record.wakeCycleId, record.sessionType, record.timestampStart, record.timestampEnd, record.summary, record.keyExcerpts && record.keyExcerpts.length > 0
        ? JSON.stringify(record.keyExcerpts)
        : null, record.sourceFile, record.startLine, record.endLine, record.embeddingBlob, record.toolCallsJson ?? null, null, // trigger_type
    null, // trigger_channel
    null, // outcome
    record.toolCallCount ?? 0, record.toolCallsTruncated ? 1 : 0, record.reportedToolCallCount ?? null, 0);
    // Also insert into vec0 table for semantic search
    if (vecEnabled && stmtInsertVec && record.embeddingBlob) {
        try {
            const blob = record.embeddingBlob;
            stmtInsertVec.run(record.wakeCycleId, new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength));
        }
        catch {
            // best-effort — vec insert failure is non-fatal
        }
    }
}
/**
 * Return the total number of wake cycles in the database.
 */
export function getWakeCycleCount() {
    const dbInstance = getDb();
    const row = dbInstance.prepare("SELECT COUNT(*) as count FROM wake_cycles").get();
    return row?.count ?? 0;
}
export function listWakeCyclesForUsageBackfill(limit = 5_000) {
    const dbInstance = getDb();
    return dbInstance.prepare(`
    SELECT wake_cycle_id, session_type, timestamp_start, timestamp_end,
           summary, key_excerpts, source_file, start_line, end_line, embedding,
           tool_calls_json, usage_reported, trigger_type, trigger_channel,
           outcome, tool_call_count, tool_calls_truncated, reported_tool_call_count
    FROM wake_cycles
    ORDER BY timestamp_start ASC
    LIMIT ?
  `).all(Math.max(1, limit));
}
/**
 * List wake cycles that have not yet been queued into usage.db.
 * Used by the background UsageReporter.
 */
export function listUnreportedWakeCycles(limit = 100) {
    const dbInstance = getDb();
    return dbInstance.prepare(`
    SELECT wake_cycle_id, session_type, timestamp_start, timestamp_end,
           summary, key_excerpts, source_file, start_line, end_line, embedding,
           tool_calls_json, usage_reported, trigger_type, trigger_channel,
           outcome, tool_call_count, tool_calls_truncated, reported_tool_call_count
    FROM wake_cycles
    WHERE usage_reported = 0
    ORDER BY timestamp_start ASC
    LIMIT ?
  `).all(Math.max(1, limit));
}
/**
 * Mark a wake cycle as successfully queued into usage.db.
 */
export function markWakeCycleUsageReported(wakeCycleId) {
    const dbInstance = getDb();
    dbInstance.prepare("UPDATE wake_cycles SET usage_reported = 1 WHERE wake_cycle_id = ?").run(wakeCycleId);
}
/**
 * Record a usage report error for a wake cycle.
 */
export function markWakeCycleUsageReportError(wakeCycleId, error) {
    const dbInstance = getDb();
    dbInstance.prepare("UPDATE wake_cycles SET usage_report_error = ?, usage_report_attempted_at = ? WHERE wake_cycle_id = ?").run(error, new Date().toISOString(), wakeCycleId);
}
/**
 * Update the tool_calls_json for an existing wake cycle row.
 * Used to persist reconstructed tool calls from archived transcripts.
 */
export function updateWakeCycleToolCallsJson(wakeCycleId, toolCallsJson) {
    const dbInstance = getDb();
    dbInstance.prepare("UPDATE wake_cycles SET tool_calls_json = ? WHERE wake_cycle_id = ?").run(toolCallsJson, wakeCycleId);
}
/**
 * Check if a wake cycle ID already exists in the database.
 */
export function wakeCycleExists(wakeCycleId) {
    getDb();
    const getByIdStmt = requireStatement(stmtGetById, "stmtGetById");
    const row = getByIdStmt.get(wakeCycleId);
    return row !== undefined;
}
/**
 * Check whether semantic/vector search is available.
 */
export function isSemanticSearchAvailable() {
    getDb();
    return vecEnabled;
}
/**
 * Search wake cycle summaries. Routes to keyword, semantic, or hybrid
 * search based on the `type` option.
 *
 * - keyword: FTS5 full-text search with BM25 ranking (default)
 * - semantic: vec0 KNN search using embedding similarity
 * - hybrid: combines keyword + semantic via Reciprocal Rank Fusion (RRF)
 */
export function searchWakeCycles(options) {
    const { type = "keyword" } = options;
    // Fall back to keyword if semantic search is unavailable
    if ((type === "semantic" || type === "hybrid") && !vecEnabled) {
        logger.debug("[transcript-indexer] Semantic search requested but sqlite-vec not loaded, falling back to keyword");
        return searchKeyword(options);
    }
    switch (type) {
        case "semantic":
            return searchSemantic(options);
        case "hybrid":
            return searchHybrid(options);
        default:
            return searchKeyword(options);
    }
}
/**
 * FTS5 keyword search with BM25 ranking.
 */
function searchKeyword(options) {
    const { query, timeRange, sessionType, limit = 5 } = options;
    getDb();
    // Sanitize the query for FTS5: wrap words in quotes and join with OR
    const sanitized = query
        .split(/\s+/)
        .filter((w) => w.length > 0)
        .map((w) => `"${w.replace(/"/g, "")}"`)
        .join(" OR ");
    if (!sanitized)
        return [];
    // Fetch more than `limit` to allow post-filtering
    const fetchLimit = timeRange ?? sessionType ? limit * 3 : limit;
    const keywordStmt = requireStatement(stmtSearchKeyword, "stmtSearchKeyword");
    const rows = keywordStmt.all(sanitized, fetchLimit);
    // Normalize BM25 scores: raw BM25 is negative (lower = better).
    // Convert to positive 0-1 range where higher = more relevant.
    const normalized = rows.map((r) => ({
        ...r,
        relevance_score: normalizeBm25Score(r.relevance_score),
    }));
    return applyFilters(normalized, timeRange, sessionType, limit);
}
/**
 * Semantic search requires async embedding generation.
 * This sync stub falls back to keyword — use searchSemanticAsync() instead.
 */
function searchSemantic(options) {
    // Semantic search requires async embedding generation;
    // MCP tools call searchSemanticAsync() directly.
    return searchKeyword(options);
}
/**
 * Async semantic search — generates query embedding then searches vec0.
 */
export async function searchSemanticAsync(options) {
    const { query, timeRange, sessionType, limit = 5, config } = options;
    if (!vecEnabled || !stmtSearchSemantic || !config) {
        return searchKeyword(options);
    }
    getDb();
    // Generate embedding for the query
    const queryEmbedding = await generateEmbedding(query, config);
    if (!queryEmbedding) {
        logger.debug("[transcript-indexer] Query embedding generation failed, falling back to keyword");
        return searchKeyword(options);
    }
    // KNN search via vec0
    const queryBlob = new Uint8Array(queryEmbedding.buffer, queryEmbedding.byteOffset, queryEmbedding.byteLength);
    const fetchLimit = timeRange ?? sessionType ? limit * 3 : limit;
    const vecRows = stmtSearchSemantic.all(queryBlob, fetchLimit);
    // Hydrate with full metadata from the main table
    const results = [];
    const getByIdStmt = requireStatement(stmtGetById, "stmtGetById");
    for (const vecRow of vecRows) {
        const row = getByIdStmt.get(vecRow.wake_cycle_id);
        if (row) {
            results.push({
                wake_cycle_id: row.wake_cycle_id,
                session_type: row.session_type,
                timestamp_start: row.timestamp_start,
                timestamp_end: row.timestamp_end,
                summary: row.summary,
                // Normalize distance to 0-1 range: 1/(1+distance).
                // Lower distance = higher score = more relevant.
                relevance_score: 1 / (1 + vecRow.distance),
            });
        }
    }
    return applyFilters(results, timeRange, sessionType, limit);
}
/**
 * Async hybrid search — runs keyword + semantic in parallel, fuses with RRF.
 * Reciprocal Rank Fusion: score = Σ 1/(k + rank_i) for each result system.
 */
export async function searchHybridAsync(options) {
    const { limit = 5 } = options;
    // Run both searches — keyword is synchronous, semantic is async
    const keywordResults = searchKeyword({ ...options, limit: limit * 2 });
    const semanticResults = await searchSemanticAsync({
        ...options,
        limit: limit * 2,
    });
    return fuseWithRRF(keywordResults, semanticResults, limit);
}
/**
 * Reciprocal Rank Fusion (RRF) to merge results from multiple search systems.
 * k=60 is the standard RRF constant.
 */
function fuseWithRRF(keywordResults, semanticResults, limit, k = 60) {
    const scoreMap = new Map();
    const recordMap = new Map();
    // Score from keyword results
    for (let rank = 0; rank < keywordResults.length; rank++) {
        const r = keywordResults[rank];
        const rrfScore = 1 / (k + rank + 1);
        scoreMap.set(r.wake_cycle_id, (scoreMap.get(r.wake_cycle_id) ?? 0) + rrfScore);
        recordMap.set(r.wake_cycle_id, r);
    }
    // Score from semantic results
    for (let rank = 0; rank < semanticResults.length; rank++) {
        const r = semanticResults[rank];
        const rrfScore = 1 / (k + rank + 1);
        scoreMap.set(r.wake_cycle_id, (scoreMap.get(r.wake_cycle_id) ?? 0) + rrfScore);
        if (!recordMap.has(r.wake_cycle_id)) {
            recordMap.set(r.wake_cycle_id, r);
        }
    }
    // Sort by fused score (descending) and take top `limit`
    const fused = [...scoreMap.entries()]
        .sort((a, b) => b[1] - a[1])
        .slice(0, limit)
        .map(([id, score]) => {
        const record = recordMap.get(id);
        if (!record) {
            throw new Error(`Missing fused record for wake cycle ${id}`);
        }
        return { ...record, relevance_score: score };
    });
    return fused;
}
/**
 * Stub for synchronous hybrid — delegates to keyword (the async version
 * is called from the MCP tool layer).
 */
function searchHybrid(options) {
    // Hybrid requires async embedding generation; this sync fallback
    // returns keyword results. The MCP tool uses searchHybridAsync().
    return searchKeyword(options);
}
/**
 * Apply time range, session type filters and limit to search results.
 */
function applyFilters(results, timeRange, sessionType, limit) {
    let filtered = results;
    if (timeRange) {
        filtered = filtered.filter((r) => r.timestamp_start >= timeRange.start &&
            r.timestamp_end <= timeRange.end);
    }
    if (sessionType) {
        filtered = filtered.filter((r) => r.session_type === sessionType);
    }
    return filtered.slice(0, limit);
}
/**
 * Normalize BM25 scores (which are negative, lower = better) to positive
 * scores where higher = better. Uses: score = 1 / (1 + |bm25|).
 * This gives a 0-1 range suitable for display.
 */
export function normalizeBm25Score(bm25) {
    return 1 / (1 + Math.abs(bm25));
}
// ---------------------------------------------------------------------------
// Public API: get details
// ---------------------------------------------------------------------------
/**
 * Get the key_excerpts for a specific wake cycle.
 */
export function getWakeCycleExcerpts(wakeCycleId) {
    getDb();
    const getByIdStmt = requireStatement(stmtGetById, "stmtGetById");
    const row = getByIdStmt.get(wakeCycleId);
    if (!row)
        return null;
    return row.key_excerpts;
}
/**
 * Get the full original source content for a specific wake cycle.
 * Reads from the source file (Claude transcript or VisionClaw log),
 * decompressing .gz if needed. No line limit is applied here so the
 * agent can access the complete original data when needed.
 */
export async function getWakeCycleFullTranscript(wakeCycleId) {
    getDb();
    const getByIdStmt = requireStatement(stmtGetById, "stmtGetById");
    const row = getByIdStmt.get(wakeCycleId);
    if (!row)
        return null;
    const lines = await readSourceFileLines(row.source_file, row.start_line, row.end_line);
    if (lines.length === 0) {
        return "[Source file no longer available. The summary and key excerpts above are the only preserved content.]";
    }
    return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
/**
 * Close the database connection. Call during graceful shutdown.
 */
export function closeTranscriptMemoryDb() {
    if (db) {
        db.close();
        db = null;
        stmtInsert = null;
        stmtInsertVec = null;
        stmtSearchKeyword = null;
        stmtSearchSemantic = null;
        stmtGetById = null;
        vecEnabled = false;
    }
}
//# sourceMappingURL=transcript-indexer.js.map