/**
 * Thin JSON-RPC 2.0 client that spawns `loop-engine serve` as a
 * subprocess and talks to it over stdin/stdout.
 *
 * Responsibilities:
 *  - Spawn the engine binary lazily on first call.
 *  - Map outgoing requests to pending promises by `id`.
 *  - Translate JSON-RPC error responses into thrown JS Errors with
 *    `.code` and `.data` attached.
 *  - Survive crashes: if the subprocess exits, the next call respawns.
 *
 * Binary discovery (v0.4 — see src/config.ts resolveEngineBin):
 *   1. `OPENSQUID_ENGINE_BIN` env var — explicit override
 *   2. `<data-root>/config.json` `engine_bin` field — persisted choice
 *   3. Auto-search common dev paths (~/projects/<*>/engine/target/release/
 *      and ~/work/<*>/engine/target/release/)
 *   4. $PATH — system-installed binary
 *   5. Throw with a helpful error message
 *
 * On first successful auto-discovery, the resolved path is written back
 * to config.json so subsequent sessions skip the search. Moving the
 * loop-engine checkout invalidates the cached path; the next start
 * re-discovers automatically.
 *
 * v0.2 keeps the subprocess alive across the MCP session lifetime —
 * a single engine process serves all tool calls until the MCP host
 * disconnects.
 */
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { resolveEngineBin } from "./config.js";
export class RpcError extends Error {
    code;
    data;
    constructor(message, code, data) {
        super(message);
        this.name = "RpcError";
        this.code = code;
        this.data = data;
    }
}
export class EngineClient {
    proc = null;
    reader = null;
    pending = new Map();
    nextId = 1;
    startupAck = null;
    /** Lazily spawn the engine subprocess. Idempotent. */
    async ensureStarted() {
        if (this.proc)
            return;
        if (this.startupAck)
            return this.startupAck;
        const bin = await resolveEngineBin();
        if (!bin) {
            throw new Error("loop-engine binary not found. Set OPENSQUID_ENGINE_BIN, or run " +
                "`opensquid engine set-path <path>`, or build the engine at " +
                "~/projects/loop/engine/target/release/loop-engine");
        }
        const proc = spawn(bin, ["serve"], {
            stdio: ["pipe", "pipe", "pipe"],
            env: {
                ...process.env,
                // Pin the engine to opensquid's storage root so the on-disk
                // layout is shared across both halves.
                LOOP_HOME: process.env.LOOP_HOME ?? `${process.env.HOME}/.opensquid`,
            },
        });
        this.proc = proc;
        this.reader = createInterface({ input: proc.stdout });
        this.reader.on("line", (line) => this.handleLine(line));
        proc.stderr.on("data", (chunk) => {
            // Engine logs go to stderr; mirror them but tag so they're
            // distinguishable from opensquid's own logs.
            const text = chunk.toString().trimEnd();
            if (text)
                console.error(`[engine] ${text}`);
        });
        proc.on("exit", (code) => {
            const reason = `engine subprocess exited (code=${code ?? "null"})`;
            console.error(`[opensquid] ${reason}`);
            this.proc = null;
            this.reader = null;
            // #170: clear the cached startup promise so the next call's
            // `ensureStarted()` actually respawns instead of returning the
            // stale resolved promise from the previous lifetime. Honors the
            // "Survive crashes: if the subprocess exits, the next call
            // respawns" invariant in this file's header docstring.
            this.startupAck = null;
            // Reject all in-flight calls so callers can retry.
            for (const [, pending] of this.pending) {
                pending.reject(new Error(reason));
            }
            this.pending.clear();
        });
        proc.on("error", (err) => {
            console.error(`[opensquid] engine spawn error: ${err.message}`);
        });
        // Single ping to confirm the engine is live before we return.
        this.startupAck = this.call("ping", {})
            .then(() => undefined)
            .catch((e) => {
            throw new Error(`engine failed to start: ${e.message}`);
        });
        return this.startupAck;
    }
    handleLine(line) {
        const trimmed = line.trim();
        if (!trimmed)
            return;
        let msg;
        try {
            msg = JSON.parse(trimmed);
        }
        catch (e) {
            console.error(`[opensquid] failed to parse engine line: ${e}`);
            return;
        }
        const pending = this.pending.get(msg.id);
        if (!pending) {
            console.error(`[opensquid] response for unknown id=${msg.id}`);
            return;
        }
        this.pending.delete(msg.id);
        if (msg.error) {
            pending.reject(new RpcError(msg.error.message, msg.error.code, msg.error.data));
        }
        else {
            pending.resolve(msg.result);
        }
    }
    /**
     * Send a JSON-RPC request, await the response. Throws `RpcError` on
     * engine-side errors; throws `Error` on subprocess crashes.
     */
    async call(method, params) {
        if (method !== "ping")
            await this.ensureStarted();
        const id = this.nextId++;
        return new Promise((resolve, reject) => {
            this.pending.set(id, {
                resolve: (v) => resolve(v),
                reject,
            });
            const req = JSON.stringify({ jsonrpc: "2.0", id, method, params });
            if (!this.proc) {
                reject(new Error("engine subprocess not running"));
                return;
            }
            this.proc.stdin.write(req + "\n");
        });
    }
    /** Graceful shutdown — kill the subprocess. */
    shutdown() {
        if (this.proc) {
            this.proc.kill();
            this.proc = null;
        }
    }
}
export class OpenSquidEngine {
    client = new EngineClient();
    shutdown() {
        this.client.shutdown();
    }
    ping() {
        return this.client.call("ping", {});
    }
    createLesson(args) {
        return this.client.call("lesson.create", args);
    }
    recall(args) {
        return this.client.call("lesson.recall", args);
    }
    promote(args) {
        return this.client.call("lesson.promote", args);
    }
    discard(args) {
        return this.client.call("lesson.discard", args);
    }
    /**
     * v0.5 / engine v1.3: paginated list across the four non-discarded
     * status dirs. Order is deterministic (status then id ascending).
     * Default limit 50, capped at 500. Pass `statuses` to restrict to
     * specific dirs (e.g. `["pending"]` for the pending-candidates flow).
     */
    listLessons(args) {
        return this.client.call("lesson.list", args);
    }
    /**
     * v0.5 / engine v1.3: record a thumbs-up or thumbs-down on a lesson.
     * Adds to the lesson's `external_signal_sources`, which feeds the
     * wedge gate's signal-diversity check. Idempotent on
     * `source_signal_id` — passing the same signal twice doesn't
     * double-count. If `source_signal_id` is omitted, the engine mints a
     * synthetic one (the call still records).
     */
    captureFeedback(args) {
        return this.client.call("lesson.capture_feedback", args);
    }
    /**
     * v0.5 / engine v1.3: point an old lesson at a new replacement.
     * Old lesson moves to `superseded/`, new lesson is unaffected. The
     * causal chain is preserved via `superseded_by`. User-authored
     * lessons are protected unless `force: true`.
     *
     * Engine errors:
     *   -32004 — supersede blocked (cycle detected, self-reference,
     *            or replacement not found)
     *   -32001 — user lesson immune (force=true required)
     *   -32002 — old or new lesson not found
     */
    supersedeLesson(args) {
        return this.client.call("lesson.supersede", args);
    }
    createMemory(args) {
        return this.client.call("memory.create", args);
    }
    searchMemory(args) {
        return this.client.call("memory.search", args);
    }
    /**
     * v0.3.1: fetch a single memory by id with full content + scope.
     * Throws `RpcError` with code -32002 if no memory with that id
     * exists (engine returns `DispatchError::NotFound`).
     */
    getMemory(args) {
        return this.client.call("memory.get", args);
    }
    /**
     * v0.5 / engine v1.4: central RAG-style assembly. Returns active
     * lessons (deterministic-sorted, gate-annotated, applied_count
     * bumped) + optional memory recall in one shot. This is what a host
     * like Hermes calls to get "what rules apply right now" instead of
     * stitching together listLessons + searchMemory.
     *
     * Skill/persona/team activation IDs are not threaded through in v0.5
     * (deferred to a later release per find-simple-solutions). The
     * active_skills / active_personas / active_teams arrays in the
     * response ship empty.
     */
    assembleManifest(args) {
        return this.client.call("manifest.assemble", args);
    }
    /**
     * v0.5 / engine v1.3: paginated memory enumeration. Filter-optional
     * via scope_filter (same wire shape as searchMemory). Default limit
     * 50, capped at 500. Order is deterministic (id ascending — memory
     * ids are ULID-shaped so this is roughly chronological).
     *
     * Returns frontmatter rows but NOT body — call getMemory(id) for
     * the full content of any single hit. consumed_by_user_lessons is
     * the reverse-citation count enforcing the user-immunity invariant
     * (>0 means a user-authored lesson cites this memory; prune refuses).
     */
    listMemories(args) {
        return this.client.call("memory.list", args);
    }
    /**
     * v0.4: mutate description / content / scope on an existing memory.
     * Identity, citation counter, derived_from, and origin are always
     * preserved. Re-embeds on content change; cheap path for
     * description/scope-only edits. At least one mutable field must
     * be supplied. Throws `RpcError` -32002 if the id doesn't exist.
     */
    updateMemory(args) {
        return this.client.call("memory.update", args);
    }
    /**
     * v0.4: `forget` — delete a memory. `force = false` (default)
     * respects user-immunity (returns RpcError -32003 if cited by a
     * user-authored lesson). `force = true` is the user-initiated
     * override.
     */
    deleteMemory(args) {
        return this.client.call("memory.delete", args);
    }
    // ---- v0.6.1 — phase ledger (engine 0.5.0+) ----------------------
    /**
     * Record a phase entry for `task_id`. Idempotent: re-logging the
     * same phase returns `newly_recorded: false`. `phase` must be one
     * of: pre_research, learn, code, test, audit, post_research, fix.
     *
     * Pre-#166 this also took a `session_id`, but the writer's MCP
     * session id and the reader's Claude Code session UUID were two
     * different id surfaces — the ledger was unreadable across them.
     * Dropped in favor of per-task scoping; a task that spans multiple
     * sessions now accumulates phases correctly.
     */
    logPhase(args) {
        return this.client.call("task.log_phase", args);
    }
    /**
     * Fetch the workflow phase ledger for `task_id`. Returns the set of
     * phases logged + the entries with timestamps, sorted
     * chronologically.
     */
    getTaskLedger(args) {
        return this.client.call("task.get_ledger", args);
    }
}
