/**
 * Domain API layer for Hippo.
 *
 * Pure functions taking a Context (hippoRoot + tenantId + actor) plus
 * operation options. Both the CLI (direct mode) and the HTTP server
 * (`hippo serve`, A1) call into this module so the business logic lives
 * in exactly one place.
 */
import { createHash } from 'node:crypto';
import { openHippoDb, closeHippoDb } from './db.js';
import { writeEntry, writeEntryDbOnly, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, RECALL_DEFAULT_DENY_SCOPES, removeEntryMirrors, loadActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, loadIndex, saveIndex, loadAllEntries, updateStats, isInitialized, markSummaryDirtyInTx, } from './store.js';
import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
import { appendAuditEvent, queryAuditEvents, auditMemories, } from './audit.js';
import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
import { archiveRawMemory } from './raw-archive.js';
import { createApiKey, listApiKeys, revokeApiKey, } from './auth.js';
import { applyGoalStackBoost } from './goals.js';
import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
import { scopeMatch } from './scope.js';
import { consolidate } from './consolidate.js';
import { loadConfig } from './config.js';
import { deduplicateStore } from './dedupe.js';
import { computeAmbientState } from './ambient.js';
import { computePlanningFallacyOutput, } from './predictions.js';
import { detectAnchoring, hashQueryText, } from './recall-history.js';
import { detectAvailabilityBias } from './availability.js';
/**
 * Helper for building process-local (admin-by-default) Actor values. v1.12.0
 * factory used by CLI / MCP / connector Context constructors so the role
 * boilerplate isn't repeated at every site. Bearer-authed callers (HTTP
 * /v1/*) construct Actor directly from the api_keys row's role column via
 * buildContextWithAuth in src/server.ts.
 */
export function adminActor(subject) {
    return { subject, role: 'admin' };
}
/**
 * Thrown by `api.recall` when a caller's options violate a recall contract
 * that has been opted into via env. Carries a stable `code` field for HTTP /
 * MCP / CLI render paths to discriminate without parsing the message.
 *
 * Codes:
 *   - 'fresh_tail_requires_session_id' — `freshTailCount > 0` AND no
 *     `freshTailSessionId` AND `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1`.
 *     Default behaviour (env unset) returns tenant-wide rows; the env gate
 *     is opt-in so multi-session tenants can fail loud instead of silently
 *     surfacing cross-session rows tagged `isFreshTail=true`.
 *   - 'invalid_scorer_window' — `opts.scorerWindow` is set to a non-positive,
 *     non-integer, or non-finite value. Pre-v1.7.0 the value 0 routed
 *     through FTS/LIKE `LIMIT 0` and then fell through to an uncapped
 *     full-store fallback (codex v1.7.0 diff-pass P1). Validated upfront
 *     so the contract holds.
 */
export class RecallContractError extends Error {
    code;
    constructor(code, message) {
        super(message);
        this.name = 'RecallContractError';
        this.code = code;
    }
}
/**
 * v1.2.1: source-agnostic private-scope detector. A scope string is treated
 * as private when it has the shape `<lowercase-source>:private:<rest>`.
 *
 * Examples that match:
 *   slack:private:Cabc, github:private:owner/repo, jira:private:PROJ-1
 * Examples that DO NOT match:
 *   slack:public:Cgeneral, acme:public:my-private-channel, null, '',
 *   'unknown:legacy', 'private' (alone), 'private:foo' (no source prefix).
 *
 * Used by api.recall, mcp/server.ts (hippo_recall + hippo_context),
 * cli.ts (cmdRecall continuity). Keep these in sync — the export is the
 * single source of truth so v1.3 GitHub work cannot drift.
 */
const PRIVATE_SCOPE_RE = /^[a-z][a-z0-9_-]*:private:/;
/**
 * Recall-side scope filter. Mirrors the inline rule in `recall` continuity
 * filtering and the row filter at api.ts:198-205. Lifted to a helper so
 * the v1.5.0 DAG substitution path can reuse it without duplicating logic.
 *
 * - When `requested` is set and non-empty: exact match required.
 * - When `requested` is undefined/empty: default-deny on any
 *   `<source>:private:*` scope and on the `unknown:legacy` quarantine bucket.
 *   `null` and public scopes pass.
 */
/**
 * @internal v1.7.2 — exported for test parity with `RECALL_DEFAULT_DENY_SCOPES`
 * (single-source-of-truth verification). NOT part of the public API surface;
 * not re-exported from `src/index.ts`. Subject to change without semver bump.
 */
export function passesScopeFilterForRecall(scope, requested) {
    if (requested !== undefined && requested !== '') {
        return scope === requested;
    }
    if (scope === null)
        return true;
    if (isPrivateScope(scope))
        return false;
    // v1.7.2 — read from RECALL_DEFAULT_DENY_SCOPES (single source of truth
    // shared with the SQL clause in loadSearchRows). Cast the array to
    // readonly string[] so .includes() accepts arbitrary string scopes
    // without a cast on the input (codex P0-2: casting `scope` would defeat
    // the constant's safety).
    if (RECALL_DEFAULT_DENY_SCOPES.includes(scope))
        return false;
    return true;
}
export function isPrivateScope(scope) {
    return typeof scope === 'string' && PRIVATE_SCOPE_RE.test(scope);
}
export function remember(ctx, opts) {
    const entry = createMemory(opts.content, {
        kind: opts.kind ?? 'distilled',
        scope: opts.scope ?? null,
        owner: opts.owner ?? null,
        artifact_ref: opts.artifactRef ?? null,
        tags: opts.tags,
        tenantId: ctx.tenantId,
    });
    // writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
    // one 'remember' event lands in the log with the supplied actor.
    writeEntry(ctx.hippoRoot, entry, { actor: ctx.actor.subject, afterWrite: opts.afterWrite });
    return { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
}
/**
 * Shared construction helper for `RecallSuppressionSummary`. Used by
 * `api.recall`, `cmdRecall`, and the MCP `hippo_recall` handler so all three
 * pipelines produce the same shape without duplicating field-construction
 * logic. Pass-through identity today; kept as a helper so future field
 * additions (B4 interference counter wiring, etc.) land at one site.
 */
export function buildSuppressionSummary(counts) {
    return {
        totalCandidates: counts.totalCandidates,
        droppedPreRank: counts.droppedPreRank,
        droppedByBudget: counts.droppedByBudget,
        summarySubstitutionsAdded: counts.summarySubstitutionsAdded,
        freshTailAdded: counts.freshTailAdded,
        suppressedByInterference: counts.suppressedByInterference,
    };
}
/**
 * Domain-level recall. Loads BM25-ranked candidates from SQLite scoped to
 * `ctx.tenantId`. The `mode` flag is accepted for forward compatibility (the
 * CLI exposes hybrid/physics paths) but Task 2 wires only the BM25 candidate
 * loader; later tasks can extend this to call the physics/hybrid scorer.
 *
 * **api.recall does NOT mutate `index.last_retrieval_ids`** (v1.11.5 contract
 * lock). The CLI `cmdRecall` (cli.ts) writes `last_retrieval_ids` because the
 * CLI is interactive (user is about to run `hippo outcome --good`). SDK callers
 * are programmatic: they either pass explicit ids to `api.outcome` or call
 * `api.getContext` first for the context-then-outcome workflow (getContext
 * DOES write `last_retrieval_ids`). Adding the side-effect here would change
 * `api.recall` from a pure read into a read+write, breaking SDK callers who
 * batch recall calls in a row. Locked by
 * `tests/api-recall-no-side-effects.test.ts`.
 */
export function recall(ctx, opts) {
    const limit = opts.limit ?? 10;
    // F5 (v1.6.5) preflight — codex P1: original guard fired AFTER
    // loadSearchEntries (which runs initStore, migrating legacy state on first
    // call). For a true contract preflight we want the throw before any
    // store-touching work. Single check here; the consumer site at
    // `if (freshTailCount > 0)` does NOT re-validate (would be a no-op).
    const freshTailCountPreflight = opts.freshTailCount ?? 0;
    if (freshTailCountPreflight > 0 &&
        !opts.freshTailSessionId &&
        process.env.HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL === '1') {
        throw new RecallContractError('fresh_tail_requires_session_id', 'fresh-tail requires a session id when HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1; ' +
            'pass opts.freshTailSessionId or unset the env to allow tenant-wide fresh-tail.');
    }
    // F3 (v1.7.0): scorerWindow opt-in. When undefined (default),
    // loadSearchEntries uses its own store-internal default — this
    // preserves every pre-v1.7.0 caller's behaviour bit-for-bit (codex
    // mk2-pass P0-1: defaulting to `limit` would have shrunk the
    // candidate pool and killed overflow summaries).
    // DEFAULT_SEARCH_CANDIDATE_LIMIT is imported from store.ts so the two
    // values cannot drift (codex diff-pass P1 #3).
    // Validate the input — codex diff-pass P1 #1 caught that scorerWindow=0
    // would route through FTS/LIKE LIMIT 0 and then fall through to an
    // uncapped full-store fallback. Reject non-positive / non-finite values.
    if (opts.scorerWindow !== undefined) {
        if (!Number.isFinite(opts.scorerWindow) ||
            !Number.isInteger(opts.scorerWindow) ||
            opts.scorerWindow < 1) {
            throw new RecallContractError('invalid_scorer_window', `scorerWindow must be a positive integer; got ${opts.scorerWindow}`);
        }
    }
    const windowSize = opts.scorerWindow ?? DEFAULT_SEARCH_CANDIDATE_LIMIT;
    // v1.7.1 — root-cause fix for the `unknown:legacy` leak. Scope predicate
    // is now pushed into `loadSearchRows` SQL via `loadRecallSearchEntries`.
    // - opts.scope undefined / '': SQL excludes `unknown:legacy`.
    // - opts.scope non-empty: SQL exact-matches m.scope = opts.scope.
    // Tenant predicate still runs first, so a tenant-mismatched scope cannot
    // surface another tenant's row even when both share the same scope string.
    //
    // **CALLER CONTRACT:** any future recall-mode loader MUST go through
    // `loadRecallSearchEntries` (or invoke the SQL scope predicate equivalently).
    // Calling `loadSearchEntries` from this code path re-introduces the v1.6.5
    // codex-flagged leak. See `passesScopeFilterForRecall` in this file for
    // the canonical recall-side scope rule (kept in sync with the SQL clause
    // in loadSearchRows).
    //
    // Also fixes a latent code smell: pre-v1.7.1 passed `opts.scorerWindow`
    // (raw, possibly undefined) where `windowSize` was intended.
    // v1.12.13 / C5 — WYSIATI counters. Declared BEFORE the load step so the
    // assignments at the existing filter sites (load / scope-filter / limit-
    // slice / substitution / fresh-tail) are after declaration. The return at
    // end-of-function reads them via buildSuppressionSummary.
    let totalCandidatesCount = 0;
    let droppedPreRankCount = 0;
    let droppedByBudgetCount = 0;
    let summarySubstitutionsCount = 0;
    let freshTailAddedCount = 0;
    const all = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope);
    // v1.12.13 / C5 — WYSIATI totalCandidates counter (post tenant + SQL scope
    // predicate, pre JS scope filter).
    totalCandidatesCount = all.length;
    let entries;
    if (opts.scope !== undefined && opts.scope !== '') {
        // SQL already exact-matched in loadRecallSearchEntries; keep the JS
        // filter as defense-in-depth so a future SQL-clause regression cannot
        // silently surface cross-scope rows.
        entries = all.filter((e) => e.scope === opts.scope);
    }
    else {
        // SQL already excluded `unknown:legacy`. The remaining JS filter
        // covers the regex-only `<source>:private:*` rule (v1.2.1 generalization
        // from `slack:private:*` to any source: connector authors cannot silently
        // surface private rows to no-scope callers).
        entries = all.filter((e) => !isPrivateScope(e.scope ?? null));
    }
    // v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
    // for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
    // into the same field per the plan's Task 3 mapping table).
    droppedPreRankCount = all.length - entries.length;
    // BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
    // Score is a placeholder — the physics/hybrid scorers in src/search.ts
    // produce richer breakdowns and will replace this when wired up.
    let baseSlice = entries.slice(0, limit);
    // v1.12.13 / C5 — WYSIATI dropped_by_budget counter (candidates loaded but
    // excluded by the final limit slice).
    droppedByBudgetCount = entries.length - baseSlice.length;
    // v1.7.4 -- single db handle for the goal-stack boost AND the audit-event
    // emit below (codex P1: do not open a second short-lived handle for the
    // appendAuditEvent call). The handle is closed in the matching `finally`
    // immediately above the continuity block.
    const db = openHippoDb(ctx.hippoRoot);
    // v1.7.4 -- declared outside the try so the return statement (which lives
    // outside, after the continuity block) can read the final values.
    let rankedOut = [];
    let tokensOut = 0;
    let totalOut = 0;
    // v1.7.4 -- dlPFC goal-stack boost on the PRIMARY band only. Appendix paths
    // (fresh-tail, summary substitutions) are appended AFTER and keep their
    // semantically-special placement.
    let baseScored = baseSlice.map((entry, idx) => ({
        entry,
        score: Math.max(0, 1 - idx / Math.max(1, limit)),
    }));
    try {
        if (opts.sessionId && !opts.goalTag) {
            baseScored = applyGoalStackBoost(db, baseScored, {
                sessionId: opts.sessionId,
                tenantId: ctx.tenantId,
                limit,
            });
            baseSlice = baseScored.map((r) => r.entry);
        }
        // v1.5.0 DAG-aware substitution (Phase 1, Task 2). When entries overflow the
        // limit and ≥2 of them share a level-2 parent summary, append the parent
        // summary so the user sees a compact pointer to the dropped detail. Capped
        // at ceil(limit * 0.3) substitutions so a runaway DAG can't expand results.
        // Each substituted summary is tenant-scoped via loadEntriesByIds and
        // re-checked against the active scope filter (default-deny on private).
        // Drill-down (Task 3) reverses substitution: caller passes substitutedFor[]
        // ids back through `drillDown` to recover the children.
        const summarizeOverflow = opts.summarizeOverflow ?? true;
        let substituted = [];
        if (summarizeOverflow && entries.length > limit) {
            const overflow = entries.slice(limit);
            const baseIds = new Set(baseSlice.map((e) => e.id));
            const overflowByParent = new Map();
            for (const e of overflow) {
                const parentId = e.dag_parent_id;
                if (!parentId)
                    continue;
                if ((e.dag_level ?? 0) > 1)
                    continue;
                const list = overflowByParent.get(parentId) ?? [];
                list.push(e);
                overflowByParent.set(parentId, list);
            }
            const eligibleParentIds = Array.from(overflowByParent.keys()).filter((pid) => (overflowByParent.get(pid)?.length ?? 0) >= 2 && !baseIds.has(pid));
            if (eligibleParentIds.length > 0) {
                const parents = loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId);
                const eligibleParents = parents.filter((p) => (p.dag_level ?? 0) === 2 && passesScopeFilterForRecall(p.scope ?? null, opts.scope));
                const maxSub = Math.max(1, Math.ceil(limit * 0.3));
                // Order parents by overflow count descending so the most
                // information-dense substitutions come first.
                eligibleParents.sort((a, b) => {
                    const ac = overflowByParent.get(a.id)?.length ?? 0;
                    const bc = overflowByParent.get(b.id)?.length ?? 0;
                    return bc - ac;
                });
                substituted = eligibleParents.slice(0, maxSub).map((p) => ({
                    entry: p,
                    childIds: (overflowByParent.get(p.id) ?? []).map((e) => e.id),
                }));
            }
        }
        // v1.12.13 / C5 — WYSIATI summary_substitutions_added counter.
        summarySubstitutionsCount = substituted.length;
        // v1.7.4 -- baseScored carries the (possibly boosted) per-row scores. When
        // the goal-stack boost did not run, scores are identical to the original
        // positional placeholder; when it did run, scores reflect the boost AND the
        // rows are in the boosted order (helper sort()).
        const baseRanked = baseScored.map((r) => ({
            id: r.entry.id,
            content: r.entry.content,
            score: r.score,
            layer: r.entry.layer,
            strength: r.entry.strength,
        }));
        // Substituted summaries land at the end with score = 0.5 (mid-rank), so
        // they don't outrank top-N strong matches but stay above lowest-rank
        // leaves on the consumer side. Caller sorts/filters as it sees fit.
        const summaryRanked = substituted.map((s) => ({
            id: s.entry.id,
            content: s.entry.content,
            score: 0.5,
            layer: s.entry.layer,
            strength: s.entry.strength,
            isSummary: true,
            substitutedFor: s.childIds,
            descendantCount: s.entry.descendant_count ?? s.childIds.length,
        }));
        // v1.5.2 fresh-tail. Surface the last N kind='raw' rows so an agent's
        // "what did I just see" recall path always covers the recent window even
        // when the query terms don't match. Tenant + scope filtered.
        //
        // Dual-membership semantics: `loadSearchEntries` returns all tenant-scoped
        // rows scored by BM25 (even rows with no token overlap can surface at
        // score≈0), so a row in the recent window often ALSO appears as a BM25
        // hit. We don't duplicate. Instead:
        //   1. Mark any baseRanked entry that's in the recent set with isFreshTail.
        //   2. Prepend genuinely-new recent rows (not in BM25 hits or summaries).
        // Net: every recent row carries `isFreshTail=true`, exactly once.
        const freshTailCount = opts.freshTailCount ?? 0;
        const freshRanked = [];
        if (freshTailCount > 0) {
            // F5 contract guard fires at recall() preflight (top of function).
            // No re-check needed here — by the time we reach this block the
            // env/session policy has already been validated.
            const recent = loadFreshRawMemories(ctx.hippoRoot, freshTailCount, ctx.tenantId, opts.freshTailSessionId);
            const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope));
            const recentIdSet = new Set(recentScoped.map((m) => m.id));
            for (const r of baseRanked) {
                if (recentIdSet.has(r.id))
                    r.isFreshTail = true;
            }
            const seenIds = new Set([
                ...baseRanked.map((r) => r.id),
                ...summaryRanked.map((r) => r.id),
            ]);
            for (const m of recentScoped) {
                if (seenIds.has(m.id))
                    continue;
                freshRanked.push({
                    id: m.id,
                    content: m.content,
                    score: 1.0,
                    layer: m.layer,
                    strength: m.strength,
                    isFreshTail: true,
                });
                seenIds.add(m.id);
            }
        }
        // v1.12.13 / C5 — WYSIATI fresh_tail_added counter. Captures the new rows
        // prepended (NOT rows already in baseRanked that got tagged isFreshTail).
        freshTailAddedCount = freshRanked.length;
        rankedOut = [...freshRanked, ...baseRanked, ...summaryRanked];
        tokensOut = rankedOut.reduce((acc, r) => acc + Math.ceil(r.content.length / 4), 0);
        totalOut = entries.length;
        // TODO(a1-task-4): emit via the shared audit hook in store.ts so we don't
        // double-emit. Recall does not currently write through writeEntry, so no
        // duplicate exists today, but we keep the same shape for symmetry.
        // v1.7.4: reuse the `db` handle opened above for the goal-stack boost --
        // single open/close spans both side effects.
        // GDPR Path A: store a sha256 hash (16 hex chars) of the query text
        // instead of the truncated query itself. If a caller queries with content
        // that matches an archived (RTBF) memory, the original text must not
        // persist in audit_log. query_length is preserved for debugging
        // long-prompt patterns and compliance metrics.
        appendAuditEvent(db, {
            tenantId: ctx.tenantId,
            actor: ctx.actor.subject,
            op: 'recall',
            metadata: {
                query_hash: createHash('sha256').update(opts.query).digest('hex').slice(0, 16),
                query_length: opts.query.length,
                results: rankedOut.length,
            },
        });
    }
    finally {
        closeHippoDb(db);
    }
    let continuity;
    let continuityTokens;
    if (opts.includeContinuity) {
        const snapshot = loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId);
        // No active snapshot = no anchor = no handoff/events. Avoids resurrecting
        // a stale handoff from a deleted/completed session.
        const sessionId = snapshot?.session_id ?? undefined;
        const sessionHandoff = sessionId
            ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId)
            : null;
        const recentSessionEvents = sessionId
            ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 })
            : [];
        // Scope filtering on continuity. Mirrors the memory-recall path:
        //   - opts.scope set: EXACT match required (no cross-scope leakage)
        //   - opts.scope unset: default-deny on ANY `<source>:private:*` AND on
        //     legacy 'unknown:legacy' rows quarantined by the v23 migration.
        //     Public and null scopes pass through.
        // v1.1.0 wrongly wrote this as `opts.scope || isPublic`, which allowed
        // ANY explicit scope to see ALL continuity rows. v1.2 closed the latent
        // leak. v1.2.1 generalizes the private check from slack-only to any
        // source so v1.3 GitHub (and future Jira/Linear/etc.) cannot leak.
        const rowScope = (r) => r?.scope ?? null;
        // v1.2: TaskSnapshot / SessionHandoff / SessionEvent now carry scope; the
        // wrapper just normalizes null vs undefined.
        const passesScopeFilter = (s) => {
            if (opts.scope !== undefined && opts.scope !== '') {
                return s === opts.scope;
            }
            if (s === null)
                return true;
            if (isPrivateScope(s))
                return false;
            // v1.7.2: read from RECALL_DEFAULT_DENY_SCOPES (single source of truth
            // shared with SQL + passesScopeFilterForRecall).
            if (RECALL_DEFAULT_DENY_SCOPES.includes(s))
                return false;
            return true;
        };
        const filteredSnapshot = snapshot && passesScopeFilter(rowScope(snapshot)) ? snapshot : null;
        const filteredHandoff = sessionHandoff && passesScopeFilter(rowScope(sessionHandoff)) ? sessionHandoff : null;
        const filteredEvents = recentSessionEvents.filter((e) => passesScopeFilter(rowScope(e)));
        continuity = {
            activeSnapshot: filteredSnapshot,
            sessionHandoff: filteredHandoff,
            recentSessionEvents: filteredEvents,
        };
        const tokenize = (s) => s ? Math.ceil(s.length / 4) : 0;
        continuityTokens =
            tokenize(filteredSnapshot?.task) +
                tokenize(filteredSnapshot?.summary) +
                tokenize(filteredSnapshot?.next_step) +
                tokenize(filteredHandoff?.summary) +
                tokenize(filteredHandoff?.nextAction) +
                (filteredHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
                filteredEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
    }
    // v0.32 / J3.2 — auto-injection of reference-class baserate when the
    // query carries a forward-prediction phrase AND the closest matching
    // class has closed historical data. Pipeline-invariant (queryText-
    // derived), so MCP and CLI both read this as the single source of
    // truth instead of recomputing (unlike suppressionSummary which IS
    // per-pipeline). opts.actor threads through to the inner
    // computePredictionBaserate call so MCP/HTTP-originated hints attribute
    // correctly instead of defaulting to 'cli'. Disabled by HIPPO_AUTODEBIAS=off.
    // v1.13.4: switched from computePlanningFallacyHint to
    // computePlanningFallacyOutput so the no-class-match / tiebreak
    // watching variant can also reach the caller surface. The two
    // outputs are mutually exclusive; we splat both as optional fields.
    const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
    const planningFallacyHint = planningFallacyOutput.hint ?? null;
    const planningFallacyWatching = planningFallacyOutput.watching ?? null;
    // v0.33 / J1 (v1.13.2) — recall-recurrence anchoring detection.
    // Uses opts.recallHistory (caller-supplied snapshot) + this pipeline's
    // own top-1 from rankedOut[0]. PURE read — does NOT mutate the snapshot
    // or any caller-side Map. Disabled by HIPPO_ANCHORING=off (which gates
    // even the detectAnchoring call so disabled tenants pay zero work on
    // this surface). On CLI-routed call paths opts.recallHistory is
    // undefined because cmdRecall computes its own hint separately; the
    // detect call returns null and api.recall's anchoringHint stays absent.
    let anchoringHint = null;
    let suppressedByInterferenceCount = 0;
    if (process.env.HIPPO_ANCHORING !== 'off' && opts.recallHistory) {
        const queryHash = hashQueryText(opts.query);
        const topMemoryId = rankedOut[0]?.id ?? null;
        anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
        if (anchoringHint?.reason === 'memory_dominance') {
            suppressedByInterferenceCount = 1;
            // Emit audit op for the memory-dominance detection.
            const db = openHippoDb(ctx.hippoRoot);
            try {
                appendAuditEvent(db, {
                    tenantId: ctx.tenantId,
                    actor: ctx.actor.subject,
                    op: 'recall_anchor_detected_memory_dominance',
                    targetId: anchoringHint.memoryId,
                    metadata: {
                        memory_id: anchoringHint.memoryId,
                        query_count: anchoringHint.queryCount ?? null,
                    },
                });
            }
            finally {
                closeHippoDb(db);
            }
        }
        else if (anchoringHint?.reason === 'query_repeat') {
            const db = openHippoDb(ctx.hippoRoot);
            try {
                appendAuditEvent(db, {
                    tenantId: ctx.tenantId,
                    actor: ctx.actor.subject,
                    op: 'recall_anchor_detected_query_repeat',
                    targetId: anchoringHint.memoryId,
                    metadata: { memory_id: anchoringHint.memoryId },
                });
            }
            finally {
                closeHippoDb(db);
            }
        }
    }
    // v1.13.x / J2 — availability/recency-bias detection. PURE read: compares
    // the age distribution of the returned top-K (baseSlice, the post-goal-boost
    // slice) against the matched candidate pool it was drawn from (entries, the
    // scope/private-FILTERED candidate set baseSlice is sliced from — NOT `all`,
    // which still holds private/cross-scope rows the caller is not eligible to see
    // and that could never enter the top-K; counting them would leak hidden pool
    // shape and inflate the signal). Soft warning only — does NOT filter, reorder,
    // or suppress. Disabled by HIPPO_AVAILABILITY=off (gates even the detect call
    // so disabled tenants pay zero work). Suppressed via opts.suppressAvailabilityHint
    // when the caller computes its own per-pipeline hint (MCP), mirroring the J1
    // opts.recallHistory gate above so we never double-emit the audit op. Audit
    // emission is pipeline-local, mirroring the J1 block above.
    let availabilityHint = null;
    if (process.env.HIPPO_AVAILABILITY !== 'off' && !opts.suppressAvailabilityHint) {
        availabilityHint = detectAvailabilityBias({
            topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
            pool: entries.map((e) => ({ id: e.id, created: e.created })),
        });
        if (availabilityHint) {
            const db = openHippoDb(ctx.hippoRoot);
            try {
                appendAuditEvent(db, {
                    tenantId: ctx.tenantId,
                    actor: ctx.actor.subject,
                    op: 'recall_availability_detected',
                    metadata: {
                        recent_fraction: availabilityHint.recentFraction,
                        older_passed_over: availabilityHint.olderCandidatesPassedOver,
                        returned_count: availabilityHint.returnedCount,
                    },
                });
            }
            finally {
                closeHippoDb(db);
            }
        }
    }
    return {
        results: rankedOut,
        total: totalOut,
        tokens: tokensOut,
        continuity,
        continuityTokens,
        windowSize,
        suppressionSummary: buildSuppressionSummary({
            totalCandidates: totalCandidatesCount,
            droppedPreRank: droppedPreRankCount,
            droppedByBudget: droppedByBudgetCount,
            summarySubstitutionsAdded: summarySubstitutionsCount,
            freshTailAdded: freshTailAddedCount,
            suppressedByInterference: suppressedByInterferenceCount,
        }),
        ...(planningFallacyHint ? { planningFallacyHint } : {}),
        ...(planningFallacyWatching ? { planningFallacyWatching } : {}),
        ...(anchoringHint ? { anchoringHint } : {}),
        ...(availabilityHint ? { availabilityHint } : {}),
    };
}
/**
 * Build a chronologically-ordered context window for a session. Adapts the
 * lossless-claw context-engine pattern to Hippo's score-ranked memory store.
 *
 * Algorithm:
 *   1. Load all kind='raw' rows for the session, tenant + scope filtered.
 *   2. Split: newest `freshTailCount` are protected (fresh tail).
 *   3. For older rows, when ≥2 share a level-2 parent, substitute the
 *      summary; everything else passes through as raw.
 *   4. Hippo-additive eviction: when over-budget, drop the lowest-strength
 *      non-fresh-tail item first. Fresh-tail rows are never evicted.
 *
 * Strength-weighted eviction is the differentiator from lossless-claw,
 * which evicts oldest-first. A high-strength older row (high retrieval
 * count, slow decay) survives; a low-strength recent row (newer but
 * unimportant) goes first.
 *
 * Returns `items: []` cleanly when:
 *   - sessionId is empty
 *   - no raws exist for the session
 *   - all rows fail the scope/tenant filter
 */
export function assemble(ctx, sessionId, opts = {}) {
    const budget = opts.budget ?? 4000;
    const freshTailCount = opts.freshTailCount ?? 10;
    const summarizeOlder = opts.summarizeOlder ?? true;
    const rowCap = opts.rowCap ?? 5000;
    if (!sessionId) {
        return { sessionId, items: [], tokens: 0, totalRaw: 0, summarized: 0, evicted: 0, truncated: false };
    }
    const rows = loadSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, rowCap);
    const truncated = rows.length === rowCap;
    // v1.6.3 senior-review P0-1: report the FULL post-filter row count even
    // when the cap windows the loaded set. Pre-v1.6.3 used `scoped.length`
    // which under-reported on long sessions and made consumers render
    // wrong "session has N msgs" UX.
    const scoped = rows.filter((r) => passesScopeFilterForRecall(r.scope ?? null, opts.scope));
    let totalRaw;
    if (truncated) {
        // v1.6.3 codex P1 / senior P0: scope-aware unbounded COUNT. The helper
        // SQL-encodes the same default-deny rule passesScopeFilterForRecall
        // applies in TS, so a no-scope caller cannot infer private rows by
        // comparing totalRaw to items.length on a truncated session.
        totalRaw = countSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, opts.scope);
    }
    else {
        totalRaw = scoped.length;
    }
    if (scoped.length === 0) {
        return { sessionId, items: [], tokens: 0, totalRaw, summarized: 0, evicted: 0, truncated };
    }
    // Split newest N into fresh tail; rest is older.
    const tailStartIdx = Math.max(0, scoped.length - freshTailCount);
    const olderRows = scoped.slice(0, tailStartIdx);
    const tailRows = scoped.slice(tailStartIdx);
    // Substitute parent summaries for older rows that share one.
    const olderItems = [];
    let summarized = 0;
    if (summarizeOlder && olderRows.length > 0) {
        const olderByParent = new Map();
        for (const r of olderRows) {
            if (!r.dag_parent_id)
                continue;
            const list = olderByParent.get(r.dag_parent_id) ?? [];
            list.push(r);
            olderByParent.set(r.dag_parent_id, list);
        }
        const eligibleParentIds = Array.from(olderByParent.keys()).filter((pid) => (olderByParent.get(pid)?.length ?? 0) >= 2);
        const parents = eligibleParentIds.length > 0
            ? loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId)
                .filter((p) => (p.dag_level ?? 0) === 2)
                .filter((p) => passesScopeFilterForRecall(p.scope ?? null, opts.scope))
            : [];
        const claimedRawIds = new Set();
        for (const parent of parents) {
            const claimed = (olderByParent.get(parent.id) ?? []).map((r) => r.id);
            claimed.forEach((id) => claimedRawIds.add(id));
            olderItems.push({
                id: parent.id,
                content: parent.content,
                createdAt: parent.earliest_at ?? parent.created,
                isSummary: true,
                substitutedFor: claimed,
                strength: parent.strength,
            });
            summarized += claimed.length;
        }
        for (const r of olderRows) {
            if (claimedRawIds.has(r.id))
                continue;
            olderItems.push({
                id: r.id,
                content: r.content,
                createdAt: r.created,
                strength: r.strength,
            });
        }
    }
    else {
        for (const r of olderRows) {
            olderItems.push({
                id: r.id,
                content: r.content,
                createdAt: r.created,
                strength: r.strength,
            });
        }
    }
    const tailItems = tailRows.map((r) => ({
        id: r.id,
        content: r.content,
        createdAt: r.created,
        isFreshTail: true,
        strength: r.strength,
    }));
    // F4 (v1.6.5): byte compare canonical UTC ISO timestamps. ~50× faster than
    // localeCompare and chronological by virtue of the timestamp invariant
    // documented in src/memory.ts above MemoryEntry.
    const cmpIso = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
    olderItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
    tailItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
    let items = [...olderItems, ...tailItems];
    let tokens = items.reduce((acc, it) => acc + Math.ceil(it.content.length / 4), 0);
    let evicted = 0;
    while (tokens > budget && items.length > 0) {
        let worstIdx = -1;
        let worstStrength = Infinity;
        for (let i = 0; i < items.length; i++) {
            if (items[i].isFreshTail)
                continue;
            if (items[i].strength < worstStrength) {
                worstStrength = items[i].strength;
                worstIdx = i;
            }
        }
        if (worstIdx === -1)
            break;
        const cost = Math.ceil(items[worstIdx].content.length / 4);
        items = items.filter((_, i) => i !== worstIdx);
        tokens -= cost;
        evicted++;
    }
    return { sessionId, items, tokens, totalRaw, summarized, evicted, truncated };
}
/**
 * Walk one step down the DAG from a level-2 (or higher) summary to its direct
 * children. Companion to `recall(... summarizeOverflow: true)` — when recall
 * surfaces a summary with `substitutedFor: [...]`, the caller drills into the
 * summary id to recover the original detail.
 *
 * Tenant scope: only summaries owned by `ctx.tenantId` are reachable. The same
 * scope filter that recall applies is enforced on the children — a level-2
 * summary in `slack:public:CGEN` cannot leak `slack:private:*` children even
 * if the underlying DAG accidentally linked across scopes.
 *
 * Returns a discriminated `DrillDownOutcome`: `DrillDownResult` on success,
 * or `{failure: '...'}` for `not_found` (covers genuinely-missing AND wrong-
 * tenant, intentionally indistinguishable), `not_drillable` (id is a leaf
 * row), or `scope_blocked` (caller has no scope grant for the row's scope).
 *
 * Pre-v1.6.4 returned null for all four cases. JS callers migrate via
 * `'failure' in result` checks; HTTP route maps `not_drillable` to 422.
 */
export function drillDown(ctx, summaryId, opts = {}) {
    const limit = opts.limit ?? 50;
    // v0.30 / E5: depth defaults 1 (backward compat); hard cap 10 levels
    // prevents pathological deep trees. CLI/HTTP/MCP layers also clamp.
    const depth = Math.max(1, Math.min(opts.depth ?? 1, 10));
    const summary = readEntry(ctx.hippoRoot, summaryId, ctx.tenantId);
    // No unscoped cross-tenant probe here — readEntry's null return covers
    // both "doesn't exist" and "exists in another tenant" by design.
    // Distinguishing them via an unscoped lookup would leak existence to
    // unauthorised tenants. The two cases collapse into not_found.
    if (!summary)
        return { failure: 'not_found' };
    if ((summary.dag_level ?? 0) < 2)
        return { failure: 'not_drillable' };
    if (!passesScopeFilterForRecall(summary.scope ?? null, undefined)) {
        // codex round 3 P1: collapse to not_found. A distinguishable
        // "scope_blocked" tells a no-scope caller "this row exists, just
        // not for you" — same existence-leak the HTTP 404 collapse was
        // already preventing. Match the HTTP behaviour at the API level.
        return { failure: 'not_found' };
    }
    // v0.30 / E5: BFS walk levels 1..depth with visited-Set dedup. Defensive
    // against shared-child data anomalies (dag_parent_id has no uniqueness
    // constraint, so a misconfigured tree could double-emit at depth > 1).
    // Each level uses loadChildrenOf which is tenant-scoped via ctx.tenantId.
    const collected = [];
    const visited = new Set([summaryId]);
    let frontier = [summaryId];
    // independent-review MED #4 fold: track level-0 direct-children count
    // separately so the descendantCount fallback (for legacy summaries with
    // null descendant_count) reflects DIRECT children, not BFS-collected total.
    let level0DirectCount = 0;
    for (let level = 0; level < depth; level++) {
        const nextFrontier = [];
        for (const parentId of frontier) {
            const kids = loadChildrenOf(ctx.hippoRoot, parentId, ctx.tenantId);
            const eligibleKids = kids.filter((c) => passesScopeFilterForRecall(c.scope ?? null, undefined));
            for (const k of eligibleKids) {
                if (visited.has(k.id))
                    continue;
                visited.add(k.id);
                collected.push(k);
                nextFrontier.push(k.id);
                if (level === 0)
                    level0DirectCount++;
            }
        }
        if (nextFrontier.length === 0)
            break;
        frontier = nextFrontier;
    }
    // Apply global cumulative token budget + limit cap on collected.
    let children = collected;
    let truncated = false;
    if (opts.budget !== undefined) {
        const out = [];
        let used = 0;
        for (const c of collected) {
            const t = Math.ceil(c.content.length / 4);
            if (out.length > 0 && used + t > opts.budget) {
                truncated = true;
                break;
            }
            out.push(c);
            used += t;
        }
        children = out;
    }
    if (children.length > limit) {
        children = children.slice(0, limit);
        truncated = true;
    }
    return {
        summary: {
            id: summary.id,
            content: summary.content,
            // v0.30 / E5: descendant_count stays the summary's STORED value
            // (direct children at creation time). totalChildren below reflects
            // the full BFS collection at the requested depth.
            // independent-review MED #4 fold: legacy fallback uses level-0 direct
            // count (NOT collected.length which is BFS-depth-N total).
            descendantCount: summary.descendant_count ?? level0DirectCount,
            earliestAt: summary.earliest_at ?? null,
            latestAt: summary.latest_at ?? null,
        },
        children: children.map((c) => ({
            id: c.id,
            content: c.content,
            layer: c.layer,
            dagLevel: c.dag_level ?? 0,
            created: c.created,
        })),
        // v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
        // depth=1 this equals the eligible direct-children count (backward
        // compat). For depth>1 it is the cumulative count across levels.
        totalChildren: collected.length,
        truncated,
    };
}
// ---------------------------------------------------------------------------
// outcome
// ---------------------------------------------------------------------------
/**
 * Apply a positive/negative outcome to a list of recently-recalled memory ids.
 * Used by the MCP `hippo_outcome` tool and the HTTP `POST /v1/outcome` route.
 * Tenant-scoped: ids that don't belong to ctx.tenantId are silently skipped
 * (matches the prior MCP semantics — a stale id from another tenant doesn't
 * crash the call). Each successful outcome emits one audit_log row with
 * op='outcome' tagged with ctx.actor.subject.
 *
 * Returns `{applied, appliedIds}`. `appliedIds` is the tenant-filtered subset
 * of input ids that actually had `applyOutcome` run on them (i.e. ids whose
 * `readEntry(..., ctx.tenantId)` resolved). Callers that surface the id list
 * over a multi-tenant boundary (HTTP /v1/outcome last-recall path, Python SDK)
 * MUST return `appliedIds` instead of the raw input list — otherwise the
 * non-applied (cross-tenant) ids leak to the caller. Added in v1.11.4 to
 * close that disclosure path on POST /v1/outcome.
 */
export function outcome(ctx, ids, good) {
    const appliedIds = [];
    const db = openHippoDb(ctx.hippoRoot);
    try {
        for (const id of ids) {
            const entry = readEntry(ctx.hippoRoot, id, ctx.tenantId);
            if (!entry)
                continue;
            const updated = applyOutcome(entry, good);
            writeEntry(ctx.hippoRoot, updated, { actor: ctx.actor.subject });
            appendAuditEvent(db, {
                tenantId: ctx.tenantId,
                actor: ctx.actor.subject,
                op: 'outcome',
                targetId: id,
                metadata: { good },
            });
            appliedIds.push(id);
        }
    }
    finally {
        closeHippoDb(db);
    }
    return { applied: appliedIds.length, appliedIds };
}
// ---------------------------------------------------------------------------
// forget
// ---------------------------------------------------------------------------
/**
 * Delete a memory by id. `deleteEntry` threads ctx.actor.subject into its internal
 * audit hook, so exactly one 'forget' event lands with the supplied actor.
 *
 * Tenant scope: deleteEntry looks up the row by id alone, so without an
 * explicit tenant guard a Bearer for tenant A could delete tenant B's row
 * by guessing or leaking the id. Pre-check the row's tenant_id and deny
 * cross-tenant access with a not-found error (no info leak about whether
 * the id exists in another tenant).
 */
export function forget(ctx, id) {
    const db = openHippoDb(ctx.hippoRoot);
    try {
        const row = db
            .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
            .get(id);
        if (!row || row.tenant_id !== ctx.tenantId) {
            throw new Error(`memory not found: ${id}`);
        }
    }
    finally {
        closeHippoDb(db);
    }
    const removed = deleteEntry(ctx.hippoRoot, id, { actor: ctx.actor.subject });
    if (!removed) {
        throw new Error(`memory not found: ${id}`);
    }
    return { ok: true, id };
}
// ---------------------------------------------------------------------------
// promote
// ---------------------------------------------------------------------------
/**
 * Copy a local memory into the global store. Mirrors `cmdPromote` in cli.ts:
 * the `writeEntry` inside `promoteToGlobal` emits a 'remember' on the global
 * db; we add a 'promote' audit event on the global db so the user-facing
 * intent stays distinct from the underlying upsert.
 *
 * Note: `promoteToGlobal` does not currently take a tenantId override — it
 * reads the entry from the local root via `readEntry` (no tenant filter) and
 * preserves the entry's existing tenantId on the global side. Task 4 may
 * tighten this once writeEntry/readEntry thread tenant context.
 */
export function promote(ctx, id) {
    // Tenant scope: promoteToGlobal reads the entry from the local root via
    // readEntry without a tenant filter, so a Bearer for tenant A could
    // promote tenant B's row by guessing or leaking the id. Pre-check the
    // row's tenant_id and deny cross-tenant access with the same not-found
    // wording archiveRaw uses (no info leak about whether the id exists in
    // another tenant).
    const ownerDb = openHippoDb(ctx.hippoRoot);
    try {
        const row = ownerDb
            .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
            .get(id);
        if (!row || row.tenant_id !== ctx.tenantId) {
            throw new Error(`memory not found: ${id}`);
        }
    }
    finally {
        closeHippoDb(ownerDb);
    }
    // promoteToGlobal threads ctx.actor.subject into the writeEntry call on the global
    // db, which emits a 'remember' audit row. We then add the user-facing
    // 'promote' event on the global db so the audit trail keeps the intent
    // distinct from the underlying upsert.
    const globalEntry = promoteToGlobal(ctx.hippoRoot, id, { actor: ctx.actor.subject, tenantId: ctx.tenantId });
    const db = openHippoDb(getGlobalRoot());
    try {
        appendAuditEvent(db, {
            tenantId: ctx.tenantId,
            actor: ctx.actor.subject,
            op: 'promote',
            targetId: globalEntry.id,
            metadata: { sourceId: id },
        });
    }
    finally {
        closeHippoDb(db);
    }
    return { ok: true, sourceId: id, globalId: globalEntry.id };
}
// ---------------------------------------------------------------------------
// supersede
// ---------------------------------------------------------------------------
/**
 * Replace an old memory with new content, chaining old.superseded_by = new.id.
 * Mirrors `cmdSupersede` in cli.ts (without flag-driven layer/tag/pin overrides
 * — A1 keeps the API minimal; the CLI handler will continue to handle those
 * flags and pass the resolved values once Task 4 lands).
 */
export function supersede(ctx, oldId, newContent) {
    // Read old (tenant-scoped). readEntry filters by tenantId, so a Bearer for
    // tenant A on tenant B's id throws "Memory not found" here without any
    // info leak.
    const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
    if (!old) {
        throw new Error(`Memory not found: ${oldId}`);
    }
    // Guard: not already superseded. The CAS UPDATE below race-safely closes
    // the window between this read and the write; this check just produces a
    // clearer error in the common single-writer case.
    if (old.superseded_by) {
        throw new Error(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
    }
    const newEntry = createMemory(newContent, {
        layer: old.layer ?? Layer.Episodic,
        tags: [...old.tags],
        pinned: old.pinned,
        source: old.source,
        confidence: 'verified',
        tenantId: ctx.tenantId,
    });
    // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
    // three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
    // inside the same transaction. Two concurrent supersedes: exactly one CAS
    // wins (changes=1), the other gets changes=0 and throws CONFLICT. No
    // dangling-pointer window: the new memory's row commits atomically with
    // the old.superseded_by pointer.
    const db = openHippoDb(ctx.hippoRoot);
    try {
        db.exec('BEGIN IMMEDIATE');
        try {
            // 1. CAS update: only succeed if old.superseded_by IS NULL AND the
            //    row still belongs to ctx.tenantId. Tenant filter is belt-and-
            //    braces with the readEntry above — it costs nothing and closes
            //    a hypothetical window where ownership changes between read and
            //    update.
            const result = db.prepare(`
        UPDATE memories
        SET superseded_by = ?
        WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
      `).run(newEntry.id, oldId, ctx.tenantId);
            if ((result.changes ?? 0) === 0) {
                db.exec('ROLLBACK');
                throw new Error(`Memory ${oldId} already superseded by another writer`);
            }
            // v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
            // superseded. Its parent (if any) needs rebuild. Lands strictly
            // between the rollback guard above and the writeEntryDbOnly(NEW)
            // below so a failed CAS hits throw before this hook. The NEW
            // entry's parent (typically same parent) is auto-marked by the
            // writeEntryDbOnly hook (same parent → idempotent, audits once).
            if (old.dag_parent_id) {
                markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
            }
            // 2. Write new memory inside same tx via writeEntryDbOnly (DB-only
            //    path). This emits its OWN 'remember' audit row for the new
            //    memory inside the SAVEPOINT — atomic with the row INSERT.
            writeEntryDbOnly(db, newEntry, { actor: ctx.actor.subject });
            // 3. User-facing 'supersede' audit row inside the same tx so the
            //    chain pointer + audit trail commit atomically.
            appendAuditEvent(db, {
                tenantId: ctx.tenantId,
                actor: ctx.actor.subject,
                op: 'supersede',
                targetId: oldId,
                metadata: { newId: newEntry.id },
            });
            db.exec('COMMIT');
        }
        catch (err) {
            try {
                db.exec('ROLLBACK');
            }
            catch { /* already rolled back */ }
            throw err;
        }
        // Mirrors after COMMIT, while the db handle is still open. Same
        // invariant as the original writeEntry: a mirror failure leaves disk
        // MISSING the markdown for the new memory (self-heals on next backfill
        // via writeIndexMirror reading the DB) but DOES NOT desync the DB or
        // roll back the supersede. Logged + swallowed, non-fatal.
        try {
            writeEntryMirrors(ctx.hippoRoot, db, newEntry);
        }
        catch (mirrorErr) {
            console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
        }
    }
    finally {
        closeHippoDb(db);
    }
    return { ok: true, oldId, newId: newEntry.id };
}
export function archiveRaw(ctx, id, reason, opts = {}) {
    const db = openHippoDb(ctx.hippoRoot);
    let mirrorOk = false;
    try {
        // Tenant scope: archiveRawMemory looks up the row by id alone, so a
        // Bearer for tenant A could archive tenant B's raw row without this
        // pre-check. Deny cross-tenant access with the same not-found message
        // archiveRawMemory itself would throw on a missing row, so we don't
        // leak whether the id exists in another tenant.
        const row = db
            .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
            .get(id);
        if (!row || row.tenant_id !== ctx.tenantId) {
            throw new Error(`memory not found: ${id}`);
        }
        archiveRawMemory(db, id, {
            reason,
            who: ctx.actor.subject,
            afterArchive: opts.afterArchive,
        });
        // archiveRawMemory deletes the memories row but leaves any legacy markdown
        // mirror in <root>/{buffer,episodic,semantic}/<id>.md untouched. If we left
        // the mirror in place, a subsequent initStore() on an empty memories table
        // would silently re-import the row via bootstrapLegacyStore — defeating the
        // archive (and the GDPR right-to-be-forgotten promise on raw rows). Mirror
        // forget() at src/store.ts:1046, which uses the same removeEntryMirrors call.
        // The DB transaction has already committed; if filesystem unlink fails here
        // we log and continue. The mirror reaper in openHippoDb will catch it on
        // next DB open: raw_archive.mirror_cleaned_at stays NULL until every layer
        // mirror for this id is gone, so the reaper genuinely retries.
        try {
            removeEntryMirrors(ctx.hippoRoot, id);
            mirrorOk = true;
        }
        catch (mirrorErr) {
            console.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb):`, mirrorErr);
        }
        if (mirrorOk) {
            // Stamp mirror_cleaned_at now so the next openHippoDb reaper SELECT
            // returns empty for this row. NULL stays untouched on failure -> retry.
            db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
        }
    }
    finally {
        closeHippoDb(db);
    }
    // archiveRawMemory does not return the archive_at timestamp it wrote. We
    // emit a fresh ISO timestamp here for the API response. Within a millisecond
    // of the actual write, fine for a server response shape.
    return { ok: true, archivedAt: new Date().toISOString() };
}
/**
 * Mint a new API key. The new key is ALWAYS bound to `ctx.tenantId`. Callers
 * cannot override the tenant via the opts bag — a previous `tenantId` field
 * was removed because the HTTP layer would happily forward `body.tenantId`,
 * letting tenant A mint a key for tenant B. The HTTP route handler at
 * `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
 * `tenantId` and uses the resolved Bearer's tenant exclusively.
 *
 * Per A5 v2 follow-ups (TODOS.md), `auth_create` is currently unaudited —
 * we intentionally match that behavior here for consistency. When A5 v2
 * lands and adds the audit op, this function should mirror the cli handler.
 */
export function authCreate(ctx, opts) {
    const db = openHippoDb(ctx.hippoRoot);
    try {
        const role = opts.role ?? 'admin';
        const result = createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
        // v1.12.4: audit emit (closes the gap v1.12.3 CHANGELOG flagged as deferred).
        // Mirrors the auth_revoke pattern at authRevoke — same try/catch so audit
        // failure can't crash a successful mint. The plaintext is NEVER logged;
        // metadata carries label + role + the keyId (which is non-secret).
        try {
            appendAuditEvent(db, {
                tenantId: ctx.tenantId,
                actor: ctx.actor.subject,
                op: 'auth_create',
                targetId: result.keyId,
                metadata: {
                    label: opts.label ?? null,
                    role,
                },
            });
        }
        catch {
            // Audit must not crash a successful mint.
        }
        return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
    }
    finally {
        closeHippoDb(db);
    }
}
/**
 * List API keys visible to the calling tenant.
 *
 * Divergence from `cmdAuthList` in src/cli.ts: the CLI today returns ALL keys
 * regardless of tenant (single-tenant deployments). The API surface is tenant-
 * scoped because future multi-tenant deployments will share a hippoRoot, and
 * tenant A must not see tenant B's keys. Read-only — no audit emit (matches A5).
 */
export function authList(ctx, opts) {
    const db = openHippoDb(ctx.hippoRoot);
    try {
        const all = listApiKeys(db, opts);
        return all.filter((k) => k.tenantId === ctx.tenantId);
    }
    finally {
        closeHippoDb(db);
    }
}
/**
 * Revoke an API key.
 *
 * Security: the key must belong to `ctx.tenantId`. Cross-tenant revoke is
 * rejected with the same "not found" message used for missing keys, so that a
 * caller cannot probe which key_ids exist on other tenants.
 *
 * Audit: emits 'auth_revoke' with `tenantId` set to the KEY ROW's tenant_id
 * (M1 fix from A5 review, mirrors src/cli.ts:cmdAuthRevoke). Skipped on no-op
 * revoke (already revoked) so re-running doesn't pad the audit log.
 */
export function authRevoke(ctx, keyId) {
    const db = openHippoDb(ctx.hippoRoot);
    try {
        const row = db
            .prepare(`SELECT key_id, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
            .get(keyId);
        if (!row) {
            throw new Error(`Unknown key_id: ${keyId}`);
        }
        // Cross-tenant access denied: same message as missing key, no info leak.
        if (row.tenant_id !== ctx.tenantId) {
            throw new Error(`Unknown key_id: ${keyId}`);
        }
        let revokedAt;
        let alreadyRevoked = false;
        if (row.revoked_at) {
            alreadyRevoked = true;
            revokedAt = row.revoked_at;
        }
        else {
            revokeApiKey(db, keyId);
            const updated = db
                .prepare(`SELECT revoked_at FROM api_keys WHERE key_id = ?`)
                .get(keyId);
            revokedAt = updated?.revoked_at ?? new Date().toISOString();
        }
        if (!alreadyRevoked) {
            try {
                appendAuditEvent(db, {
                    tenantId: row.tenant_id, // M1: KEY's tenant, not ctx.tenantId.
                    actor: ctx.actor.subject,
                    op: 'auth_revoke',
                    targetId: keyId,
                });
            }
            catch {
                // Audit must not crash a successful revoke.
            }
        }
        return { ok: true, revokedAt };
    }
    finally {
        closeHippoDb(db);
    }
}
/**
 * Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches
 * A5: cmdAuditList does not record a 'recall'-style read event).
 */
export function auditList(ctx, opts) {
    const db = openHippoDb(ctx.hippoRoot);
    try {
        return queryAuditEvents(db, {
            tenantId: ctx.tenantId,
            op: opts.op,
            since: opts.since,
            limit: opts.limit,
        });
    }
    finally {
        closeHippoDb(db);
    }
}
/**
 * Assemble a context bundle: recalled memories (pinned-only / strength-sorted
 * fallback / hybrid search) + active task snapshot + session handoff + recent
 * session events. Budget-bounded, tenant-scoped. Mutates `last_retrieval_ids`
 * + emits a 'recall' audit row for non-pinned, non-'*' queries.
 *
 * Behaves like the pre-extraction `cmdContext` data-loading + selection
 * pipeline. CLI presentation (markdown / json / additional-context rendering)
 * stays in `cli.ts`.
 *
 * Tenant scope: all `loadAllEntries` / snapshot / handoff / events reads use
 * `ctx.tenantId`. Cross-tenant rows are filtered out.
 *
 * Returns an empty result (`entries: []`, snapshot/handoff/events undefined)
 * when there's nothing to surface (no memories AND no snapshot AND no handoff
 * AND no recent events).
 */
export async function getContext(ctx, opts = {}) {
    const pinnedOnly = opts.pinnedOnly === true;
    const budget = opts.budget ?? 1500;
    const limit = opts.limit ?? Number.POSITIVE_INFINITY;
    const includeRecent = opts.includeRecent ?? 0;
    const activeScope = opts.scope ?? '';
    if (budget <= 0) {
        return { entries: [], tokens: 0 };
    }
    // Pinned-only path is allowed against an un-initialised local store (the
    // UserPromptSubmit hook can run in directories without a .hippo). Non-pinned
    // path requires an initialised local store; callers should check first.
    const hasLocal = isInitialized(ctx.hippoRoot);
    const query = (opts.q ?? '').trim() || '*';
    const globalRoot = getGlobalRoot();
    const hasGlobal = isInitialized(globalRoot);
    // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
    let localEntries = hasLocal ? loadAllEntries(ctx.hippoRoot, ctx.tenantId) : [];
    let globalEntries = hasGlobal ? loadAllEntries(globalRoot, ctx.tenantId) : [];
    // Filter superseded — context never includes superseded rows.
    localEntries = localEntries.filter((e) => !e.superseded_by);
    globalEntries = globalEntries.filter((e) => !e.superseded_by);
    const activeSnapshot = hasLocal
        ? loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId)
        : null;
    const sessionHandoff = hasLocal && activeSnapshot?.session_id
        ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, activeSnapshot.session_id)
        : null;
    const recentSessionEvents = hasLocal && activeSnapshot?.session_id
        ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
            session_id: activeSnapshot.session_id,
            limit: 5,
        })
        : [];
    if (localEntries.length === 0 &&
        globalEntries.length === 0 &&
        !activeSnapshot &&
        !sessionHandoff &&
        recentSessionEvents.length === 0) {
        return { entries: [], tokens: 0 };
    }
    let selectedItems = [];
    let totalTokens = 0;
    if (pinnedOnly) {
        // loadConfig is safe even when local isn't initialised — returns defaults.
        const pinnedCfg = loadConfig(ctx.hippoRoot);
        if (!pinnedCfg.pinnedInject.enabled) {
            return { entries: [], tokens: 0 };
        }
        // Effective budget: explicit opts.budget wins over config.
        const effBudget = opts.budget !== undefined ? budget : pinnedCfg.pinnedInject.budget;
        const nowP = new Date();
        const selectedIds = new Set();
        let usedP = 0;
        if (includeRecent > 0) {
            const recent = [
                ...localEntries.map((entry) => ({ entry, isGlobal: false })),
                ...globalEntries.map((entry) => ({ entry, isGlobal: true })),
            ]
                .sort((a, b) => {
                const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
                return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
            })
                .slice(0, includeRecent)
                .map(({ entry, isGlobal }) => ({
                entry,
                score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
                tokens: estimateTokens(entry.content),
                isGlobal,
            }));
            for (const r of recent) {
                if (selectedIds.has(r.entry.id))
                    continue;
                if (usedP + r.tokens > effBudget)
                    continue;
                selectedItems.push(r);
                selectedIds.add(r.entry.id);
                usedP += r.tokens;
            }
        }
        const pinnedLocal = localEntries.filter((e) => e.pinned);
        const pinnedGlobal = globalEntries.filter((e) => e.pinned);
        if (pinnedLocal.length === 0 &&
            pinnedGlobal.length === 0 &&
            selectedItems.length === 0) {
            return { entries: [], tokens: 0 };
        }
        const rankedPinned = [
            ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
            ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
        ]
            .map(({ entry, isGlobal }) => {
            const scopeSig = scopeMatch(entry.tags, activeScope);
            const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
            return {
                entry,
                score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
                tokens: estimateTokens(entry.content),
                isGlobal,
            };
        })
            .sort((a, b) => b.score - a.score);
        for (const r of rankedPinned) {
            if (selectedIds.has(r.entry.id))
                continue;
            if (usedP + r.tokens > effBudget)
                continue;
            selectedItems.push(r);
            selectedIds.add(r.entry.id);
            usedP += r.tokens;
        }
        totalTokens = usedP;
    }
    else if (query === '*') {
        // No query: return strongest memories by strength, up to budget.
        const now = new Date();
        const localRanked = localEntries
            .map((e) => ({
            entry: e,
            score: calculateStrength(e, now),
            tokens: estimateTokens(e.content),
            isGlobal: false,
        }))
            .sort((a, b) => b.score - a.score);
        const globalRanked = globalEntries
            .map((e) => ({
            entry: e,
            score: calculateStrength(e, now) * (1 / 1.2),
            tokens: estimateTokens(e.content),
            isGlobal: true,
        }))
            .sort((a, b) => b.score - a.score);
        const combined = [...localRanked, ...globalRanked].sort((a, b) => b.score - a.score);
        let used = 0;
        for (const r of combined) {
            if (used + r.tokens > budget)
                continue;
            selectedItems.push(r);
            used += r.tokens;
        }
        totalTokens = used;
    }
    else {
        // Real query: hybrid search (global + local) or physics+hybrid (local only).
        let results;
        if (hasGlobal) {
            const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
                budget,
                scope: activeScope,
                tenantId: ctx.tenantId,
            });
            const localIndex = loadIndex(ctx.hippoRoot);
            results = merged.map((r) => ({
                entry: r.entry,
                score: r.score,
                tokens: r.tokens,
                isGlobal: !localIndex.entries[r.entry.id],
            }));
        }
        else {
            const ctxConfig = loadConfig(ctx.hippoRoot);
            const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
            const ctxResults = usePhysicsCtx
                ? await physicsSearch(query, localEntries, {
                    budget,
                    hippoRoot: ctx.hippoRoot,
                    physicsConfig: ctxConfig.physics,
                    scope: activeScope,
                })
                : await hybridSearch(query, localEntries, {
                    budget,
                    hippoRoot: ctx.hippoRoot,
                    scope: activeScope,
                });
            results = ctxResults.map((r) => ({
                entry: r.entry,
                score: r.score,
                tokens: r.tokens,
                isGlobal: false,
            }));
        }
        selectedItems = results;
        totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
        // A5 H4: emit recall audit row for context-mode searches (matches the
        // 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
        // never hit the search engines, so they don't emit (matches cmdContext).
        const ctxRecallMetadata = {
            query: query.slice(0, 200),
            results: selectedItems.length,
            mode: 'context',
        };
        if (hasLocal) {
            const localDb = openHippoDb(ctx.hippoRoot);
            try {
                appendAuditEvent(localDb, {
                    tenantId: ctx.tenantId,
                    actor: ctx.actor.subject,
                    op: 'recall',
                    metadata: ctxRecallMetadata,
                });
            }
            finally {
                closeHippoDb(localDb);
            }
        }
        if (hasGlobal) {
            const globalDb = openHippoDb(globalRoot);
            try {
                appendAuditEvent(globalDb, {
                    tenantId: ctx.tenantId,
                    actor: ctx.actor.subject,
                    op: 'recall',
                    metadata: ctxRecallMetadata,
                });
            }
            finally {
                closeHippoDb(globalDb);
            }
        }
    }
    if (limit < selectedItems.length) {
        selectedItems = selectedItems.slice(0, limit);
        totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
    }
    if (selectedItems.length === 0 &&
        !activeSnapshot &&
        !sessionHandoff &&
        recentSessionEvents.length === 0) {
        return { entries: [], tokens: 0 };
    }
    // pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
    // memories don't inflate retrieval_count or extend half_life by 2 days per
    // turn over a long session.
    if (!pinnedOnly) {
        const toUpdate = selectedItems.map((s) => s.entry);
        const updatedEntries = markRetrieved(toUpdate);
        const localIndex = loadIndex(ctx.hippoRoot);
        for (const u of updatedEntries) {
            const targetRoot = localIndex.entries[u.id]
                ? ctx.hippoRoot
                : hasGlobal
                    ? globalRoot
                    : ctx.hippoRoot;
            writeEntry(targetRoot, u);
        }
        localIndex.last_retrieval_ids = updatedEntries.map((u) => u.id);
        saveIndex(ctx.hippoRoot, localIndex);
        updateStats(ctx.hippoRoot, { recalled: selectedItems.length });
        // Replace selectedItems entries with markRetrieved-updated copies so
        // the returned ContextResult reflects post-recall state.
        selectedItems = selectedItems.map((s) => ({
            ...s,
            entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
        }));
    }
    return {
        entries: selectedItems,
        tokens: totalTokens,
        activeSnapshot: activeSnapshot ?? undefined,
        sessionHandoff: sessionHandoff ?? undefined,
        recentEvents: recentSessionEvents.length > 0 ? recentSessionEvents : undefined,
    };
}
const DEFAULT_SLEEP_PHASES = {
    consolidate,
    deduplicateStore,
    auditMemories,
    autoShare,
    loadAllEntries,
    deleteEntry,
    computeAmbientState,
    loadConfig,
};
export async function sleep(ctx, opts = {}) {
    const dryRun = Boolean(opts.dryRun);
    // v1.12.2: resolve phase dependencies, allowing test-only `__phases`
    // override to inject deterministic throws for mid-phase failure coverage.
    const phases = { ...DEFAULT_SLEEP_PHASES, ...(opts.__phases ?? {}) };
    // v1.11.5: phase counters for the consolidate audit emit (in finally).
    // Accumulated as each phase completes so partial-failure paths still report
    // accurate "what got done before the failure" data.
    let consolidationCount = 0;
    let dedupCount = 0;
    let auditDeletedCount = 0;
    let ambientTotal = 0;
    let phaseError = null;
    let result = null;
    try {
        // Phase 1: Consolidation.
        const consolidateResult = await phases.consolidate(ctx.hippoRoot, { dryRun });
        consolidationCount = consolidateResult.semanticCreated + consolidateResult.merged;
        result = {
            active: consolidateResult.decayed,
            removed: consolidateResult.removed,
            mergedEpisodic: consolidateResult.merged,
            newSemantic: consolidateResult.semanticCreated,
            dryRun,
            details: consolidateResult.details,
        };
        if (dryRun)
            return result;
        // Phase 2: Dedup (post-consolidate near-duplicate cleanup).
        const dedupResult = phases.deduplicateStore(ctx.hippoRoot);
        dedupCount = dedupResult.removed;
        if (dedupResult.removed > 0) {
            const semDups = dedupResult.pairs.filter((p) => p.keptLayer === 'semantic' && p.removedLayer === 'semantic').length;
            const epiDups = dedupResult.pairs.filter((p) => p.keptLayer === 'episodic' && p.removedLayer === 'episodic').length;
            const crossDups = dedupResult.pairs.filter((p) => p.keptLayer !== p.removedLayer).length;
            result.deduped = {
                removed: dedupResult.removed,
                semDups,
                epiDups,
                crossDups,
            };
        }
        // Phase 3: Quality audit (remove junk, report warnings).
        const allEntries = phases.loadAllEntries(ctx.hippoRoot);
        const auditOut = phases.auditMemories(allEntries);
        if (auditOut.issues.length > 0) {
            const errors = auditOut.issues.filter((i) => i.severity === 'error');
            const warnings = auditOut.issues.filter((i) => i.severity === 'warning');
            if (errors.length > 0) {
                for (const issue of errors) {
                    phases.deleteEntry(ctx.hippoRoot, issue.memoryId);
                }
            }
            auditDeletedCount = errors.length;
            if (errors.length > 0 || warnings.length > 0) {
                result.audit = {
                    errorsRemoved: errors.length,
                    warningCount: warnings.length,
                };
            }
        }
        // Phase 4: Auto-share high-transfer-score memories to global.
        if (!opts.noShare) {
            const sleepConfig = phases.loadConfig(ctx.hippoRoot);
            if (sleepConfig.autoShareOnSleep) {
                const shared = phases.autoShare(ctx.hippoRoot, { minScore: 0.6 });
                if (shared.length > 0) {
                    result.shared = shared.length;
                }
            }
        }
        // Phase 5: Post-sleep ambient state summary.
        const postSleepConfig = phases.loadConfig(ctx.hippoRoot);
        if (postSleepConfig.ambient.enabled) {
            const postSleepEntries = phases.loadAllEntries(ctx.hippoRoot).filter((e) => !e.superseded_by);
            if (postSleepEntries.length > 0) {
                result.ambient = phases.computeAmbientState(postSleepEntries);
                ambientTotal = result.ambient.totalMemories;
            }
        }
        return result;
    }
    catch (err) {
        phaseError = err;
        throw err;
    }
    finally {
        // v1.11.5: emit one 'consolidate' audit_log row per api.sleep invocation,
        // with phase counters in metadata. Closes the CLI/MCP parity gap that T6
        // fixed for cmdOutcome (Episode A follow-up). In finally so partial-failure
        // paths still emit; `partial: true` + errorMessage flag the failure.
        // Dedicated handle for this emit only (phase helpers above each open their
        // own handle via hippoRoot — SQLite single-writer makes parallel handles
        // safe for the read-heavy phases).
        //
        // TODO(v1.12.0 + A5 v2): the audit row is tagged with ctx.tenantId but
        // api.sleep is host-wide (cross-tenant dedup is intentional). When
        // /v1/sleep moves off loopback-only, either tag with a synthetic "host"
        // tenant or scope api.sleep per-tenant. Independent-review-critic flag,
        // v1.11.5 ship.
        //
        // Error preservation: if openHippoDb or appendAuditEvent throws here, we
        // do NOT let it replace the original phaseError (independent-review HIGH:
        // would mask the underlying consolidation failure). Audit emit failure
        // is logged to stderr but the original throw wins.
        try {
            const db = openHippoDb(ctx.hippoRoot);
            try {
                // D2 v1.12.10: tag with '__host__' synthetic tenant since api.sleep
                // is host-wide (cross-tenant dedup is intentional). Tagging with
                // ctx.tenantId would mislead tenant-scoped audit queries — a
                // consolidate row labeled tenant=acme is wrong when the underlying
                // work touched all tenants' rows. '__host__' is a system-reserved
                // tenant string for host-wide ops; admins query it explicitly via
                // `hippo audit list --tenant __host__`. The actor field still
                // carries ctx.actor.subject so the operator who triggered the
                // consolidation is traceable.
                appendAuditEvent(db, {
                    tenantId: '__host__',
                    actor: ctx.actor.subject,
                    op: 'consolidate',
                    metadata: {
                        consolidationCount,
                        dedupCount,
                        auditDeletedCount,
                        ambientTotal,
                        dryRun,
                        noShare: opts.noShare ?? false,
                        partial: phaseError !== null,
                        triggeredByTenant: ctx.tenantId, // preserve for audit forensics
                        ...(phaseError ? { errorMessage: phaseError.message } : {}),
                    },
                });
            }
            finally {
                closeHippoDb(db);
            }
        }
        catch (auditErr) {
            // Audit emit failure must NOT mask the original phaseError. Log to
            // stderr so the secondary failure is observable but does not throw.
            // This guards the case where consolidation AND audit-emit fail in the
            // same invocation against the same DB (correlated: same disk, same
            // schema state) — losing the original error makes diagnosis much harder.
            // eslint-disable-next-line no-console
            console.error(`[hippo] api.sleep audit emit failed: ${auditErr.message}`);
        }
    }
}
// ---------------------------------------------------------------------------
// outcomeForLastRecall (last-recall wrapper around outcome — Task 3)
// ---------------------------------------------------------------------------
/**
 * Apply an outcome to the ids most recently returned by `recall()`.
 *
 * Reads `loadIndex(ctx.hippoRoot).last_retrieval_ids` (per-hippoRoot local
 * state; not tenant-scoped at the index layer) and forwards to `outcome()`,
 * which DOES tenant-filter via `readEntry(..., ctx.tenantId)`. Cross-tenant
 * ids in `last_retrieval_ids` are silently skipped, matching the MCP
 * `hippo_outcome` semantics.
 *
 * **Tenant-safe response shape (v1.11.4 security fix):** the returned `ids`
 * field contains ONLY the tenant-filtered subset that actually had outcomes
 * applied (i.e. `appliedIds` from the inner `outcome()` call). Earlier
 * versions returned the raw `last_retrieval_ids` regardless of tenant, which
 * leaked cross-tenant memory IDs to the caller via POST /v1/outcome's
 * no-body last-recall response. The fix is at this helper so all callers
 * (CLI cmdOutcome, HTTP /v1/outcome, MCP `hippo_outcome` if added later)
 * inherit the tenant-safe contract.
 *
 * Do NOT tighten `loadIndex` with `tenantId` inside this helper — doing so
 * would break the (correct) cross-tenant-silent-skip behavior covered by
 * the test in `tests/api-outcome-for-last-recall.test.ts`.
 */
export function outcomeForLastRecall(ctx, good) {
    const idx = loadIndex(ctx.hippoRoot);
    const ids = idx.last_retrieval_ids;
    if (ids.length === 0)
        return { applied: 0, ids: [] };
    const { applied, appliedIds } = outcome(ctx, ids, good);
    return { applied, ids: appliedIds };
}
//# sourceMappingURL=api.js.map