/** * 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 { type DatabaseSyncLike } from './db.js'; import { deleteEntry, loadAllEntries, type TaskSnapshot, type SessionEvent } from './store.js'; import { type RejectedValueRow } from './rejection.js'; import { type SessionHandoff } from './handoff.js'; import { type MemoryKind, type MemoryEntry } from './memory.js'; import { auditMemories, type AuditEvent, type AuditOp } from './audit.js'; import { autoShare } from './shared.js'; import { type ApiKeyListItem } from './auth.js'; import { type RerankStep } from './search.js'; import { consolidate } from './consolidate.js'; import { loadConfig } from './config.js'; import { deduplicateStore } from './dedupe.js'; import { computeAmbientState, type AmbientState } from './ambient.js'; import { loadPendingExtractionTenants } from './graph.js'; import { extractGraph } from './graph-extract.js'; import { type PlanningFallacyHint, type PlanningFallacyWatching } from './predictions.js'; import { type AnchoringHint, type RecallHistorySnapshot } from './recall-history.js'; import { type AvailabilityHint } from './availability.js'; /** * Actor identity + authorization role for a Context. v1.12.0 A5 v2 sub-1. * * Before v1.12.0, Context.actor was a bare string. v1.12.0 promotes it to an * object carrying both the audit-log subject (formerly the string itself) and * a role for /v1/sleep admin gating. Audit helpers continue accepting `string` * — callers pass `ctx.actor.subject`. Role checks happen at the request * boundary (e.g. /v1/sleep), not inside api functions. */ export interface Actor { /** 'cli' | 'localhost:cli' | 'api_key:' | 'mcp' | 'connector:slack' | 'connector:github' */ subject: string; role: 'admin' | 'member'; } export interface Context { hippoRoot: string; tenantId: string; actor: Actor; } /** * 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 declare function adminActor(subject: string): Actor; /** * 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 declare class RecallContractError extends Error { readonly code: 'fresh_tail_requires_session_id' | 'invalid_scorer_window'; constructor(code: 'fresh_tail_requires_session_id' | 'invalid_scorer_window', message: string); } import { isPrivateScope, passesScopeFilterForRecall } from './recall-scope.js'; export { isPrivateScope, passesScopeFilterForRecall }; export { passesCliRecallScopeFilter } from './recall-scope.js'; export { classifyOriginProject } from './project-identity.js'; /** * v39 S4: the secret half of the ambient policy on its own, for surfaces * with their own scope semantics (MCP hippo_context's explicit-scope * exact-match). A flagged row is only admitted inside its owning project; * flagged rows with no project origin never ambient-inject. */ export declare function ambientSecretAdmit(e: MemoryEntry, currentProjectName: string): boolean; export interface RememberOpts { content: string; kind?: MemoryKind; scope?: string; owner?: string; artifactRef?: string; tags?: string[]; /** * Optional hook invoked inside the same transaction as the underlying * memories INSERT. Used by ingestion connectors (E1.3+) to stamp * idempotency / cursor rows atomically with the memory row, so a crash * mid-write cannot produce a memory without its corresponding side-effect * log row (or vice versa). If the callback throws, the INSERT is rolled * back and the error is rethrown. */ afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void; } export interface RememberResult { id: string; kind: MemoryKind; tenantId: string; } export declare function remember(ctx: Context, opts: RememberOpts): RememberResult; export interface RecallOpts { query: string; limit?: number; /** * F3 (v1.7.0): scorer-window opt-in. When set, `loadSearchEntries` * loads up to `scorerWindow` candidates. When undefined (default), * the existing behaviour is preserved: store-internal 200-row default, * which every release before v1.7.0 silently relied on. * * `scorerWindow` lets callers decouple "how many candidates do I want * the scorer to evaluate" from `limit` ("how many do I want returned"). * Useful when `summarizeOverflow=true` and you want a wider candidate * pool to detect more level-2 parent clusters. * * NOT a hard cap on returned results. Fresh-tail and substituted * summaries can extend the result count above `limit`. The CLI's * existing slice in `cmdRecall` (cli.ts) is the CLI hard cap; library * callers slice themselves if they want one. * * Validated as a positive finite integer when set. `scorerWindow: 0` * or non-finite values throw `RecallContractError` with code * `invalid_scorer_window` to prevent the v1.6.x footgun where 0 fell * through to an uncapped fallback (codex v1.7.0 diff-pass P1). * * **Input is library-only at v1.7.0.** HTTP `/v1/memories`, MCP * `hippo_recall`, and `client.ts` thin-client do NOT serialize this * INPUT field; remote callers cannot send `scorerWindow` and will see * the store default applied. The OUTPUT `RecallResult.windowSize` is * always serialized over the wire (HTTP `sendJson` ships the whole * RecallResult, so remote callers receive `windowSize: 200` in the * response). Transport exposure for the input planned for v1.7.1 * alongside the deferred-queue items that need a wider candidate pool * (e.g. mean-of-children summary re-rank). */ scorerWindow?: number; mode?: 'bm25' | 'hybrid' | 'physics'; /** * Restrict results to memories whose `scope` equals this value exactly. * * When `scope` is undefined or empty, recall applies a DEFAULT-DENY rule: * any memory whose scope starts with `'slack:private:'` is filtered out so * a frontend caller passing `undefined` cannot accidentally surface * private-channel content. Memories with scope=null (the common case for * non-Slack content) are still returned. */ scope?: string; /** * v1.5.0 DAG-aware recall. When true (default), entries that overflow the * `limit` and share a level-2 parent summary cause that summary to be * appended in their place, capped at ceil(limit * 0.3) extra rows. Set to * false to disable and get the pre-v1.5 strict-limit behaviour. */ summarizeOverflow?: boolean; /** * v1.5.2 fresh-tail. When > 0, prepend the last N kind='raw' rows * (tenant + scope filtered, dedup against the BM25 hits) so an agent's * "what did I just see" recall path always covers the recent window * even when the query terms don't match. Capped at 200. Default 0 = off. */ freshTailCount?: number; /** * v1.6.2 fresh-tail session scope. When set, restricts the fresh-tail * window to a specific session. Without it, fresh-tail is tenant-wide, * which surfaces newest rows across ALL sessions — useful for "anything * new in this tenant", but wrong for "what did I just see in this one * conversation". Set to ctx-supplied session id for the correct shape. */ freshTailSessionId?: string; /** * When true, include a continuity block (active task snapshot, latest matching * session handoff, recent session events) on the result. Default false to keep * the hot path cheap; agent boot paths should set this to true. * * All three lookups are tenant-scoped to ctx.tenantId via the v0.40+ store * helpers. No risk of cross-tenant leak. * * Note: when no active snapshot exists, sessionHandoff is null and * recentSessionEvents is []. We deliberately do NOT fall back to the latest * tenant handoff without a session anchor, to avoid resurrecting stale state * after a session ends. The explicit handoff-without-snapshot path remains * `hippo session resume`. */ includeContinuity?: boolean; /** * v1.7.4 -- when set AND `(ctx.tenantId, sessionId)` has active goals AND * `goalTag` is unset, `api.recall` applies the dlPFC goal-stack boost lifted * from CLI cmdRecall. Pre-v1.7.4 the boost was CLI-only (env-driven via * HIPPO_SESSION_ID). Undefined preserves v1.7.3 behaviour (no boost). * * Why on RecallOpts and not Context: Context is shared by remember/recall/ * assemble/outcome. Goal-stack boost is recall-scoped only. */ sessionId?: string; /** * v1.7.4 -- explicit goal-tag override. When set, the goal-stack boost is * SUPPRESSED (mirrors the CLI's `goalTag === ''` gate from v0.38). Use to * pin recall ranking against one specific goal/tag without the multi-goal * stack interfering. */ goalTag?: string; /** * v0.33 / J1 anchoring detector. Caller-supplied snapshot of the per- * (tenant, session) recall ring. When present, api.recall computes * `RecallResult.anchoringHint` against this snapshot + the just-computed * top-1. When undefined (default), no anchoring detection runs on the * api.recall surface — but a calling pipeline (CLI cmdRecall, MCP * hippo_recall) MAY compute its own hint via the shared * `detectAnchoring()` helper against its own ring + top-1. * * Pure read: api.recall NEVER mutates the snapshot or any caller-side * Map. Caller is responsible for appending to its own ring after the * recall (passing the resulting hint's memoryId as `anchoredOn` to feed * the cooldown logic on the NEXT recall). */ recallHistory?: RecallHistorySnapshot; /** * v1.13.x / J2 — when true, api.recall does NOT compute or emit the * availabilityHint. Callers that run their OWN per-pipeline availability * detection over a different result set (the MCP handler computes it over * physics/hybrid results, not api.recall's BM25 band) pass this to avoid a * double audit emission and a hint describing a result set the caller never * surfaces. Mirrors how J1 only computes anchoring when opts.recallHistory * is supplied. HTTP / direct SDK callers leave this unset and receive the hint. */ suppressAvailabilityHint?: boolean; /** * A7 recall-trace. When true, api.recall captures the lifecycle re-ranking * trace (currently the goal-boost step on the primary band) and attaches it * to each `RecallResultItem` as `rerankTrace`, plus `rerankPipeline:'api'`. * When undefined/false (default), both fields are absent on EVERY band so * the response shape is byte-identical to pre-A7. The api pipeline applies * only goal-boost; the richer CLI stages (interference/value/utility/ * reranker/retrieval-count-downweight) are A7.2. */ explain?: boolean; /** * LC1 (docs/plans/2026-08-02-lc1-recall-trace-persistence.md) / F2 fix. * When true, api.recall does NOT write a recall_traces row for this call. * Mirrors `suppressAvailabilityHint`'s pattern: callers that run their OWN * tracing over a DIFFERENT result set must suppress api.recall's copy so * the training corpus doesn't get a trace mislabeled as 'api' pipeline * when the caller's actual user-visible results came from elsewhere. The * MCP handler sets this — its primary ranked band comes from a separate * physics/hybrid scorer, not this api.recall call's BM25 band (real MCP * tracing is the reserved 'mcp' pipeline, a follow-up). HTTP / direct SDK * callers leave this unset and get the trace. */ suppressRecallTrace?: boolean; } export interface ContinuityBlock { activeSnapshot: TaskSnapshot | null; sessionHandoff: SessionHandoff | null; recentSessionEvents: SessionEvent[]; } export interface RecallResultItem { id: string; content: string; score: number; layer: string; strength: number; /** * v1.5.0 DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2). * True when this row is a level-2 topic summary substituted in for * overflowed children that didn't fit the limit. */ isSummary?: boolean; /** * IDs of the overflow leaves this summary covers. Caller can drill * into these via `drillDown` (Task 3) to recover the original detail. */ substitutedFor?: string[]; /** Cached descendant count from schema v25; non-zero for level-2+ rows. */ descendantCount?: number; /** * v1.5.2 fresh-tail (docs/plans/2026-05-05-dag-recall.md Task 4). True * for rows surfaced via the most-recent-N kind='raw' window, NOT by the * BM25 query match. Caller can render them in a separate "recent" band. */ isFreshTail?: boolean; /** * A7 recall-trace. Ordered lifecycle re-ranking steps that mutated this * row's `score` after candidate generation. On the api pipeline this carries * the goal-boost step (the only re-ranking api.recall applies). Populated * ONLY when `RecallOpts.explain` is set; absent on the default path * (additive optional, back-compat per the `windowSize?` precedent; * `client.ts` deserializes `as RecallResult` so the field rides through). */ rerankTrace?: RerankStep[]; /** * A7 recall-trace. Names which pipeline produced `rerankTrace`. `'api'` on * every band returned by `api.recall` when `explain` is set; the CLI carries * its trace on `SearchResult` instead and does not set this. Absent on the * default path. Distinguishes the api pipeline (goal-boost only) from the * richer CLI pipeline (A7.2 will unify them). */ rerankPipeline?: 'cli' | 'api'; } export interface RecallResult { results: RecallResultItem[]; total: number; tokens: number; continuity?: ContinuityBlock; /** * Tokens consumed by the continuity block: snapshot (task + summary + next_step) * + handoff (summary + nextAction + artifacts + constraints + evidence line) * + every event's full content across the last 5 events. Each measured by Math.ceil(len/4), matching * the existing `tokens` count and src/search.ts estimateTokens(). * Undefined when continuity not requested. Callers needing a tighter budget * should truncate event.content themselves before display. */ continuityTokens?: number; /** * F3 (v1.7.0): scorer window actually used for this recall. Equals * `opts.scorerWindow` when set, otherwise the store-internal default * (200) used by `loadSearchEntries(undefined, ...)`. Reported so * callers can introspect "did the scorer see enough candidates?" * without re-deriving the value. * * Optional in the type to keep `RecallResult` literal-construction * back-compatible with pre-v1.7 test fakes / mocks (senior review P1-2). * Always present on values returned by `api.recall` itself; consumers * reading from `api.recall` can treat it as defined. */ windowSize?: number; /** * v1.12.13 / C5 — WYSIATI cutoff transparency. When present, gives the * calling agent a per-pipeline breakdown of what was excluded from * `results[]` and why. Always populated by `api.recall`, `cmdRecall`, and * the MCP `hippo_recall` handler. Optional in the type for back-compat * with test fakes / mocks (same pattern as `windowSize?`). * * Counters reflect actual filter activity in the pipeline that produced * THIS specific RecallResult. api.recall counts its own filter sites; * cmdRecall counts its (richer) filter sites; MCP counts the physics/ * hybrid pipeline's filter sites. Shape is identical across surfaces; * numbers are honest per-path reports, NOT normalised cross-pipeline * counts. */ suppressionSummary?: RecallSuppressionSummary; /** * v0.32 / J3.2 — auto-injected planning-fallacy hint. When the recall * query carries a forward-prediction phrase ("will take ~3 days", "ship * by Friday", "ETA in 2 weeks") AND the closest matching prediction * class has closed historical data, this carries the base-rate stats so * the calling agent sees its track record at the moment of forecasting * (Lovallo-Kahneman 2003 inside-vs-outside view). * * Populated by `api.recall` itself via `computePlanningFallacyHint`. * Pipeline-invariant: the value depends only on (queryText, tenantId, * predictions table state) — all three are identical regardless of * which downstream search pipeline produces the memory list, so MCP * and CLI both read this field as the single source of truth (unlike * `suppressionSummary` which is per-pipeline). * * Optional in the type so existing test fakes / mocks of RecallResult * remain valid (same pattern as `windowSize?` / `suppressionSummary?`). * Disabled by setting `HIPPO_AUTODEBIAS=off`. */ planningFallacyHint?: PlanningFallacyHint; /** * v1.13.4 / J3.2 follow-up — "watching" variant emitted when the * forward-claim regex matched but no baserate could be produced * (either because no prediction class scored ≥ 1 on token overlap, * or because ≥2 classes tied at the best score). Mutually exclusive * with `planningFallacyHint`: at most one of the two is set per * recall. Dogfood diary (docs/dogfood/2026-05-27-track-j-warnings.md) * Trial 2a confirmed the pre-v1.13.4 silent-no-class-match path was * the dominant J3.2 failure mode, because natural-language queries * rarely share non-stopword tokens with class tags. The watching * variant gives the agent enough signal to either re-tag the * prediction or pass the suggestion through to the user. * * Pipeline-invariant same as `planningFallacyHint`. Honoured by * api.recall, cmdRecall, and MCP handler render paths. * Disabled by setting `HIPPO_AUTODEBIAS=off`. */ planningFallacyWatching?: PlanningFallacyWatching; /** * v0.33 / J1 (v1.13.2) — recall-recurrence anchoring hint. Populated * when api.recall's `opts.recallHistory` snapshot + the just-computed * top-1 satisfy R1 (query_repeat) or R2 (memory_dominance). * * Per-pipeline detection: each pipeline (api.recall, cmdRecall, MCP) * computes its OWN hint against its OWN top-1. This field reflects * api.recall's compute ONLY. On CLI-routed call paths cmdRecall does * NOT thread its ring snapshot through `opts.recallHistory`, so this * field is null on CLI-routed calls even when CLI's own hint fires * (the user-visible hint there comes from cmdRecall's parallel * compute, surfaced via the CLI render path + cmdSuppressionSummary). * Non-null on direct SDK / HTTP-routed invocations where the caller * threads its own ring snapshot. * * Disabled by setting `HIPPO_ANCHORING=off`. */ anchoringHint?: AnchoringHint; /** * v1.13.x / J2 — availability/recency-bias hint. Per-pipeline (computed * against this pipeline's own returned top-K + the matched candidate pool * it was drawn from), soft-warning ONLY: never filters, reorders, or * suppresses a result. Fires when the returned slice is recency-dominated * while substantially older relevant matches in the same pool were passed * over. Disabled by setting `HIPPO_AVAILABILITY=off`. */ availabilityHint?: AvailabilityHint; } /** * v1.12.13 / C5 — WYSIATI cutoff transparency (Track C Pineal Gland, C5). * * Surfaces what the recall pipeline excluded from `results[]` so the calling * agent does not treat the cutoff as the full picture (Kahneman's "What You * See Is All There Is" failure mode, TFAS ch. 7). Each counter reflects * filter activity in the pipeline that produced this RecallResult; counts * are honest per-path reports, not normalised cross-pipeline numbers. * * See `buildSuppressionSummary` for the shared construction helper used by * all three pipelines (api.recall, cmdRecall, MCP). */ export interface RecallSuppressionSummary { /** Total candidates loaded from the store, before any post-load filter or * limit cut. Per-pipeline source: * - api.recall: `all.length` immediately after `loadRecallSearchEntries` * - cmdRecall: candidate count immediately after the initial load * - MCP physics/hybrid: count of entries passed to physicsSearch/hybridSearch */ totalCandidates: number; /** Candidates dropped by any non-budget filter site (pre-rank OR post-rank, * but NOT the final budget cut). Field name retains the `preRank` label * for the original framing; semantically: any filter drop that is not the * final limit slice. Per-pipeline source: * - api.recall: `all.length - entries.length` (private-scope JS filter + scope-mismatch defense; pre-rank) * - cmdRecall: SUM of drops from `--as-of`, default-drop of superseded (when `--include-superseded` not set), `--filter-conflicts` (`.filter` drop only), `--outcome` (post-rank), `--layer` (post-rank). `--salience-threshold` HARD drops would also land here; current implementation is soft-rebalance only (logged in `ScoreBreakdown`, not here). * - MCP physics/hybrid: scope-filter drops at the MCP handler before physicsSearch */ droppedPreRank: number; /** Candidates loaded but excluded by the final `limit` slice after scoring. * Per-pipeline source: * - api.recall: `entries.length - baseSlice.length` * - cmdRecall: pre-slice candidate count minus final slice count * - MCP physics/hybrid: pre-slice minus post-slice at the physics/hybrid limit */ droppedByBudget: number; /** Substituted DAG-L2 summaries added back to mitigate overflow. * Per-pipeline source: * - api.recall: `substituted.length` after the `summarizeOverflow` block * - cmdRecall: 0 (CLI does not run summarizeOverflow) * - MCP physics/hybrid: count of summary rows appended from apiResult.tailOrSummary */ summarySubstitutionsAdded: number; /** Fresh-tail `kind='raw'` rows prepended. * Per-pipeline source: * - api.recall: `freshRanked.length` when `freshTailCount > 0`; else 0 * - cmdRecall: 0 (CLI does not currently expose fresh-tail) * - MCP physics/hybrid: count of fresh-tail rows appended from apiResult.tailOrSummary */ freshTailAdded: number; /** Counter of memories suppressed by detected interference patterns. * v0.33 / J1 (v1.13.2): incremented by 1 PER PIPELINE when that * pipeline's own R2 memory_dominance verdict fires (via the J1 * anchoring detector — see `detectAnchoring()` in src/recall-history.ts). * Each pipeline (api.recall, cmdRecall, MCP physics/hybrid) bumps its * OWN suppressionSummary independently because each runs its own * detector against its own top-1 + its own per-(tenant, session) ring * buffer. The number reflects this-pipeline interference only; not a * cross-pipeline aggregate. * * Future B4-depth work may add additional sources (e.g. vlPFC inhibition * scores). No `interference_suppression` table is built — the v1.12.13 * doc that referenced one was speculative; J1 uses caller-side in-memory * rings instead. */ suppressedByInterference: number; } /** * 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 declare function buildSuppressionSummary(counts: { totalCandidates: number; droppedPreRank: number; droppedByBudget: number; summarySubstitutionsAdded: number; freshTailAdded: number; suppressedByInterference: number; }): RecallSuppressionSummary; /** * 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 declare function recall(ctx: Context, opts: RecallOpts): RecallResult; export interface AssembleOpts { /** Token budget. Default 4000. */ budget?: number; /** Recent raw rows always kept verbatim. Default 10. */ freshTailCount?: number; /** Substitute parent summaries for older raws when ≥2 share a level-2 * ancestor. Default true. */ summarizeOlder?: boolean; /** * Restrict to a specific scope. v1.6.1 senior-review P1 #3 parity with * `recall`: when set, exact match required (so an authorised caller can * assemble a `slack:private:CSEC` session by passing scope explicitly). * When undefined, default-deny applies to ANY `:private:*` and * `unknown:legacy` rows. */ scope?: string; /** * Hard row cap on the SELECT that loads session raws. Default 5000 to * protect against degenerate sessions. When the cap is hit, `truncated` * is set on the result so the caller knows to widen. */ rowCap?: number; } export interface AssembledContextItem { id: string; content: string; /** ISO timestamp of the source row's `created` field (or `earliest_at` * for substituted summaries). */ createdAt: string; /** Fresh-tail protected window (last freshTailCount raws). */ isFreshTail?: boolean; /** Level-2 summary substituted for older raw rows that share a parent. */ isSummary?: boolean; /** When isSummary, the raw ids this summary covers. drillDown * recovers the originals. */ substitutedFor?: string[]; /** Decay × retrieval × emotional. Lets callers render a confidence * hint without re-deriving from MemoryEntry. */ strength: number; } export interface AssembleResult { sessionId: string; items: AssembledContextItem[]; tokens: number; /** * Tenant + scope-filtered raw row count for the session — what the caller * could have seen given their grant. Pre-v1.6.1 was pre-filter (confusing * for all-private sessions); pre-v1.6.3 was capped (under-reported on * sessions > rowCap). v1.6.3 reports the FULL post-filter count via a * separate COUNT(*) query so consumers can render "session has N msgs" * accurately even when items[] is the windowed view. */ totalRaw: number; summarized: number; evicted: number; /** * True when `rowCap` truncated the loaded window. With v1.6.2's NEWEST-cap * semantics, the items[] array represents the freshest tail of the session; * older rows beyond the cap are silently absent. Use `totalRaw - items.length * - summarized + ...` to estimate how much you didn't see, or widen `rowCap`. */ truncated: boolean; } /** * 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 declare function assemble(ctx: Context, sessionId: string, opts?: AssembleOpts): AssembleResult; export interface DrillDownOpts { /** Cap on number of children returned. Default 50. */ limit?: number; /** * Optional token budget. When set, children are appended in chronological * order (created ASC) until adding the next child would exceed the budget. * Token cost = ceil(content.length / 4) per child. * * For depth > 1, the budget is GLOBAL cumulative (NOT per-level). */ budget?: number; /** * v0.30 / E5 — walk N levels down (default 1 = direct children only). * Higher values include children of children, etc. Internal hard cap 10 * to prevent pathological depth walks. BFS uses visited Set for dedup * (defensive against shared-child data anomalies; DAG is acyclic by * construction). */ depth?: number; } export interface DrillDownResult { summary: { id: string; content: string; descendantCount: number; earliestAt: string | null; latestAt: string | null; }; children: Array<{ id: string; content: string; layer: string; dagLevel: number; created: string; }>; totalChildren: number; truncated: boolean; } /** * v1.6.4 discriminated failure shape. Two reasons distinguishable: * - `not_found`: covers genuinely-missing, wrong-tenant, AND * scope-blocked (codex round 3 P1 — distinguishing scope_blocked * from not_found on non-HTTP surfaces leaked private-row existence * to no-scope callers, even though the HTTP route already collapsed * them. Collapse at the API layer.) * - `not_drillable`: id is a leaf row (level 0/1). Caller-actionable. * * If a future drillDown gains a `scope` opt for explicit-scope callers, * a `scope_blocked` failure could be safely re-introduced ONLY for that * code path (caller already proved authorization by passing a scope). */ export interface DrillDownFailure { failure: 'not_found' | 'not_drillable'; } export type DrillDownOutcome = DrillDownResult | DrillDownFailure; /** * 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 declare function drillDown(ctx: Context, summaryId: string, opts?: DrillDownOpts): DrillDownOutcome; /** * 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. * * `opts.traceId` (LC1, docs/plans/2026-08-02-lc1-recall-trace-persistence.md): * OPTIONAL additive opt so a programmatic caller can link this outcome to * the recall_traces row it judges. NOT applied unconditionally — an SDK * caller passing explicit ids with no preceding CLI/context recall would * otherwise get linked to a stale, unrelated trace. `outcomeForLastRecall` * supplies this automatically from `last_trace_id`; every other caller * (server.ts explicit-ids path, MCP hippo_outcome) omits it and gets no * linkage, which is correct. */ export interface OutcomeResult { applied: number; appliedIds: string[]; } export declare function outcome(ctx: Context, ids: ReadonlyArray, good: boolean, opts?: { traceId?: number; }): OutcomeResult; /** * 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 interface ForgetResult { ok: true; id: string; } export declare function forget(ctx: Context, id: string): ForgetResult; export interface RejectOpts { /** By-id form: reject the CURRENT content of an existing memory. */ memoryId?: string; /** Pre-emptive form: reject a value not currently stored (or already gone). */ value?: string; /** Required — the tombstone stores no content; reason is its only identity. */ reason: string; } export interface RejectResult { digest: string; removedIds: string[]; } /** * Reject a value: tombstone its normalized digest so a matching write is * refused everywhere (remember/capture/import/sync) until `unreject`. Two * forms — pass exactly one: * - `memoryId`: reject the CURRENT content of an existing memory. Removes * that row and every other live row in the tenant whose normalized * digest matches (not just the id passed). * - `value`: pre-emptive form — tombstone content that may not currently * be stored (or is already gone). Zero removals. * * `reason` is required (the tombstone stores no content; reason is its * only human-readable identity). Throws if the memory id is not found in * `ctx.tenantId`, or if both/neither of `memoryId`/`value` are given. */ export declare function reject(ctx: Context, opts: RejectOpts): RejectResult; /** * Delete a tombstone by exact digest or unambiguous prefix, restoring the * value's writability — the only v1 escape hatch (no per-write force flag). * Throws if `digestOrPrefix` matches no tombstone, is blank, or matches * more than one (use a longer prefix). */ export declare function unreject(ctx: Context, digestOrPrefix: string): { ok: boolean; digest: string; }; /** List every rejected-value tombstone for `ctx.tenantId`, newest first. */ export declare function listRejections(ctx: Context): RejectedValueRow[]; /** * 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 interface PromoteResult { ok: true; sourceId: string; globalId: string; } export declare function promote(ctx: Context, id: string): PromoteResult; /** * 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 interface SupersedeResult { ok: true; oldId: string; newId: string; } export declare function supersede(ctx: Context, oldId: string, newContent: string): SupersedeResult; /** * Archive a kind='raw' memory: snapshot into raw_archive, mark archived, delete. * * `archiveRawMemory` audits the operation internally (op='archive_raw') using the * row's own tenant_id. We DO NOT emit a second audit event here to avoid double- * emitting the archive_raw op (unlike Task 1 remember/forget where the underlying * helpers hardcode actor='cli'). Instead we pass `ctx.actor.subject` through as `who`, * and raw-archive.ts uses that for the audit row. */ export interface ArchiveRawOpts { /** * Connector idempotency hook (v0.39 commit 3). Runs inside the same * SAVEPOINT as the archive — throwing rolls the archive back. Used by the * Slack deletion connector to mark the deletion event seen atomically. */ afterArchive?: (db: DatabaseSyncLike, archivedMemoryId: string) => void; } export interface ArchiveRawResult { ok: true; archivedAt: string; } export declare function archiveRaw(ctx: Context, id: string, reason: string, opts?: ArchiveRawOpts): ArchiveRawResult; export interface AuthCreateOpts { label?: string; /** * v1.12.3: authorization role for the new key. Defaults to `'admin'` for * back-compat with v1.12.0-v1.12.2 (the api_keys.role column DEFAULT also * resolves to 'admin' if omitted from the INSERT). Member keys are * 403-blocked from admin-gated routes (e.g. `POST /v1/sleep`). */ role?: 'admin' | 'member'; } export interface AuthCreateResult { keyId: string; plaintext: string; tenantId: string; /** v1.12.3: the role bound to the new key (admin | member). */ role: 'admin' | 'member'; } /** * 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 declare function authCreate(ctx: Context, opts: AuthCreateOpts): AuthCreateResult; /** * 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 declare function authList(ctx: Context, opts: { active: boolean; }): ApiKeyListItem[]; /** * 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 interface AuthRevokeResult { ok: true; revokedAt: string; } export declare function authRevoke(ctx: Context, keyId: string): AuthRevokeResult; export interface AuditListOpts { op?: AuditOp; /** ISO timestamp lower bound. */ since?: string; limit?: number; } /** * Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches * A5: cmdAuditList does not record a 'recall'-style read event). */ export declare function auditList(ctx: Context, opts: AuditListOpts): AuditEvent[]; /** * Options for `getContext` — assemble a budget-bounded context bundle * (recalled memories + active task snapshot + handoff + recent events). * Extracted from `cmdContext` in `cli.ts` in Episode A of the api.ts refactor. * * Named `getContext` (not `context`) to avoid collision with the `Context` * interface above and the ubiquitous `ctx: Context` convention. Follows the * existing `getEntry` naming pattern in store.ts. * * Scope narrow (T5 execute decision): rendering opts (`format`, `framing`, * `rendered`) and host-side opts (`auto`) are NOT included here. The print * helpers (`printContextMarkdown`, `printActiveTaskSnapshot`, `printHandoff`, * `printSessionEvents`) are shared with `cmdRecall` / `cmdSnapshot` / * `cmdHandoffShow` — moving them into api.ts would expand T5 to also rewire * those commands. CLI handles rendering + auto-resolution. Episode B can add * `api.renderContext` once a shared rendering need actually materializes. */ export interface ContextOpts { q?: string; /** Default 1500 tokens. */ budget?: number; limit?: number; pinnedOnly?: boolean; scope?: string; /** With `pinnedOnly`, also inject the N most recent writes that pass the * quality floor (`isContentWorthStoring`, DF3). Filtering happens BEFORE * the take-N, so a caller asking for 5 gets 5 qualifying entries rather * than 5-minus-junk; pinned entries bypass the floor. Entries are only * skipped for this read, never mutated or deleted. Ignored when * `pinnedOnly` is false — no other path reads it. */ includeRecent?: number; /** v39 memory scope isolation: re-include other-project memories that the * origin partition excludes by default. They come back tagged * `category: 'cross-project'` so renderers can demarcate them. */ crossProject?: boolean; /** The active project name for the origin partition ('' = not in a * project). Defaults to `resolveProjectIdentity(process.cwd()).name`; * surfaces whose process cwd is not the caller's project (HTTP server) * should pass it explicitly. */ currentProject?: string; /** DF1 (docs/plans/2026-08-23-df1-snapshot-lifecycle.md, T2): the calling * session's id. Stamped on this call's recall trace, and the owner-match input to * `loadFreshActiveTaskSnapshot` — when it strictly equals the active * snapshot's `session_id`, the read is unbounded (same-session * continuity); otherwise the snapshot must pass the freshness bound to * surface. Absent (undefined/null/'') never short-circuits as a match; * it just means every snapshot goes through the age check. Host-resolved * (stdin payload, HIPPO_SESSION_ID, else the host's session var) so this stays host-agnostic. */ currentSessionId?: string | null; } export interface ContextResultEntry { entry: MemoryEntry; score: number; tokens: number; isGlobal?: boolean; isFreshTail?: boolean; /** v39: the entry's owning project ('' = user-global, null = legacy row). */ origin?: string | null; /** v39: how the origin relates to the active project. 'cross-project' * entries only appear when ContextOpts.crossProject was set (or isolation * is disabled). */ category?: 'project' | 'user-global' | 'cross-project'; } export interface ContextResult { entries: ContextResultEntry[]; tokens: number; activeSnapshot?: TaskSnapshot | null; sessionHandoff?: SessionHandoff | null; recentEvents?: SessionEvent[]; /** The ambient landscape summary over the admitted entries. Present only * when the store's ambient config is on, the caller is not pinned-only, * and at least one entry was admitted. */ ambientState?: AmbientState; } /** * 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 declare function getContext(ctx: Context, opts?: ContextOpts): Promise; /** * Options for `sleep` — run the pure-storage consolidation pipeline * (consolidate + dedup + audit + share + ambient) and return structured counts. * * Extracted from `cmdSleepCore` Phase 2-6 in Episode A. NOT covered by api.sleep: * the cli-only auto-learn phase (Phase 1: learnFromRepo + learnFromMemoryMd), * which is intrinsically host-bound (uses `process.cwd()` / `os.homedir()`). * Auto-learn stays in cli.ts cmdSleepCore as a pre-api block. * * The CLI `cmdSleep` wrapper continues to own the log-file tee + console * rendering + `process.exit`; `api.sleep` is pure (no console.log, no IO * beyond the store). */ export interface SleepOpts { dryRun?: boolean; noShare?: boolean; /** * @internal Test-only DI seam — see `tests/api-sleep-phase-faults.test.ts`. * Override one or more phase dependencies (typically a throwing stub) to * force mid-phase failure paths deterministically. Production callers * MUST NOT use this field. The runtime defaults at `DEFAULT_SLEEP_PHASES` * preserve all current behaviour when `__phases` is undefined. */ __phases?: Partial; } export interface SleepResult { active: number; removed: number; mergedEpisodic: number; newSemantic: number; dryRun: boolean; deduped?: { removed: number; semDups: number; epiDups: number; crossDups: number; }; audit?: { errorsRemoved: number; warningCount: number; }; shared?: number; /** * v1.25.0: count of memories the auto-share secret veto withheld this sleep * — rows that passed every other admission gate (transfer score, * not-already-global) and were blocked solely by `detectSecret`. Absent * when 0 or when auto-share did not run. Same redaction class as `shared` * (per-invocation activity counter, NOT redacted on egress — see the * "NOT redacted" list in src/sleep-redact.ts). */ secretSkipped?: number; /** * AT1: count of auto-share candidates the GLOBAL store's rejection * tombstone refused this sleep (docs/plans/2026-08-15-at1-rejected-value-tombstone.md * plan §3 — copy paths must not let one rejected candidate abort the * batch). Absent when 0 or when auto-share did not run. Same * per-invocation-activity class as `secretSkipped` (sibling counter, * same autoShare call) — NOT redacted on egress, see sleep-redact.ts. */ rejectedSkipped?: number; ambient?: AmbientState | null; /** * E3 sleep enqueue-hook: graph re-extraction totals across the tenants rebuilt * this sleep. Absent when no tenant was dirty, and under dryRun (the graph * phase runs only on a real sleep). Cross-tenant aggregate — zeroed on * non-loopback non-self egress by sleep-redact.ts. */ graph?: { tenants: number; entities: number; relations: number; }; details?: string[]; } /** * Run the pure-storage consolidation pipeline. * * Tenant scope note: sleep operates on the WHOLE hippoRoot (all tenants in * it), matching the pre-refactor cmdSleepCore behavior. Correct for a CLI * maintenance op invoked by the operator. Episode B (v1.11.4) exposed this * over HTTP `/v1/sleep` with loopback-only enforcement (per-request guard * in the handler plus serve()'s boot-time host check). The TODOS.md * per-tenant scoping follow-up remains open for the day non-loopback * serving lands — at that point the route will need an admin-role gate OR * api.sleep itself will need to scope dedup / audit / delete by ctx.tenantId. * * Audit emission gap: the consolidation phases (dedup, audit-delete) do * NOT emit audit_log rows today, matching pre-refactor cmdSleepCore. Same * CLI/MCP parity gap that T6 fixed for cmdOutcome, now visible at the api * surface. Tracked in TODOS.md "Episode A follow-ups" for a future minor. */ /** * v1.12.2: Test-only DI seam shape for `sleep`'s phase dependencies. * * Each field defaults to the real production implementation imported at the * top of this file. Test files pass a `Partial` override via * `SleepOpts.__phases` (note the `__` prefix — internal-only) to inject * deterministic throws for mid-phase failure-path coverage (the * `partial: true` + `errorMessage` audit-row branch at line ~2098). * * Production callers MUST NOT use `__phases`. The field exists solely so * `tests/api-sleep-phase-faults.test.ts` can force each phase boundary to * throw without depending on store-corruption fragility. */ export interface SleepPhases { consolidate: typeof consolidate; deduplicateStore: typeof deduplicateStore; auditMemories: typeof auditMemories; autoShare: typeof autoShare; loadAllEntries: typeof loadAllEntries; deleteEntry: typeof deleteEntry; computeAmbientState: typeof computeAmbientState; loadConfig: typeof loadConfig; loadPendingExtractionTenants: typeof loadPendingExtractionTenants; extractGraph: typeof extractGraph; } export declare function sleep(ctx: Context, opts?: SleepOpts): Promise; /** * 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 interface OutcomeForLastRecallResult { applied: number; ids: string[]; } export declare function outcomeForLastRecall(ctx: Context, good: boolean): OutcomeForLastRecallResult; //# sourceMappingURL=api.d.ts.map