/**
 * `createPipeline` — the single orchestrator.
 *
 * Responsibilities (V7 §0.2, §0.3, §0.5):
 *
 *   • Maintain the session / episode lifecycle. Each `onTurnStart` opens a
 *     new episode (carrying the intent classifier's decision forward). We
 *     keep the default "one user query = one episode" and leave the
 *     revision-vs-new-task split to a future iteration; today every
 *     assistant response finalizes its episode at `onTurnEnd`, which in
 *     turn kicks off the capture → reward → L2 → L3 → skill chain.
 *
 *   • Own all event buses and aggregate them into a single
 *     `CoreEvent` stream for the facade's `subscribeEvents` surface.
 *
 *   • Provide retrieval entry points for every V7 injection trigger
 *     (`turn_start`, `tool_driven`, `skill_invoke`, `sub_agent`,
 *     `decision_repair`). Packet shape is always the adapter-contract
 *     `InjectionPacket`.
 *
 *   • Forward tool-call outcomes to the feedback subscriber so the
 *     failure burst detector can schedule repairs autonomously.
 *
 * The orchestrator is single-process and holds in-memory references to
 * the current open episode per session. Adapters can still inspect the
 * session manager directly for richer queries.
 */
import { contextHashOf, } from "../feedback/index.js";
import { turnStartRetrieve, toolDrivenRetrieve, skillInvokeRetrieve, subAgentRetrieve, repairRetrieve, } from "../retrieval/retrieve.js";
import { scheduleInjection } from "../injection/scheduler.js";
import { buildPipelineBuses, buildPipelineSession, buildPipelineSubscribers, buildRetrievalDeps, extractAlgorithmConfig, pipelineLogger, } from "./deps.js";
import { wrapRetrievalRepos } from "./retrieval-repos.js";
import { bridgeToCoreEvents } from "./event-bridge.js";
import { memoryBuffer } from "../logger/index.js";
import { onBroadcastLog } from "../logger/transports/sse-broadcast.js";
import { createEmbeddingRetryWorker, systemErrorEvent } from "../embedding/index.js";
// ─── Factory ──────────────────────────────────────────────────────────────
export function createPipeline(deps) {
    const log = pipelineLogger(deps);
    const algorithm = extractAlgorithmConfig(deps);
    const lightweightMode = algorithm.lightweightMemory.enabled;
    const buses = buildPipelineBuses();
    // Session + intent.
    const session = buildPipelineSession(deps, buses.session);
    // Algorithm subscribers (capture → reward → L2 → L3 → skill + feedback).
    // Pass `session` so the reward runner's `getEpisodeSnapshot` hook
    // can resolve the live, in-memory episode (with turns populated)
    // rather than falling back to the empty row from SQLite.
    const subs = buildPipelineSubscribers(deps, buses, algorithm, session);
    // Core-event aggregator. Every internal bus funnels into one stream.
    const eventListeners = new Set();
    const logListeners = new Set();
    // Small ring buffer of the most-recent events. Late-connecting SSE
    // subscribers (e.g. the viewer's Overview panel opened after an agent
    // turn already fired) replay this buffer on connect so the "实时活动"
    // dashboard isn't empty by default.
    const RECENT_EVENTS_CAP = 160;
    const recentEvents = [];
    const emitCore = (evt) => {
        recentEvents.push(evt);
        if (recentEvents.length > RECENT_EVENTS_CAP) {
            recentEvents.splice(0, recentEvents.length - RECENT_EVENTS_CAP);
        }
        if (eventListeners.size === 0)
            return;
        for (const listener of eventListeners) {
            try {
                listener(evt);
            }
            catch (err) {
                log.warn("event.listener_threw", {
                    type: evt.type,
                    err: err instanceof Error ? err.message : String(err),
                });
            }
        }
    };
    const getRecentEvents = () => recentEvents.slice();
    let retryEventSeq = 1_000_000;
    const embeddingRetryWorker = createEmbeddingRetryWorker({
        repos: deps.repos,
        embedder: deps.embedder,
        log: log.child({ channel: "core.embedding.retry" }),
        now: deps.now,
        onSystemError: (payload, correlationId) => {
            emitCore(systemErrorEvent(payload, retryEventSeq++, correlationId));
        },
    });
    embeddingRetryWorker.start();
    // Hydrate the ring buffer with synthetic events derived from the
    // most-recent rows on disk. Without this, every plugin restart
    // produces an empty "实时活动" panel until the user happens to
    // interact with the agent again — misleading, because the DB clearly
    // has recent activity. Include the same categories the overview
    // dashboard renders (memory / experience / environment / skill /
    // feedback), not just task/session lifecycle events.
    try {
        const hydrated = [];
        const pushSynthetic = (type, ts, correlationId, payload) => {
            if (!Number.isFinite(ts))
                return;
            hydrated.push({
                type,
                ts: ts,
                seq: 0,
                correlationId,
                payload,
            });
        };
        const recentEpisodes = deps.repos.episodes.list({ limit: 20 });
        for (const ep of recentEpisodes) {
            const ts = ep.endedAt ?? ep.startedAt;
            if (!ts)
                continue;
            const type = ep.status === "closed" ? "episode.closed" : "episode.opened";
            pushSynthetic(type, ts, ep.id, {
                episodeId: ep.id,
                sessionId: ep.sessionId,
                status: ep.status,
                rTask: ep.rTask ?? null,
            });
        }
        for (const tr of deps.repos.traces.list({ limit: 30 })) {
            pushSynthetic("trace.created", tr.ts, tr.id, {
                traceId: tr.id,
                episodeId: tr.episodeId,
                sessionId: tr.sessionId,
            });
        }
        for (const policy of deps.repos.policies.list({ limit: 20 })) {
            const ts = policy.updatedAt ?? policy.createdAt;
            pushSynthetic("l2.revised", ts, policy.id, {
                policyId: policy.id,
                status: policy.status,
                signature: policy.title,
            });
        }
        for (const world of deps.repos.worldModel.list({ limit: 20 })) {
            const ts = world.updatedAt ?? world.createdAt;
            pushSynthetic("l3.revised", ts, world.id, {
                worldModelId: world.id,
                title: world.title,
                status: world.status,
            });
        }
        for (const skill of deps.repos.skills.list({ limit: 20 })) {
            const ts = skill.updatedAt ?? skill.createdAt;
            const type = skill.status === "archived" ? "skill.archived" : "skill.crystallized";
            pushSynthetic(type, ts, skill.id, {
                skillId: skill.id,
                name: skill.name,
                status: skill.status,
            });
        }
        for (const fb of deps.repos.feedback.list({ limit: 20 })) {
            pushSynthetic("feedback.classified", fb.ts, fb.id, {
                feedbackId: fb.id,
                episodeId: fb.episodeId,
                traceId: fb.traceId,
                tone: fb.polarity,
                channel: fb.channel,
            });
        }
        hydrated.sort((a, b) => a.ts - b.ts);
        const keep = hydrated.slice(-RECENT_EVENTS_CAP);
        const seqStart = -keep.length;
        for (let i = 0; i < keep.length; i++) {
            const evt = keep[i];
            recentEvents.push({
                ...evt,
                // Negative ids are reserved for replay-only synthetic rows, so
                // live bridge events starting at seq=1 never collide in the UI.
                seq: seqStart + i,
            });
        }
        if (recentEvents.length > RECENT_EVENTS_CAP) {
            recentEvents.splice(0, recentEvents.length - RECENT_EVENTS_CAP);
        }
        log.debug("events.ring.hydrated", {
            count: recentEvents.length,
            source: "storage",
        });
    }
    catch (err) {
        log.debug("events.ring.hydrate_failed", {
            err: err instanceof Error ? err.message : String(err),
        });
    }
    const bridge = bridgeToCoreEvents({
        buses,
        agent: deps.agent,
        log,
        emit: emitCore,
    });
    // In-memory index of the open episode per session so we can route
    // `addTurn` calls without a repo round-trip.
    const openEpisodeBySession = new Map();
    // Track the most-recently-closed episode per session so V7 §0.1
    // "revision" can reopen it. Cleared on `new_task`.
    const lastEpisodeBySession = new Map();
    // Track last-seen user-text per session for failure-burst context hashing
    // when the adapter doesn't pass its own.
    const lastUserTextBySession = new Map();
    // When a session is closed (e.g. adapter fires `session_end`), purge
    // every orchestrator-local map entry for that session. Without this,
    // `openEpisodeIfNeeded` would still see the stale `lastEpisodeBySession`
    // entry and could reopen an already-abandoned episode the next time the
    // same `bridgeSessionId` is reused — producing the "skipped → active"
    // flip the viewer showed after `/new`.
    buses.session.on("session.closed", (evt) => {
        if (evt.kind !== "session.closed")
            return;
        const sid = evt.sessionId;
        openEpisodeBySession.delete(sid);
        lastEpisodeBySession.delete(sid);
        lastUserTextBySession.delete(sid);
        log.debug("session.maps_cleared", { sessionId: sid, reason: evt.reason });
    });
    // ─── session/episode helpers ────────────────────────────────────────────
    async function ensureSession(agent, sessionId, meta) {
        if (sessionId && session.sessionManager.getSession(sessionId)) {
            if (meta && Object.keys(meta).length > 0) {
                session.sessionManager.openSession({ id: sessionId, agent, meta });
            }
            return sessionId;
        }
        const snap = session.sessionManager.openSession({
            id: sessionId,
            agent,
            meta: meta ?? {},
        });
        return snap.id;
    }
    function lightweightEpisodeMeta(meta) {
        if (!lightweightMode)
            return meta;
        return {
            ...meta,
            lightweightMemory: true,
            relation: "lightweight_memory",
            topicState: "active",
        };
    }
    async function startLightweightEpisode(sessionId, userText, meta, turnTs) {
        const currentEpId = openEpisodeBySession.get(sessionId);
        if (currentEpId) {
            const current = session.sessionManager.getEpisode(currentEpId);
            if (current?.status === "open") {
                session.sessionManager.finalizeEpisode(currentEpId, {
                    patchMeta: {
                        lightweightMemory: true,
                        closeReason: "finalized",
                        recoveryReason: "lightweight_boundary_before_new_turn",
                    },
                });
            }
            openEpisodeBySession.delete(sessionId);
        }
        lastEpisodeBySession.delete(sessionId);
        const snap = await session.sessionManager.startEpisode({
            sessionId,
            userMessage: userText,
            ts: turnTs,
            meta: lightweightEpisodeMeta(meta),
        });
        openEpisodeBySession.set(sessionId, snap.id);
        return snap;
    }
    function isLightweightEpisode(episode) {
        return episode?.meta?.lightweightMemory === true;
    }
    /**
     * Decide whether the new turn continues the current episode, opens a
     * new episode in the same session, or requires a brand-new session.
     *
     * V7 §0.1 routing — under the new "topic-end reflection" architecture
     * episodes are no longer auto-finalized after every turn. So this
     * function takes on the additional responsibility of recognising
     * topic boundaries and finalizing the open episode at the right
     * moment (which in turn fires the topic-level batch reflection).
     *
     * Decision tree:
     *
     *   1. There IS an open episode for this session (the common case):
     *      a. Classify the new user turn against the open episode's
     *         own most recent user/assistant text.
     *      b. revision / follow_up / unknown within `mergeMaxGapMs`
     *         → keep appending to the open episode.
     *      c. new_task OR gap > mergeMaxGapMs OR (episode_per_turn mode)
     *         → `finalizeEpisode(open)` (triggers `runReflect` →
     *         R_human + V backprop), then start a fresh one.
     *
     *   2. No open episode but a recently-finalized one in
     *      `lastEpisodeBySession`:
     *      a. Classify against it as before.
     *      b. revision → reopen.
     *      c. follow_up within window (merge mode) → reopen.
     *      d. new_task / out of window → fresh episode.
     *
     *   3. Neither: bootstrap a fresh episode.
     */
    async function openEpisodeIfNeeded(sessionId, userText, meta, agent) {
        const mergeMode = algorithm.session.followUpMode === "merge_follow_ups";
        const mergeCapMs = algorithm.session.mergeMaxGapMs;
        const turnTs = timestampFromMeta(meta, "startedAtTurnTs");
        if (lightweightMode) {
            const snap = await startLightweightEpisode(sessionId, userText, meta, turnTs);
            return { episode: snap, sessionId, relation: "lightweight_memory" };
        }
        // ─── Case 1: there is a currently open episode ──────────────────
        const currentEpId = openEpisodeBySession.get(sessionId);
        if (currentEpId) {
            const open = session.sessionManager.getEpisode(currentEpId);
            if (open && open.status === "open") {
                // Build a richer context for the relation classifier, mirroring
                // the legacy `buildTopicJudgeState`: include the initial topic
                // (first user message) plus the most recent user/assistant pair
                // so the classifier sees the episode's full theme, not just the
                // tail.
                const ctx = buildClassifierContext(open.turns);
                const lastTurnTs = open.turns[open.turns.length - 1]?.ts ?? open.startedAt;
                const gapMs = Math.max(0, (turnTs ?? now()) - lastTurnTs);
                const relationStartedAt = Date.now();
                const decision = await session.relation.classify({
                    prevUserText: ctx.prevUserText,
                    prevAssistantText: ctx.prevAssistantText,
                    newUserText: userText,
                    gapMs,
                    prevEpisodeId: currentEpId,
                });
                const relationDurationMs = Math.max(0, Date.now() - relationStartedAt);
                log.info("relation.classified", {
                    sessionId,
                    prevEpisodeId: currentEpId,
                    relation: decision.relation,
                    confidence: decision.confidence,
                    reason: decision.reason,
                    gapMs,
                    source: "open_episode",
                });
                buses.session.emit({
                    kind: "episode.relation_classified",
                    sessionId,
                    episodeId: currentEpId,
                    relation: decision.relation,
                    confidence: decision.confidence,
                    reason: decision.reason,
                });
                const withinMergeWindow = mergeCapMs === 0 || gapMs <= mergeCapMs;
                const keepAppending = mergeMode &&
                    withinMergeWindow &&
                    (decision.relation === "revision" ||
                        decision.relation === "follow_up" ||
                        decision.relation === "unknown");
                recordRelationClassification({
                    sessionId,
                    prevEpisodeId: currentEpId,
                    source: "open_episode",
                    gapMs,
                    mergeMode,
                    withinMergeWindow,
                    prevUserText: ctx.prevUserText,
                    prevAssistantText: ctx.prevAssistantText,
                    newUserText: userText,
                    decision,
                    action: keepAppending
                        ? "append_to_open_episode"
                        : decision.relation === "new_task"
                            ? "close_open_and_start_new_task"
                            : "close_open_and_start_new_episode",
                    durationMs: relationDurationMs,
                });
                if (keepAppending) {
                    // Same topic — just append the new user turn to the open
                    // episode. No finalize, no reflect; that's deferred until
                    // the user actually changes topic / closes the session.
                    session.sessionManager.addTurn(currentEpId, {
                        role: "user",
                        content: userText,
                        ts: turnTs,
                        meta: {
                            source: "follow_up",
                            classifiedRelation: decision.relation,
                            ...meta,
                        },
                    });
                    return { episode: open, sessionId, relation: decision.relation };
                }
                // Topic changed (new_task) OR gap too large OR
                // episode_per_turn mode — finalize the open episode, which
                // fires `episode.finalized` → captureSubscriber.runReflect →
                // R_human + V backprop. Fire-and-forget; the chain runs on
                // its own clock (tests can drive it via `flush()`).
                log.info("episode.topic_boundary_close", {
                    sessionId,
                    episodeId: currentEpId,
                    relation: decision.relation,
                    gapMs,
                    mergeMode,
                    withinMergeWindow,
                });
                session.sessionManager.finalizeEpisode(currentEpId);
                openEpisodeBySession.delete(sessionId);
                // V7 §0.1 "new task": previous episode's arc closes, but the
                // SESSION stays the same. OpenClaw maps `(agentId, sessionKey)`
                // to exactly one `bridgeSessionId`; minting a fresh session id
                // here used to leave two orphans behind —
                // (a) the brand-new empty episode because the bridge's
                //     `openEpisodeBySession` cache (keyed on the ORIGINAL
                //     sessionId) never saw the new id and fell into its
                //     lazy-open branch on `handleAgentEnd`, creating yet
                //     another episode under the old session;
                // (b) the never-ended "新任务" placeholder that surfaced in the
                //     task list as "未命名任务" (1 turns, empty dialogue).
                // Keeping `sessionId` stable collapses all of that: one session,
                // one open episode at a time, guaranteed. The `new_task`
                // distinction is preserved via `lastEpisodeBySession.delete`
                // (so no stale prev-episode is available for relation
                // reclassification on the next turn) and the episode's meta.
                if (decision.relation === "new_task") {
                    lastEpisodeBySession.delete(sessionId);
                    const snap = await session.sessionManager.startEpisode({
                        sessionId,
                        userMessage: userText,
                        ts: turnTs,
                        meta: { ...meta, relation: "new_task" },
                    });
                    openEpisodeBySession.set(sessionId, snap.id);
                    return { episode: snap, sessionId, relation: decision.relation };
                }
                // Same session, new episode (gap too long or
                // episode_per_turn). Snapshot the just-closed one for
                // possible later relation classification + reopen.
                lastEpisodeBySession.set(sessionId, {
                    episodeId: currentEpId,
                    endedAt: now(),
                    userText: ctx.prevUserText.slice(0, 1000),
                    assistantText: ctx.prevAssistantText.slice(0, 2000),
                });
                const fresh = await session.sessionManager.startEpisode({
                    sessionId,
                    userMessage: userText,
                    ts: turnTs,
                    meta: { ...meta, relation: decision.relation, gapMs },
                });
                openEpisodeBySession.set(sessionId, fresh.id);
                return { episode: fresh, sessionId, relation: decision.relation };
            }
            // Open episode disappeared (race). Fall through to the
            // closed-episode path below.
            openEpisodeBySession.delete(sessionId);
        }
        // ─── Case 2: there's a previously-closed episode ────────────────
        const prev = lastEpisodeBySession.get(sessionId);
        if (!prev) {
            const recoverable = findRecoverableOpenTopic(sessionId, turnTs ?? now());
            if (recoverable) {
                const snapshot = session.sessionManager.hydrateEpisode(recoverable);
                const ctx = buildClassifierContext(snapshot.turns);
                const lastTurnTs = snapshot.turns[snapshot.turns.length - 1]?.ts ?? snapshot.startedAt;
                const gapMs = Math.max(0, (turnTs ?? now()) - lastTurnTs);
                const hardWindowMs = staleTopicWindowMs();
                if (gapMs > hardWindowMs) {
                    log.info("episode.recovered_topic_hard_boundary", {
                        sessionId,
                        episodeId: snapshot.id,
                        gapMs,
                        hardWindowMs,
                    });
                    if (snapshot.status === "open") {
                        session.sessionManager.finalizeEpisode(snapshot.id, {
                            patchMeta: {
                                topicState: "ended",
                                recoveryReason: "hard_timeout_before_new_turn",
                            },
                        });
                    }
                }
                else {
                    const relationStartedAt = Date.now();
                    const decision = await session.relation.classify({
                        prevUserText: ctx.prevUserText,
                        prevAssistantText: ctx.prevAssistantText,
                        newUserText: userText,
                        gapMs,
                        prevEpisodeId: snapshot.id,
                    });
                    const relationDurationMs = Math.max(0, Date.now() - relationStartedAt);
                    log.info("relation.classified", {
                        sessionId,
                        prevEpisodeId: snapshot.id,
                        relation: decision.relation,
                        confidence: decision.confidence,
                        reason: decision.reason,
                        gapMs,
                        source: "recovered_open_topic",
                    });
                    buses.session.emit({
                        kind: "episode.relation_classified",
                        sessionId,
                        episodeId: snapshot.id,
                        relation: decision.relation,
                        confidence: decision.confidence,
                        reason: decision.reason,
                    });
                    const withinMergeWindow = mergeCapMs === 0 || gapMs <= mergeCapMs;
                    const keepAppending = mergeMode &&
                        withinMergeWindow &&
                        (decision.relation === "revision" ||
                            decision.relation === "follow_up" ||
                            decision.relation === "unknown");
                    recordRelationClassification({
                        sessionId,
                        prevEpisodeId: snapshot.id,
                        source: "recovered_open_topic",
                        gapMs,
                        mergeMode,
                        withinMergeWindow,
                        prevUserText: ctx.prevUserText,
                        prevAssistantText: ctx.prevAssistantText,
                        newUserText: userText,
                        decision,
                        action: keepAppending
                            ? "reopen_or_append_recovered_episode"
                            : "start_new_episode_after_recovered_topic",
                        durationMs: relationDurationMs,
                    });
                    if (keepAppending) {
                        if (snapshot.status === "closed") {
                            session.sessionManager.reopenEpisode(snapshot.id, decision.relation === "revision" ? "revision" : "follow_up");
                        }
                        session.sessionManager.addTurn(snapshot.id, {
                            role: "user",
                            content: userText,
                            ts: turnTs,
                            meta: {
                                source: "recovered_topic",
                                classifiedRelation: decision.relation,
                                previousSessionId: snapshot.sessionId,
                                ...meta,
                            },
                        });
                        openEpisodeBySession.set(sessionId, snapshot.id);
                        lastEpisodeBySession.delete(sessionId);
                        return {
                            episode: session.sessionManager.getEpisode(snapshot.id) ?? snapshot,
                            sessionId,
                            relation: decision.relation,
                        };
                    }
                    if (snapshot.status === "open") {
                        session.sessionManager.finalizeEpisode(snapshot.id, {
                            patchMeta: {
                                topicState: "ended",
                                boundaryRelation: decision.relation,
                                boundaryReason: decision.reason,
                            },
                        });
                    }
                }
            }
            // ─── Case 3: bootstrap ──────────────────────────────────────
            const snap = await session.sessionManager.startEpisode({
                sessionId,
                userMessage: userText,
                ts: turnTs,
                meta,
            });
            openEpisodeBySession.set(sessionId, snap.id);
            return { episode: snap, sessionId, relation: "bootstrap" };
        }
        const gapMs = Math.max(0, (turnTs ?? now()) - prev.endedAt);
        const relationStartedAt = Date.now();
        const decision = await session.relation.classify({
            prevUserText: prev.userText,
            prevAssistantText: prev.assistantText,
            newUserText: userText,
            gapMs,
            prevEpisodeId: prev.episodeId,
        });
        const relationDurationMs = Math.max(0, Date.now() - relationStartedAt);
        log.info("relation.classified", {
            sessionId,
            prevEpisodeId: prev.episodeId,
            relation: decision.relation,
            confidence: decision.confidence,
            reason: decision.reason,
            gapMs,
            source: "closed_episode",
        });
        buses.session.emit({
            kind: "episode.relation_classified",
            sessionId,
            episodeId: prev.episodeId,
            relation: decision.relation,
            confidence: decision.confidence,
            reason: decision.reason,
        });
        const withinMergeWindow = mergeCapMs === 0 || gapMs <= mergeCapMs;
        const shouldReopen = decision.relation === "revision" ||
            (mergeMode && decision.relation === "follow_up" && withinMergeWindow) ||
            (mergeMode && decision.relation === "unknown" && withinMergeWindow);
        recordRelationClassification({
            sessionId,
            prevEpisodeId: prev.episodeId,
            source: "closed_episode",
            gapMs,
            mergeMode,
            withinMergeWindow,
            prevUserText: prev.userText,
            prevAssistantText: prev.assistantText,
            newUserText: userText,
            decision,
            action: shouldReopen
                ? "reopen_previous_episode"
                : decision.relation === "new_task"
                    ? "start_new_task_episode"
                    : "start_new_episode",
            durationMs: relationDurationMs,
        });
        if (shouldReopen) {
            const reopenReason = decision.relation === "revision" ? "revision" : "follow_up";
            const snap = session.sessionManager.reopenEpisode(prev.episodeId, reopenReason);
            session.sessionManager.addTurn(prev.episodeId, {
                role: "user",
                content: userText,
                ts: turnTs,
                meta: {
                    source: reopenReason,
                    classifiedRelation: decision.relation,
                    ...meta,
                },
            });
            openEpisodeBySession.set(sessionId, prev.episodeId);
            lastEpisodeBySession.delete(sessionId);
            return { episode: snap, sessionId, relation: decision.relation };
        }
        if (decision.relation === "new_task") {
            // V7 §0.1 "new task": the previous episode's arc is closed, but
            // the SESSION stays the same. OpenClaw maps its (agentId,
            // sessionKey) pair to exactly one `bridgeSessionId`; minting a
            // fresh session id here used to leave two orphans behind —
            // (a) the brand-new empty episode (because the bridge's
            //     `openEpisodeBySession` cache keyed on the ORIGINAL
            //     sessionId never saw the new id and fell into its lazy-open
            //     branch on `handleAgentEnd`, creating yet another episode),
            // (b) the never-ended "新任务" placeholder that surfaced in the
            //     task list as "未命名任务".
            // Keeping sessionId stable collapses all of that: one session,
            // one open episode at a time, guaranteed.
            openEpisodeBySession.delete(sessionId);
            lastEpisodeBySession.delete(sessionId);
            const snap = await session.sessionManager.startEpisode({
                sessionId,
                userMessage: userText,
                ts: turnTs,
                meta: { ...meta, relation: "new_task" },
            });
            openEpisodeBySession.set(sessionId, snap.id);
            return { episode: snap, sessionId, relation: decision.relation };
        }
        const snap = await session.sessionManager.startEpisode({
            sessionId,
            userMessage: userText,
            ts: turnTs,
            meta: { ...meta, relation: decision.relation },
        });
        openEpisodeBySession.set(sessionId, snap.id);
        return { episode: snap, sessionId, relation: decision.relation };
    }
    function finalizeOpenEpisode(sessionId, rTask) {
        const id = openEpisodeBySession.get(sessionId);
        if (!id)
            return;
        const snap = session.sessionManager.getEpisode(id);
        if (!snap || snap.status !== "open") {
            openEpisodeBySession.delete(sessionId);
            return;
        }
        session.sessionManager.finalizeEpisode(id, {
            rTask: rTask ?? null,
        });
        openEpisodeBySession.delete(sessionId);
    }
    function staleTopicWindowMs() {
        return Math.max(algorithm.session.mergeMaxGapMs * 2, 4 * 60 * 60 * 1000);
    }
    function findRecoverableOpenTopic(currentSessionId, atTs) {
        const candidates = deps.repos.episodes.list({ limit: 50 });
        for (const row of candidates) {
            const meta = row.meta ?? {};
            if (meta.boundaryRelation === "new_task")
                continue;
            const ageMs = Math.max(0, atTs - (row.endedAt ?? row.startedAt));
            if (ageMs > staleTopicWindowMs())
                continue;
            if (row.status === "closed" && meta.closeReason !== "finalized")
                continue;
            if (row.sessionId !== currentSessionId &&
                meta.topicState !== "paused" &&
                meta.topicState !== "interrupted") {
                continue;
            }
            // Prefer the same session, but allow cross-session continuation
            // after Hermes/OpenClaw restarts. New-topic classification below
            // will close unrelated candidates before bootstrapping a fresh one.
            if (row.sessionId !== currentSessionId && candidates.length > 1) {
                const sameSession = candidates.some((c) => c.sessionId === currentSessionId);
                if (sameSession)
                    continue;
            }
            return snapshotFromOpenEpisodeRow(row);
        }
        return null;
    }
    function snapshotFromOpenEpisodeRow(ep) {
        const traceIds = (ep.traceIds ?? []);
        const traces = traceIds.length > 0
            ? deps.repos.traces
                .getManyByIds(traceIds)
                .sort((a, b) => a.ts - b.ts)
            : [];
        const turns = [];
        const meta = ep.meta ?? {};
        const initialUserText = typeof meta.initialUserText === "string"
            ? meta.initialUserText
            : typeof meta.pendingUserText === "string"
                ? meta.pendingUserText
                : "";
        if (initialUserText && traces.length === 0) {
            turns.push({
                id: `${ep.id}:initial-user`,
                ts: ep.startedAt,
                role: "user",
                content: initialUserText,
                meta: { recovered: true },
            });
        }
        for (const tr of traces) {
            if (tr.userText) {
                turns.push({
                    id: `${tr.id}:user`,
                    ts: tr.ts,
                    role: "user",
                    content: tr.userText,
                });
            }
            if (tr.toolCalls.length > 0) {
                turns.push({
                    id: `${tr.id}:tool`,
                    ts: tr.ts,
                    role: "tool",
                    content: JSON.stringify(tr.toolCalls),
                    meta: { toolCalls: tr.toolCalls },
                });
            }
            if (tr.agentText) {
                turns.push({
                    id: `${tr.id}:assistant`,
                    ts: tr.ts,
                    role: "assistant",
                    content: tr.agentText,
                    meta: {
                        agentThinking: tr.agentThinking ?? undefined,
                        reflection: tr.reflection ?? undefined,
                    },
                });
            }
        }
        const maybeIntent = meta.intent;
        return {
            id: ep.id,
            sessionId: ep.sessionId,
            startedAt: ep.startedAt,
            endedAt: ep.endedAt ?? null,
            status: ep.status,
            rTask: ep.rTask ?? null,
            turnCount: turns.length,
            turns,
            traceIds,
            meta,
            intent: {
                kind: maybeIntent?.kind ?? "unknown",
                confidence: maybeIntent?.confidence ?? 0,
                reason: maybeIntent?.reason ?? "recovered open topic",
                retrieval: maybeIntent?.retrieval ?? { tier1: true, tier2: true, tier3: true },
                signals: maybeIntent?.signals ?? ["recovered_open_topic"],
                llmModel: maybeIntent?.llmModel,
            },
        };
    }
    // ─── subscribeEvents / subscribeLogs ────────────────────────────────────
    function subscribeEvents(handler) {
        eventListeners.add(handler);
        return () => eventListeners.delete(handler);
    }
    const logSubscription = onBroadcastLog((record) => {
        for (const listener of logListeners) {
            try {
                listener(record);
            }
            catch (err) {
                log.warn("log.listener_threw", {
                    err: err instanceof Error ? err.message : String(err),
                });
            }
        }
    });
    function subscribeLogs(handler) {
        // Replay the last window so adapters that subscribe late still
        // capture recent context.
        for (const rec of memoryBuffer().tail({ limit: 64 }).reverse()) {
            try {
                handler(rec);
            }
            catch {
                /* adapter is responsible */
            }
        }
        logListeners.add(handler);
        return () => logListeners.delete(handler);
    }
    // ─── Retrieval entry points ─────────────────────────────────────────────
    const retrievalDeps = buildRetrievalDeps(deps, algorithm);
    const turnStartRetrievalStats = new Map();
    function retrievalDepsFor(namespace = deps.namespace) {
        return {
            ...retrievalDeps,
            namespace,
            repos: wrapRetrievalRepos(deps.repos, namespace),
        };
    }
    async function retrieveTurnStart(input, plan) {
        const ctx = {
            reason: "turn_start",
            agent: input.agent,
            sessionId: input.sessionId,
            episodeId: input.episodeId,
            userText: input.userText,
            contextHints: input.contextHints,
            ts: input.ts,
        };
        const result = await turnStartRetrieve(retrievalDepsFor(input.namespace), ctx, {
            events: buses.retrieval,
            skipLlmFilter: input.contextHints?.__memosDeferLlmFilterToCaller === true,
            plan: plan
                ? {
                    scenarioId: plan.scenarioId,
                    wantTier1: plan.wantTier1,
                    wantTier2: plan.wantTier2,
                    wantTier3: plan.wantTier3,
                }
                : undefined,
        });
        turnStartRetrievalStats.set(result.packet.packetId, result.stats);
        return result.packet;
    }
    function consumeRetrievalStats(packetId) {
        const stats = turnStartRetrievalStats.get(packetId) ?? null;
        turnStartRetrievalStats.delete(packetId);
        return stats;
    }
    async function retrieveToolDriven(ctx) {
        const result = await toolDrivenRetrieve(retrievalDepsFor(ctx.namespace), { reason: "tool_driven", ...ctx }, { events: buses.retrieval });
        return result.packet;
    }
    async function retrieveSkillInvoke(ctx) {
        const result = await skillInvokeRetrieve(retrievalDepsFor(ctx.namespace), { reason: "skill_invoke", ...ctx }, { events: buses.retrieval });
        return result.packet;
    }
    async function retrieveSubAgent(ctx) {
        const result = await subAgentRetrieve(retrievalDepsFor(ctx.namespace), { reason: "sub_agent", ...ctx }, { events: buses.retrieval });
        return result.packet;
    }
    async function retrieveRepair(ctx) {
        const result = await repairRetrieve(retrievalDepsFor(ctx.namespace), { reason: "decision_repair", ...ctx }, { events: buses.retrieval });
        return result ? result.packet : null;
    }
    // ─── Turn lifecycle ─────────────────────────────────────────────────────
    async function onTurnStart(input) {
        const t0 = now();
        const initialSessionId = await ensureSession(input.agent, input.sessionId, input.contextHints);
        const routing = await openEpisodeIfNeeded(initialSessionId, input.userText, {
            ...(input.contextHints ?? {}),
            contextHints: input.contextHints ?? {},
            agent: input.agent,
            startedAtTurnTs: input.ts,
        }, input.agent);
        const sessionId = routing.sessionId;
        const episode = routing.episode;
        lastUserTextBySession.set(sessionId, input.userText);
        const normalized = {
            ...input,
            sessionId,
            episodeId: episode.id,
        };
        const schedulerIntent = await intentForCurrentTurn({
            episode,
            userText: input.userText,
            ts: input.ts,
        });
        const retrievePlan = scheduleInjection({
            userText: input.userText,
            sessionId,
            episodeId: episode.id,
            intent: schedulerIntent,
            relation: schedulerRelation(routing.relation),
        });
        try {
            if (retrievePlan.entry === "turn_start_skip") {
                const packet = emptyInjectionPacket(input.agent, sessionId, episode.id, input.ts);
                turnStartRetrievalStats.set(packet.packetId, skippedRetrievalStats({
                    agent: input.agent,
                    sessionId,
                    episodeId: episode.id,
                    scenarioId: retrievePlan.scenarioId,
                    userText: input.userText,
                    elapsedMs: now() - t0,
                }));
                log.info("turn.started", {
                    agent: input.agent,
                    sessionId,
                    episodeId: episode.id,
                    userChars: input.userText.length,
                    retrievalScenario: retrievePlan.scenarioId,
                    retrievalSkipped: true,
                    retrievalTotalMs: 0,
                    elapsedMs: now() - t0,
                });
                return packet;
            }
            const packet = await retrieveTurnStart(normalized, retrievePlan);
            // Always stamp the routed sessionId + episodeId on the packet so
            // adapters can correlate the subsequent `agent_end` / `turn.end`
            // call without needing a separate round-trip to the session
            // manager. Without this, the adapter-side `openEpisodeBySession`
            // cache stays empty and `onTurnEnd` falls back to a synthetic
            // episode id that fails DB lookup.
            const stamped = {
                ...packet,
                sessionId,
                episodeId: episode.id,
            };
            log.info("turn.started", {
                agent: input.agent,
                sessionId,
                episodeId: episode.id,
                userChars: input.userText.length,
                retrievalScenario: retrievePlan.scenarioId,
                retrievalTotalMs: packet.tierLatencyMs.tier1 +
                    packet.tierLatencyMs.tier2 +
                    packet.tierLatencyMs.tier3,
                elapsedMs: now() - t0,
            });
            return stamped;
        }
        catch (err) {
            log.error("turn.retrieval_failed", {
                agent: input.agent,
                sessionId,
                episodeId: episode.id,
                err: err instanceof Error ? err.message : String(err),
            });
            return emptyInjectionPacket(input.agent, sessionId, episode.id, input.ts);
        }
    }
    async function onTurnEnd(result) {
        const sessionId = await ensureSession(result.agent, result.sessionId, result.contextHints);
        const episodeId = openEpisodeBySession.get(sessionId) ?? result.episodeId;
        if (!episodeId) {
            throw new Error("pipeline.onTurnEnd: no open episode for session " + sessionId);
        }
        const episode = session.sessionManager.getEpisode(episodeId);
        if (!episode || episode.status !== "open") {
            throw new Error("pipeline.onTurnEnd: episode " + episodeId + " is not open");
        }
        if (result.contextHints && Object.keys(result.contextHints).length > 0) {
            session.sessionManager.patchEpisodeMeta(episodeId, {
                contextHints: {
                    ...(episode.meta.contextHints ?? {}),
                    ...result.contextHints,
                },
            });
        }
        // V7 §0.1: record tool-call turns BEFORE the assistant turn so the
        // episode snapshot contains the full execution trace in chronological
        // order. This mirrors the legacy `memos-local-openclaw` adapter which
        // stored tool messages as separate chunks with `role: "tool"`.
        // Without these turns the capture step-extractor still picks up
        // `meta.toolCalls`, but the viewer's timeline and the reward scorer
        // need the turns to count exchanges correctly and display the chat log.
        for (const tc of result.toolCalls) {
            session.sessionManager.addTurn(episodeId, {
                role: "tool",
                content: typeof tc.output === "string"
                    ? tc.output
                    : tc.output != null
                        ? JSON.stringify(tc.output).slice(0, 2000)
                        : "",
                ts: Number.isFinite(tc.endedAt) ? tc.endedAt : result.ts,
                meta: {
                    tool: tc.name,
                    name: tc.name,
                    input: tc.input,
                    errorCode: tc.errorCode,
                    toolCallId: tc.toolCallId,
                    startedAt: tc.startedAt,
                    endedAt: tc.endedAt,
                    // V7 §0.1: preserve the model's "Thought for X" narration that
                    // precedes this call so `step-extractor` can re-attach it to
                    // the captured ToolCallDTO. Without this, chained tool calls
                    // lose the natural-language bridge between steps.
                    thinkingBefore: tc.thinkingBefore,
                    assistantTextBefore: tc.assistantTextBefore,
                },
            });
        }
        session.sessionManager.addTurn(episodeId, {
            role: "assistant",
            content: result.agentText,
            ts: result.ts,
            meta: {
                toolCalls: result.toolCalls,
                // V7 §0.1 split:
                //   - `agentThinking` = LLM-native thinking (Claude extended,
                //     pi-ai ThinkingContent). Belongs to the conversation log.
                //   - `reflection` = adapter-supplied (rare). NEVER shown in
                //     chat — the topic-end reflect pass writes the canonical
                //     reflection field on the trace row.
                agentThinking: result.agentThinking ?? null,
                reflection: result.reflection ?? null,
                contextHints: result.contextHints ?? {},
                ts: result.ts,
            },
        });
        // Snapshot the now-augmented episode and run the lite capture
        // pass. This writes a trace row for the new step with
        // `reflection=null` + `alpha=0` so the viewer can show the
        // memory immediately — but no scoring happens yet. The full
        // reflect + reward chain only fires when the topic actually ends
        // (next turn classified as `new_task`, idle timeout, session_end,
        // or shutdown).
        const liveEpisode = session.sessionManager.getEpisode(episodeId);
        let liteTraceIds = [];
        if (liveEpisode) {
            try {
                const captureResult = isLightweightEpisode(liveEpisode)
                    ? await subs.captureRunner.runLightweight({ episode: liveEpisode })
                    : await subs.captureRunner.runLite({ episode: liveEpisode });
                liteTraceIds = captureResult.traceIds;
                if (captureResult.traceIds.length > 0) {
                    session.sessionManager.attachTraceIds(episodeId, captureResult.traceIds);
                }
            }
            catch (err) {
                log.warn("turn.lite_capture.failed", {
                    episodeId,
                    err: err instanceof Error ? err.message : String(err),
                });
            }
        }
        // Update the "current open episode" snapshot so the relation
        // classifier on the NEXT onTurnStart can decide whether the user
        // changed topic. We mirror the data shape of `lastEpisodeBySession`
        // even though the episode isn't closed yet — the classifier doesn't
        // care about `endedAt`, only about prev-user / prev-assistant text.
        const initialUserTurn = liveEpisode?.turns.find((t) => t.role === "user");
        if (!lightweightMode) {
            lastEpisodeBySession.set(sessionId, {
                episodeId,
                endedAt: now(),
                userText: (initialUserTurn?.content ?? "").slice(0, 1000),
                assistantText: (result.agentText ?? "").slice(0, 2000),
            });
        }
        log.info("turn.ended", {
            agent: result.agent,
            sessionId,
            episodeId,
            toolCalls: result.toolCalls.length,
            agentChars: result.agentText.length,
        });
        // The episode stays OPEN — finalize is deferred to topic end.
        return {
            traceCount: liteTraceIds.length,
            traceIds: liteTraceIds,
            episodeId: episodeId,
            episode: liveEpisode ?? null,
            episodeFinalized: false,
            asyncWorkScheduled: true,
        };
    }
    // ─── Tool outcomes (decision repair) ────────────────────────────────────
    function recordToolOutcome(outcome) {
        if (lightweightMode)
            return;
        const sessionId = outcome.sessionId;
        const context = outcome.context ??
            lastUserTextBySession.get(sessionId) ??
            sessionId;
        if (outcome.success) {
            subs.feedback.recordToolSuccess({
                toolId: outcome.tool,
                context,
                step: outcome.step,
                sessionId,
                episodeId: outcome.episodeId,
            });
            return;
        }
        subs.feedback.recordToolFailure({
            toolId: outcome.tool,
            context,
            step: outcome.step,
            reason: outcome.errorCode ?? "unknown",
            sessionId,
            episodeId: outcome.episodeId,
        });
    }
    // ─── flush / shutdown ───────────────────────────────────────────────────
    async function flush() {
        // Order matters: capture writes traces, reward reads them and
        // emits `reward.updated` → L2 induces policies & emits
        // `l2.policy.induced` → L3 abstracts world models → skills
        // crystallize. Each layer is its own subscriber, so we walk the
        // chain explicitly. A few ticks between waits let scheduled
        // `void run(...)` promises register in their owners' inflight
        // sets before we ask each one to drain.
        const nextTick = () => new Promise((resolve) => setImmediate(resolve));
        await subs.subscriptions.capture.drain();
        if (lightweightMode) {
            await embeddingRetryWorker.flush();
            return;
        }
        await nextTick();
        await subs.subscriptions.reward.drain();
        await nextTick();
        // L2 receives `reward.updated` and runs the induce/associate
        // pipeline (LLM call, ~5s). Without draining it here, single-
        // shot adapters (Hermes' `chat -q`) reap the bridge before L2
        // finishes — the candidate pool fills up but no policy is ever
        // induced.
        await subs.l2.drain();
        await nextTick();
        // L3 reacts to `l2.policy.induced`. Same problem, same fix.
        await subs.l3.drain();
        await nextTick();
        await subs.skills.flush();
        await subs.feedback.flush();
        await embeddingRetryWorker.flush();
    }
    async function shutdown(reason = "shutdown") {
        log.info("pipeline.shutdown.begin", { reason });
        try {
            await flush();
        }
        catch (err) {
            log.warn("pipeline.flush_failed", {
                err: err instanceof Error ? err.message : String(err),
            });
        }
        // Detach subscribers — prevents late events from re-queuing work.
        subs.subscriptions.capture.stop();
        subs.subscriptions.reward.stop();
        subs.l2.detach();
        subs.l3.detach();
        subs.skills.dispose();
        subs.feedback.dispose();
        embeddingRetryWorker.stop();
        bridge.dispose();
        logSubscription();
        session.sessionManager.shutdown(reason);
        log.info("pipeline.shutdown.done", { reason });
    }
    function now() {
        return (deps.now ?? Date.now)();
    }
    function recordRelationClassification(input) {
        if (lightweightMode)
            return;
        try {
            deps.repos.apiLogs.insert({
                toolName: "session_relation_classify",
                input: {
                    sessionId: input.sessionId,
                    prevEpisodeId: input.prevEpisodeId,
                    source: input.source,
                    gapMs: input.gapMs,
                    mergeMode: input.mergeMode,
                    withinMergeWindow: input.withinMergeWindow,
                    prevUserText: truncateForLog(input.prevUserText, 600),
                    prevAssistantText: truncateForLog(input.prevAssistantText, 900),
                    newUserText: truncateForLog(input.newUserText, 600),
                },
                output: {
                    relation: input.decision.relation,
                    confidence: input.decision.confidence,
                    reason: input.decision.reason,
                    signals: input.decision.signals,
                    llmModel: input.decision.llmModel,
                    action: input.action,
                },
                durationMs: input.durationMs,
                success: true,
                calledAt: Date.now(),
            });
        }
        catch (err) {
            log.debug("apiLogs.session_relation_classify.skipped", {
                err: err instanceof Error ? err.message : String(err),
            });
        }
    }
    function truncateForLog(text, maxChars) {
        if (text.length <= maxChars)
            return text;
        return `${text.slice(0, maxChars)}...`;
    }
    function timestampFromMeta(meta, key) {
        const ts = meta[key];
        return typeof ts === "number" && Number.isFinite(ts) ? ts : undefined;
    }
    async function intentForCurrentTurn(input) {
        const firstTurn = input.episode.turns[0];
        const isFreshEpisodeForThisTurn = input.episode.turns.length === 1 &&
            firstTurn?.role === "user" &&
            firstTurn.content === input.userText &&
            (input.ts == null || firstTurn.ts === input.ts);
        if (isFreshEpisodeForThisTurn) {
            return input.episode.intent;
        }
        return session.intent.classify(input.userText, {
            episodeId: input.episode.id,
        });
    }
    /**
     * Build richer context for the relation classifier from episode turns.
     *
     * Mirrors legacy `buildTopicJudgeState`: includes the first user message
     * (topic anchor) plus the most recent user/assistant pair, so the
     * classifier sees the episode's overall theme — not just the tail.
     * This prevents false `new_task` splits when a later turn circles back
     * to the original topic after a tangent.
     */
    function buildClassifierContext(turns) {
        const userTurns = turns.filter((t) => t.role === "user");
        const assistantTurns = turns.filter((t) => t.role === "assistant");
        const firstUser = userTurns[0]?.content ?? "";
        const lastUser = userTurns[userTurns.length - 1]?.content ?? "";
        const lastAssistant = assistantTurns[assistantTurns.length - 1]?.content ?? "";
        // For single-turn episodes the first and last are the same.
        let prevUserText;
        if (userTurns.length <= 1 || firstUser === lastUser) {
            prevUserText = lastUser.slice(0, 1000);
        }
        else {
            // Multi-turn: pack the initial topic + the most recent user query.
            prevUserText = [
                `[Task topic]: ${firstUser.slice(0, 300)}`,
                `[Latest user message]: ${lastUser.slice(0, 700)}`,
            ].join("\n\n");
        }
        return {
            prevUserText,
            prevAssistantText: lastAssistant.slice(0, 2000),
        };
    }
    // ─── Handle object ──────────────────────────────────────────────────────
    const handle = {
        agent: deps.agent,
        home: deps.home,
        config: deps.config,
        algorithm,
        namespace: deps.namespace,
        db: deps.db,
        repos: deps.repos,
        llm: deps.llm,
        reflectLlm: deps.reflectLlm,
        embedder: deps.embedder,
        sessionManager: session.sessionManager,
        episodeManager: session.episodeManager,
        intent: session.intent,
        relation: session.relation,
        captureRunner: subs.captureRunner,
        rewardRunner: subs.rewardRunner,
        l2: subs.l2,
        l3: subs.l3,
        skills: subs.skills,
        feedback: subs.feedback,
        buses,
        subscribeEvents,
        getRecentEvents,
        subscribeLogs,
        onTurnStart,
        consumeRetrievalStats,
        onTurnEnd,
        recordToolOutcome,
        retrieveToolDriven,
        retrieveSkillInvoke,
        retrieveSubAgent,
        retrieveRepair,
        flush,
        shutdown,
        retrievalDeps: () => retrievalDeps,
    };
    log.info("pipeline.ready", {
        agent: deps.agent,
        home: deps.home.root,
        algorithm: {
            captureEmbed: algorithm.capture.embedTraces,
            rewardDecayDays: algorithm.reward.decayHalfLifeDays,
            l2MinSim: algorithm.l2Induction.minSimilarity,
            skillMinSupport: algorithm.skill.minSupport,
            feedbackThreshold: algorithm.feedback.failureThreshold,
        },
    });
    // We expose the contextHashOf helper indirectly for tests that want to
    // assert the subscriber is seeing the right context bucket.
    void contextHashOf; // reference to make bundlers keep the symbol
    void _assertConfigShape(algorithm, deps.config.algorithm.feedback);
    return handle;
}
function emptyInjectionPacket(_agent, sessionId, episodeId, ts) {
    return {
        reason: "turn_start",
        snippets: [],
        rendered: "",
        tierLatencyMs: { tier1: 0, tier2: 0, tier3: 0 },
        packetId: `empty:${sessionId}:${episodeId}:${ts}`,
        ts,
        sessionId,
        episodeId,
    };
}
function skippedRetrievalStats(input) {
    return {
        reason: "turn_start",
        scenarioId: input.scenarioId,
        agent: input.agent,
        sessionId: input.sessionId,
        episodeId: input.episodeId,
        plannedTiers: { tier1: false, tier2: false, tier3: false },
        tier1Count: 0,
        tier2Count: 0,
        tier3Count: 0,
        tier1LatencyMs: 0,
        tier2LatencyMs: 0,
        tier3LatencyMs: 0,
        fuseLatencyMs: 0,
        totalLatencyMs: Math.max(0, input.elapsedMs),
        queryTokens: Math.ceil(input.userText.length / 4),
        queryTags: [],
        emptyPacket: true,
        embedding: {
            attempted: false,
            ok: false,
            degraded: false,
        },
        rawCandidateCount: 0,
        droppedByThresholdCount: 0,
        thresholdFloor: 0,
        topRelevance: 0,
        rankedCount: 0,
        llmFilterOutcome: "skipped_by_scheduler",
        llmFilterSufficient: true,
        llmFilterKept: 0,
        llmFilterDropped: 0,
        channelHits: {},
    };
}
function schedulerRelation(relation) {
    if (relation === "revision" ||
        relation === "follow_up" ||
        relation === "new_task" ||
        relation === "unknown" ||
        relation === "bootstrap" ||
        relation === "lightweight_memory") {
        return relation;
    }
    return undefined;
}
function _assertConfigShape(algorithm, feedback) {
    // Pure-TypeScript assertion: would fail type-check if shape drifted.
    // Kept live at runtime to make the call path visible in stack traces.
    if (!algorithm.feedback)
        throw new Error("feedback config missing");
    if (!feedback.failureThreshold)
        throw new Error("failureThreshold missing");
}
//# sourceMappingURL=orchestrator.js.map