/** * Storage layer for Hippo. * * SQLite is the source of truth. * Markdown + JSON files remain as human-readable compatibility mirrors. */ import { MemoryEntry, Layer } from './memory.js'; import { openHippoDb, getHippoDbPath, type DatabaseSyncLike } from './db.js'; import { SessionHandoff, HandoffEvidence, HandoffOutcome } from './handoff.js'; import { Card, CardStatus, CardRun, CardComment } from './card.js'; import { type ResolveProjectIdentityOpts } from './project-identity.js'; import { RejectedValueError } from './rejection.js'; /** A value that round-trips through JSON.stringify/JSON.parse unchanged. */ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue; }; /** * Refusal audit for the AT1 rejection guard (plan §3). Written by the * transaction OWNER post-rollback — writeEntry's catch (no outer tx exists * there, so this lands in a fresh implicit transaction) and api.supersede's * catch (after its own ROLLBACK) — never inside a scope the caller's own * rollback could claw back. Best-effort `audit()` semantics: never throws. */ export declare function auditRejectionRefusal(db: ReturnType, err: RejectedValueError, actor: string): void; export interface IndexEntry { id: string; file: string; layer: Layer; strength: number; tags: string[]; created: string; last_retrieved: string; pinned: boolean; } export interface HippoIndex { version: number; entries: Record; last_retrieval_ids: string[]; /** LC1 (docs/plans/2026-08-02-lc1-recall-trace-persistence.md): id of the * most recent recall_traces row written by getContext/cmdRecall, mirrored * from the `last_trace_id` meta key exactly like last_retrieval_ids. null * when no trace has been written yet (fresh store, pre-v40 flow, or * api.recall-only usage — api.recall never sets this). */ last_trace_id: string | null; } export interface TaskSnapshot { id: number; task: string; summary: string; next_step: string; status: string; source: string; session_id: string | null; scope: string | null; created_at: string; updated_at: string; } export interface MemoryConflict { id: number; memory_a_id: string; memory_b_id: string; reason: string; score: number; status: string; detected_at: string; updated_at: string; } export interface SessionEvent { id: number; session_id: string; task: string | null; event_type: string; content: string; source: string; scope: string | null; metadata: Record; created_at: string; } /** * Default candidate-pool size for `loadSearchEntries` when called with * `limit === undefined`. Single source of truth; `api.recall` imports * this for `RecallResult.windowSize` reporting so the two cannot drift. */ export declare const DEFAULT_SEARCH_CANDIDATE_LIMIT = 200; /** * v1.7.2 — literal scopes excluded from recall by default-deny when the * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS * helper `passesScopeFilterForRecall` (src/api.ts) both read from this * constant. Adding a deny scope is a one-place change. * * Regex-based denies (e.g. `:private:*`) stay in * `passesScopeFilterForRecall` as a separate JS step — they don't translate * cleanly to SQL. * * Invariant: never empty. An empty array would silently allow quarantine * scopes through both paths (SQL clause omitted, JS check vacuous). The * module-load assertion below pins this loudly. */ export declare const RECALL_DEFAULT_DENY_SCOPES: readonly ["unknown:legacy"]; /** * @internal v1.7.3 — runtime guard against a future maintainer blanking a * load-bearing literal array. Extracted from the inline guard so the throw * path is directly testable. `as const` arrays widen via `readonly T[]` at * the call site so the empty case is reachable at runtime. */ export declare function assertNonEmpty(arr: readonly T[], name: string): void; /** Nearest ancestor store like git; the strict join is the fallback so `hippo init` still creates `/.hippo`. */ export declare function getHippoRoot(cwd?: string, opts?: ResolveProjectIdentityOpts): string; export declare function isInitialized(hippoRoot: string): boolean; export declare function initStore(hippoRoot: string): void; /** * Serialize a MemoryEntry to markdown with YAML frontmatter. */ export declare function serializeEntry(entry: MemoryEntry): string; /** * Deserialize a markdown file to a MemoryEntry. */ export declare function deserializeEntry(raw: string): MemoryEntry | null; /** * v1.7.2 — recall-mode scope filter shape, exported so callers * (`loadRecallSearchEntries`) and tests can refer to it symbolically without * `Parameters[N]` indirection. * * Three modes: * - 'default-deny' — exclude scopes in `RECALL_DEFAULT_DENY_SCOPES` (T2). * - 'exact' — exact match on `m.scope = value` (api.recall's explicit-scope * request semantics). * - 'default-deny-or-exact' (v1.25.0) — the default-admitted set PLUS rows * whose scope equals `value`. This is the CLI `--scope` semantics: the * flag predates the envelope column as a TAG-boost ranking hint * (`scope:` tags, HIPPO_SCOPE), so an explicit flag must UNLOCK the * named envelope scope in addition to the normal set rather than narrow * the result to it — narrowing would return zero rows for every * tag-scoped workflow (envelope scope NULL). Strictly safer than the * pre-v1.25.0 CLI behavior (no filter at all): other private scopes and * quarantine buckets stay denied. * * Background pipelines (`consolidate`, `embeddings`, `refine-llm`, ...) call * `loadSearchEntries` (no scopeFilter arg) and see all rows including * quarantine. */ /** @internal v1.7.2 — internal SQL-builder shape; not on the public API * surface (not re-exported from `src/index.ts`). Subject to change. */ export type RecallScopeFilter = { mode: 'default-deny'; } | { mode: 'exact'; value: string; } | { mode: 'default-deny-or-exact'; value: string; }; export declare function removeEntryMirrors(hippoRoot: string, id: string): void; /** * AT1 mirror-purge honesty fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md): * the candidate markdown mirror paths still on disk for `id`, computed the * same way `removeEntryMirrors` walks them (one per layer: buffer/episodic/ * semantic), filtered to the ones that still `fs.existsSync`. Used to report * an EXPLICIT path when a best-effort purge fails and no reaper exists to * retry it — plain `removeEntryMirrors` returns void, giving no way to name * which file is stuck. */ export declare function getExistingEntryMirrorPaths(hippoRoot: string, id: string): string[]; /** * AT1 fix: best-effort markdown-mirror purge shared by `reject-flow.ts`'s * `rejectValue` and `resolveConflict`'s post-commit purge. Both used to log * "will retry via reaper on next open" for EVERY failure, but the reaper * (`cleanupArchivedMirrors`, raw-archive-mirror-cleanup.ts) only scans * `raw_archive` — that message was false for a non-raw id, which has no * reaper at all. * * Retries the unlink once synchronously (the common real-world failure is a * transient lock/AV-scanner false positive, not a permanent one). On a * second failure: raw ids still get the honest reaper message (true); non-raw * ids get the EXPLICIT leftover file path(s) and a manual-delete instruction, * since nothing will ever retry them automatically. * * Returns true if the mirror ended up purged (first or second attempt). */ export declare function purgeMirrorBestEffort(hippoRoot: string, id: string, isRaw: boolean, logPrefix: string): boolean; /** Named field set for the legacy `stats.json` mirror — the only fields any * caller reads (see loadLegacyStatsFile's callers below). */ interface LegacyStats { [key: string]: JsonValue; total_remembered: JsonValue; total_recalled: JsonValue; total_forgotten: JsonValue; consolidation_runs: JsonValue; } /** * Derive the current `HippoIndex` (entries + last-retrieval/trace lockstep * meta) from SQLite, the source of truth. Exported (AT1) for the same * reason as `writeIndexMirror` below: `src/reject-flow.ts` needs to rebuild * the index mirror post-commit after a (possibly multi-row) reject removal, * without duplicating this query. */ export declare function buildIndexFromDb(db: ReturnType): HippoIndex; /** * Write the `index.json` mirror file for a given (already-derived) index. * Exported (AT1) so `src/reject-flow.ts` can replicate `deleteEntry`'s exact * post-commit "removeEntryMirrors then rewrite the index once" sequence for * the reject verb's (possibly multi-row) removal, without duplicating * `buildIndexFromDb`'s query. */ export declare function writeIndexMirror(hippoRoot: string, index: HippoIndex): void; /** * Load the current derived index from SQLite and refresh the mirror file. */ export declare function loadIndex(hippoRoot: string): HippoIndex; /** * Persist mutable index metadata. Entry rows themselves are derived from SQLite. * * LC1 F1(c) structural fix: `last_retrieval_ids` and `last_trace_id` must * land atomically — callers (getContext, cmdRecall) fold a freshly-written * trace id into `index.last_trace_id` before calling this, relying on BOTH * meta keys committing together. Wrapped in BEGIN/COMMIT so a crash or a * mid-write failure can never advance one key without the other. The * filesystem mirror write stays AFTER commit — the DB is the source of * truth, the mirror is best-effort (matches every other per-call-handle * site's convention). */ export declare function saveIndex(hippoRoot: string, index: HippoIndex): void; /** * Write a memory entry to SQLite and refresh compatibility mirrors. * * `opts.actor` defaults to 'cli' so unauthenticated direct-CLI callers still * get the right audit attribution. The HTTP server (A1) and api.* layer pass * the resolved actor (`api_key:` / `localhost:cli`) so audit events * land with one row per write, no double-emit. * * `opts.afterWrite` is invoked inside the same SAVEPOINT as the memories * INSERT (mirrors archiveRawMemory's shape in raw-archive.ts). On callback * throw, the SAVEPOINT rolls back — the memory row never lands, and the * filesystem mirrors / audit emit never run. Used by E1.3+ connectors to * stamp idempotency rows atomically with the memory write. */ /** * Stamp origin_project from the store's own location when the entry has * never been stamped (v39 memory scope isolation). The store dir is * `/.hippo`, so its parent resolves to the owning project; the * home/global store resolves to '' (user-global). Callers that know a better * origin (shareMemory, syncGlobalToLocal) set entry.origin_project before * writing and this is a no-op. Returns a stamped copy; never mutates. * * NULL is deliberately PRESERVED, not re-stamped: null means "legacy row the * v39 migration found no evidence for" and is deny-by-default in ambient * context. A writeback (e.g. markRetrieved on a crossProject-included row) * must not launder it into an injectable origin - the migration is the only * evidence-based NULL converter (codex gating round 2 P1). */ export declare function stampOriginProject(hippoRoot: string, entry: MemoryEntry): MemoryEntry; export declare function writeEntry(hippoRoot: string, entry: MemoryEntry, opts?: { actor?: string; afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void; /** Runs AFTER the DB row commits (RELEASE SAVEPOINT in writeEntryDbOnly) but * BEFORE the markdown mirrors are written. Lets a caller perform a post-commit * side effect (e.g. mark the graph dirty) that must still happen even if a * mirror write then throws. Keep it best-effort — it runs on a committed, * idle connection, so opening another handle inside it is safe. */ afterCommit?: () => void; }): void; /** * DB-only write path. Caller owns the open `db` handle. Runs SAVEPOINT + * upsert + afterWrite hook + audit row inside the SAVEPOINT scope. Caller * is responsible for opening `db`, optionally wrapping in a larger BEGIN/ * COMMIT (e.g. supersede's BEGIN IMMEDIATE), closing `db`, AND calling * `writeEntryMirrors` after the larger tx commits — mirrors must run * post-commit so a rolled-back tx never leaves orphan markdown. * * Audit-order note: the audit row is emitted INSIDE the SAVEPOINT, so audit * commits atomically with the row INSERT. A subsequent mirror failure cannot * leave a recorded audit entry without its corresponding DB row. This is a * documented hardening over the prior writeEntry-as-monolith ordering. */ export declare function writeEntryDbOnly(db: DatabaseSyncLike, entry: MemoryEntry, opts?: { actor?: string; afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void; }): void; /** * Filesystem mirrors path. Caller passes `hippoRoot` + an open `db` handle * (used by `buildIndexFromDb` to derive the index from the source of truth). * MUST be invoked AFTER the outer transaction commits — a mirror write * during a tx that subsequently rolls back would leave orphan markdown. */ export declare function writeEntryMirrors(hippoRoot: string, db: DatabaseSyncLike, entry: MemoryEntry): void; /** * Read a memory entry by ID. * * When `tenantId` is provided, the read is scoped to that tenant (cross-tenant * lookups return null). When omitted, no tenant filter is applied — preserves * legacy single-tenant callers and the writeEntry/readEntry round-trip. */ export declare function readEntry(hippoRoot: string, id: string, tenantId?: string): MemoryEntry | null; /** * Batched lookup. Caps at 500 ids per call to keep the IN(?,?,...) clause * within SQLite limits. Tenant filter is enforced when `tenantId` is passed. * Used by DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 1.5) * to fetch parent summaries for a set of overflowed leaves. */ export declare function loadEntriesByIds(hippoRoot: string, ids: readonly string[], tenantId?: string): MemoryEntry[]; /** * All `kind='raw'` rows for a given session, tenant-scoped, returned * oldest-first. Used by `api.assemble` to walk a session's chronological * context. Excludes superseded rows. * * Cap semantics (v1.6.2 codex fix): when `cap` is provided, the NEWEST * `cap` rows are loaded — `ORDER BY created DESC LIMIT cap` server-side, * reversed to oldest-first client-side. Pre-v1.6.2 ordered ASC + LIMIT, * which silently dropped the newest rows and broke fresh-tail in assemble. * * Returns `[]` for an empty sessionId. Final order: `created ASC, id ASC`. */ export declare function loadSessionRawMemories(hippoRoot: string, sessionId: string, tenantId?: string, cap?: number): MemoryEntry[]; /** * Pre-cap, scope-aware row count for a session. Lets `assemble` report * the full session size even when `rowCap` truncates the loaded window, * WITHOUT leaking rows the caller wouldn't have been allowed to load. * * v1.6.3 codex P1 / senior P0: an earlier draft of this helper ran an * unscoped COUNT, which let a no-scope caller infer the existence of * private rows by comparing `totalRaw` against `items.length`. This * version SQL-encodes the same default-deny rule `passesScopeFilterForRecall` * applies in TS: * - explicit scope passed: exact-match * - no scope: rows where scope IS NULL, or scope is NOT a `:private:*` * pattern AND not the `unknown:legacy` quarantine bucket. * * `tenantId` is optional for back-compat. Pass `undefined` only when * intentionally counting cross-tenant; `assemble()` passes `ctx.tenantId`. */ export declare function countSessionRawMemories(hippoRoot: string, sessionId: string, tenantId?: string, scope?: string): number; /** * Last N kind='raw' memories by `created` desc. Tenant scoped. When * `sessionId` is supplied, also constrains to a specific session — that * is the correct shape for "what did I just see in THIS session." * * v1.6.2 codex review fix: pre-v1.6.2 was tenant-wide only. With multiple * concurrent sessions in a tenant, fresh-tail recall surfaced unrelated * rows from other sessions and stamped them `isFreshTail=true`. Callers * that want session-scoped fresh-tail now pass `sessionId`. The * tenant-wide form (no sessionId) still exists for "anything new across * the whole tenant" — pass undefined to opt in. * * Bounded count cap at 200 — beyond that the caller should filter via * tags/scope rather than time-windowed recall. * * Deprecation note (v1.6.5) — the **tenant-wide call shape** (omitting * `sessionId`) is rarely the right shape for "what did I just see in this * conversation". `api.recall` enforces session scoping when * `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set, throwing * `RecallContractError` instead. Tenant-wide remains the back-compat default * but is discouraged for new callers. Passing `sessionId` is fully supported * and recommended; this function is NOT deprecated as a whole. */ export declare function loadFreshRawMemories(hippoRoot: string, count: number, tenantId?: string, sessionId?: string): MemoryEntry[]; /** * Direct DAG children of a parent summary. Tenant scoped. Returns only rows * whose `dag_parent_id` matches `parentId`; does NOT walk recursively. * Used by `drillDown` (Task 3). */ export declare function loadChildrenOf(hippoRoot: string, parentId: string, tenantId?: string): MemoryEntry[]; /** * AT1 (plan §4, round-2 fix, designed from source): db-scoped delete core. * `deleteEntry` used to open+close its OWN connection, which meant it could * never compose inside a caller's transaction (unlike writeEntry/ * writeEntryDbOnly, which already split this way). Split identically: row- * meta SELECT, `DELETE FROM memories`, FTS delete, `forget` audit, DAG * dirty-mark. NO filesystem I/O — the caller's own transaction may still be * rolled back, and mirror writes must only happen post-commit. * * `opts.suppressForgetAudit` (default false, off): two AT1 callers set this * so a removed non-raw row does NOT ALSO emit a `forget` row, because each * already writes its own aggregate audit trail — `src/reject-flow.ts`'s * `rejectValue` (single `reject_value` row covering every same-digest row * removed) and `resolveConflict` (`conflict_resolve` row per resolution). * Default keeps `deleteEntry` byte-identical to its pre-split behavior. * * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row * with `id` existed. */ export declare function deleteEntryCore(db: ReturnType, id: string, opts?: { actor?: string; suppressForgetAudit?: boolean; }): { tenantId: string; dagParentId: string | null; } | null; /** * Delete an entry from SQLite and mirrors. * * `opts.actor` defaults to 'cli'. The api.* layer threads `ctx.actor` so HTTP * callers land with `api_key:` in the audit log without a duplicate * emit from the api wrapper. * * Thin wrapper over `deleteEntryCore` (open → core → mirrors → close); * behavior is byte-identical to the pre-split implementation for every * existing caller. */ export declare function deleteEntry(hippoRoot: string, id: string, opts?: { actor?: string; }): boolean; /** * Batch-write and batch-delete entries in a single transaction. * Used by consolidation to avoid N open/close cycles. */ export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEntry[], toDeleteIds: string[]): void; /** * Load all entries from SQLite. * * When `tenantId` is provided, results are scoped to that tenant. Omitting it * yields all rows (legacy behavior used by consolidate/autolearn etc.). Recall * paths that surface results to a user MUST pass a resolved tenant. */ export declare function loadAllEntries(hippoRoot: string, tenantId?: string): MemoryEntry[]; export declare function loadAmbientCandidates(hippoRoot: string, tenantId: string, recentNeeded: number, admit: (e: MemoryEntry) => boolean): MemoryEntry[]; /** * Load likely search candidates directly from SQLite. * Uses FTS5 when available, falls back to LIKE matching, then full-store fallback. * * When `tenantId` is provided, every SELECT (FTS join, LIKE, fallback) filters * by tenant_id. Cross-tenant memories never surface. Omitted = no filter. */ export declare function loadSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string): MemoryEntry[]; /** * v1.7.1 — recall-mode loader. Pushes the recall-side scope predicate into * SQL so `unknown:legacy` cannot leak via any consumer that hasn't remembered * to re-filter (root-cause-over-patches: codex flagged this on v1.6.5 review). * * - `requestedScope` undefined / '': default-deny on `unknown:legacy`. * - `requestedScope` non-empty string: exact match on `m.scope = requestedScope`. * * Private-scope (`:private:*`) exclusion: SQL applies a conservative * pre-window approximation (`NOT LIKE '%:private:%'`, v1.25.0 — codex P2: * post-window-only filtering let private rows starve admitted candidates out * of the LIMIT window); the exact anchored regex * (`passesScopeFilterForRecall`) remains the authoritative JS post-filter in * the recall consumers. * * Consumers: `api.recall` (v1.7.1+), `cmdRecall`/`cmdExplain` direct CLI paths * and `searchBothHybrid` recall mode (v1.25.0). Background pipelines * (`consolidate`, `embeddings`, `refine-llm`, ...) keep using * `loadSearchEntries` so they can see quarantined rows when needed. * * `tenantId` widened to optional in v1.25.0 for the searchBothHybrid recall * mode (its `tenantId` option is optional); `loadSearchRows` already treats * undefined as "no tenant filter" for legacy callers. */ export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive'): MemoryEntry[]; /** * Rebuild mirrors from SQLite, importing any legacy markdown files not already present. */ export declare function rebuildIndex(hippoRoot: string): HippoIndex; export declare function updateStats(hippoRoot: string, delta: { remembered?: number; recalled?: number; forgotten?: number; }): void; export declare function loadStats(hippoRoot: string): LegacyStats; export declare function appendConsolidationRun(hippoRoot: string, run: { timestamp: string; decayed: number; merged: number; removed: number; }): void; /** * Session decay context: provides the data needed for session-based and adaptive decay. */ export interface SessionDecayContext { /** Total number of sleep (consolidation) cycles completed. */ sleepCount: number; /** Average interval between recent sleep cycles, in days. 0 if < 2 cycles. */ avgSessionIntervalDays: number; } /** * Load the session decay context from the store. * Uses consolidation_runs timestamps to compute session intervals. */ export declare function loadSessionDecayContext(hippoRoot: string): SessionDecayContext; /** * Increment the sleep counter. Called after each consolidation run. */ export declare function incrementSleepCount(hippoRoot: string): void; /** * Defensive runtime guard for tenant id arguments. * * The continuity helpers (saveActiveTaskSnapshot, listSessionEvents, etc.) * gained a required `tenantId` parameter in v0.41 / schema v22 to close a * cross-tenant data leak. TypeScript catches misbinding at compile time, but * JavaScript callers from older versions can silently pass a `sessionId` * where `tenantId` is now expected, e.g. * loadLatestHandoff(root, 'sess-abc') // WRONG: 'sess-abc' becomes the tenant * which would silently filter to a non-existent tenant and return null with * no error. This guard rejects the most common shape of that mistake (any * value beginning with the conventional `sess-` / `sess_` session prefix). * * False-positive cost: a tenant literally named `sess-...` will be rejected. * Acceptable tradeoff for catching the silent-leak class. */ export declare function assertTenantId(fnName: string, value: JsonValue): asserts value is string; export declare function saveActiveTaskSnapshot(hippoRoot: string, tenantId: string, snapshot: { task: string; summary: string; next_step: string; source?: string; session_id?: string | null; scope?: string | null; }): TaskSnapshot; export declare function loadActiveTaskSnapshot(hippoRoot: string, tenantId: string): TaskSnapshot | null; /** * Default freshness bound for AMBIENT active-task-snapshot reads (DF1, * docs/plans/2026-08-23-df1-snapshot-lifecycle.md): 72h, chosen over 48h so * a Friday-evening orphan still offers continuity on Monday morning. * Exported so callers can override via `loadFreshActiveTaskSnapshot`'s * `opts.maxAgeMs`; deliberately no env knob (Simplicity First). */ export declare const SNAPSHOT_AMBIENT_MAX_AGE_MS: number; /** * Bounded read for AMBIENT active-task-snapshot surfaces (UserPromptSubmit * hook context, MCP recall block) — the never-expires fix for DF1. A * snapshot written by `hippo pre-compact` has no death path tied to the * session that owns it, so an orphaned row would otherwise inject into * every prompt of every later session forever. Wraps `loadActiveTaskSnapshot` * (unchanged, still the source of truth for explicit continuity surfaces), * then applies, in order: * * 1. Owner match — ONLY when both `opts.sessionId` and the snapshot's * `session_id` are non-null, non-empty strings and strictly equal * (`===`). Owner reads are unbounded: the session that owns the snapshot * can always see its own working state, regardless of age. * 2. Age check — everything else, including absent-vs-absent ids. A * null/undefined/empty id on EITHER side never counts as an owner match; * it falls through here instead. (`runPreCompact` can legitimately save a * snapshot with `session_id = null`; a null-equals-null "match" would * reopen indefinite ambient injection for exactly those rows.) Returns * the snapshot only when `age(updated_at) <= maxAgeMs` (default * `SNAPSHOT_AMBIENT_MAX_AGE_MS`); otherwise null. * * No SQL change — age derives from the existing `updated_at` column. */ export declare function loadFreshActiveTaskSnapshot(hippoRoot: string, tenantId: string, opts?: { maxAgeMs?: number; sessionId?: string | null; }): TaskSnapshot | null; export declare function clearActiveTaskSnapshot(hippoRoot: string, tenantId: string, clearedStatus?: string): boolean; /** * Close the `active` task snapshot(s) owned by `sessionId`, for the T3 * session-end death path (DF1, docs/plans/2026-08-23-df1-snapshot-lifecycle.md). * Only one `active` row exists per tenant in practice (supersession happens * at save), but the WHERE clause scopes on `session_id` too — not just * `status='active' AND tenant_id=?` — so an ending session can never close a * different, newer session's active snapshot. Returns the number of rows * closed (0 when no active row is owned by `sessionId`). */ export declare function closeTaskSnapshotsForSession(hippoRoot: string, tenantId: string, sessionId: string, status?: string): number; export declare function appendSessionEvent(hippoRoot: string, tenantId: string, event: { session_id: string; event_type: string; content: string; task?: string | null; source?: string; scope?: string | null; metadata?: Record; }): SessionEvent; export declare function listSessionEvents(hippoRoot: string, tenantId: string, options?: { session_id?: string; task?: string; limit?: number; }): SessionEvent[]; /** * Return session_ids with a `session_complete` event newer than `sinceMs`. * Used by the sleep auto-promotion pass to bound scanning to a fixed window. */ export declare function findPromotableSessions(hippoRoot: string, tenantId: string, sinceMs: number): Array<{ session_id: string; }>; /** * Idempotency guard — true if a trace-layer memory with this source_session_id * already exists. */ export declare function traceExistsForSession(hippoRoot: string, tenantId: string, session_id: string): boolean; export declare function listMemoryConflicts(hippoRoot: string, status?: string, tenantId?: string): MemoryConflict[]; export declare function replaceDetectedConflicts(hippoRoot: string, detected: Array<{ memory_a_id: string; memory_b_id: string; reason: string; score: number; }>, detectedAt?: string): void; /** * AT1 (plan §5): additive-optional opts for resolveConflict. * `rejectLoserValue` implies removal of the loser regardless of * `forgetLoser` — you cannot tombstone a value and leave it live. */ export interface ResolveConflictOpts { /** Tombstone the loser's normalized digest + kind-aware remove it. */ rejectLoserValue?: boolean; /** Actor for the tombstone + the new conflict_resolve audit row. Defaults to 'cli'. */ rejectedBy?: string; /** Reason recorded on the tombstone (and passed to archiveRawMemory if the * loser is kind='raw'). Defaults to a conflict-context string. */ reason?: string; } /** * Resolve a conflict by keeping one memory and weakening the other. * Sets conflict status to 'resolved' and halves the loser's half-life. * If --forget is used, the loser is removed entirely (kind-aware: raw rows * are archived via archiveRawMemory, others deleted via deleteEntryCore — * AT1 fix for the pre-existing crash where a raw loser aborted the whole * resolve transaction against the append-only trigger). `opts.rejectLoserValue` * additionally tombstones the loser's normalized digest so it cannot be * re-asserted later. * * Every resolution path (weaken / forget / reject) emits a `conflict_resolve` * audit row (AT1 — previously resolveConflict wrote zero audit rows on any path). * * Returns the resolved conflict, or null if not found. */ export declare function resolveConflict(hippoRoot: string, conflictId: number, keepId: string, forgetLoser?: boolean, tenantId?: string, opts?: ResolveConflictOpts): { conflict: MemoryConflict; loserId: string; } | null; /** * Save a session handoff record. Returns the persisted handoff. */ export declare function saveSessionHandoff(hippoRoot: string, tenantId: string, handoff: Omit): SessionHandoff; /** Load the most recent handoff, optionally filtered by session ID. */ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, sessionId?: string, opts?: { unfinishedOnly?: boolean; maxAgeMs?: number; scopeFilter?: 'default-deny'; }): SessionHandoff | null; /** * Load a specific handoff by its row ID. */ export declare function loadHandoffById(hippoRoot: string, tenantId: string, id: number): SessionHandoff | null; /** Stamp the outcome on a session's newest handoff, only if it has none yet. Returns rows changed. */ export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number; /** * Auto-write a handoff at session-end from the session's active snapshot (DF1 T3). * @param evidence best-effort git state; outcome comes from the newest session_complete event. * @returns null unless the snapshot belongs to sessionId and no newer handoff already covers it. */ export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null): SessionHandoff | null; export declare function transitionCard(db: DatabaseSyncLike, tenantId: string, cardId: string, from: CardStatus[], to: CardStatus, extra?: { setSql?: string; whereSql?: string; params?: unknown[]; }): number; /** Creates a card; status is ready with no deps or once every dependsOn id is done, else backlog. An unknown dependsOn id throws and commits nothing. A repeated dependsOn id is recorded once. */ export declare function createCard(hippoRoot: string, tenantId: string, input: { title: string; repo?: string; contract?: string; budget?: number; dependsOn?: string[]; }): Card; /** Returns the card row for id, or null if it does not exist under this tenant. */ export declare function loadCard(hippoRoot: string, tenantId: string, id: string): Card | null; /** Lists cards for this tenant, optionally filtered to one status, newest-updated first. */ export declare function listCards(hippoRoot: string, tenantId: string, opts?: { status?: CardStatus; }): Card[]; /** Returns this card's parent and child ids from card_deps. */ export declare function loadCardDeps(hippoRoot: string, tenantId: string, id: string): { parents: string[]; children: string[]; }; /** Returns this card's run history, most recent first. */ export declare function loadCardRuns(hippoRoot: string, tenantId: string, id: string): CardRun[]; /** Returns this card's comments, most recent first. */ export declare function loadCardComments(hippoRoot: string, tenantId: string, id: string): CardComment[]; /** Read side of the card <-> handoff round trip: the newest handoff filed against this card. */ export declare function loadLatestHandoffForCard(hippoRoot: string, tenantId: string, cardId: string): SessionHandoff | null; /** Atomic claim: WHERE status IN (ready, blocked) AND assignee_runtime IS NULL decides the race. Throws on an unknown card id; returns null for a card not ready/blocked or already claimed. Sets a CARD_LEASE_MS lease and returns the new run's id as runId. */ export declare function claimCard(hippoRoot: string, tenantId: string, id: string, runtime: string, sessionId?: string): (Card & { runId: number; }) | null; /** Moves a running card's lease to CARD_LEASE_MS from now and records the heartbeat; updated_at is left alone. Throws on an unknown card id or a run id that is not a positive integer; returns null unless the card is running and runId is its live run. */ export declare function heartbeatCard(hippoRoot: string, tenantId: string, id: string, runId: number): Card | null; /** Requires the card be running; closes the live run as blocked and files reason as a comment. Throws on an unknown card id; returns null for a card not running. When runId is given, returns null unless it is the card's live run. */ export declare function blockCard(hippoRoot: string, tenantId: string, id: string, reason: string, runId?: number): Card | null; /** Requires the card be running; moves it to review, clearing its lease and heartbeat and keeping its live run. When runId is given, returns null unless it is the card's live run. Throws on an unknown card id; returns null for a card not running. */ export declare function reviewCard(hippoRoot: string, tenantId: string, id: string, runId?: number): Card | null; /** Requires the card be in review; closes the live run with outcome. Outcome 'success' moves the card to done and, in the same transaction, promotes any child whose parents are now all done; 'failure' or 'partial' moves it to shelved and promotes nothing. Throws on an unknown card id; returns null for a card not in review. When runId is given, returns null unless it is the card's live run. */ export declare function completeCard(hippoRoot: string, tenantId: string, id: string, outcome: HandoffOutcome, runId?: number): { card: Card; promotedChildren: string[]; } | null; /** Returns to ready every running card of the tenant whose lease has expired or is missing: clears its assignee, closes its live run as 'reclaimed' and leaves its handoffs alone, all in one write transaction. Returns the reclaimed card ids in id order. */ export declare function reclaimExpiredCards(hippoRoot: string, tenantId: string): string[]; /** Appends a comment to cardId in any card status; throws if cardId is not a card of this tenant. */ export declare function addCardComment(hippoRoot: string, tenantId: string, cardId: string, author: string, body: string): CardComment; /** * Load summaries flagged dirty for the given tenant. Sorted by latest_at * DESC (NULLS LAST) so E3's rebuild cap (HIPPO_DAG_REBUILD_CAP, default 20) * takes the most-recently-changed summaries first. * * Returns full MemoryEntry shape via MEMORY_SELECT_COLUMNS + rowToEntry * (v28 fields are part of the standard read path). */ export declare function loadDirtySummaries(hippoRoot: string, tenantId: string): MemoryEntry[]; /** * v0.30 / E2 — in-transaction variant of markSummaryDirty. Takes an open * db (caller is responsible for any SAVEPOINT/BEGIN). Used by E2's hook * sites: writeEntryDbOnly, api.supersede CAS, deleteEntry, archiveRawMemory, * batchWriteAndDelete. Each child mutation's dirty-mark is atomic with the * mutation itself (where the mutation IS in a SAVEPOINT/BEGIN — deleteEntry * is the exception, acceptably non-atomic by design). * * EXPORTED (required for cross-module use by api.ts + raw-archive.ts). * Risk of misuse (caller without open tx) is mitigated by the InTx * suffix + the DatabaseSyncLike typed param. Public surface for end users * stays at the markSummaryDirty (own-connection) variant. * * Same idempotency contract: 0->1 transition only, audit row only on * transition, no-op on non-summary / archived / unknown id / cross-tenant. */ export declare function markSummaryDirtyInTx(db: DatabaseSyncLike, summaryId: string, tenantId: string, actor: string): void; /** * Mark a summary as dirty. Idempotent (re-marking dirty is a no-op + no * second audit row). Tenant-scoped to prevent cross-tenant writes via * parent-lookup. Called by E2 from invalidation.ts / writeEntry / * forgetMemory / archiveRawMemory whenever a child is invalidated, * superseded, forgotten, or archived. * * Quietly no-ops if the target row doesn't exist or isn't a level-2 * summary (E5 will widen the dag_level guard to IN (2, 3) when level-3 * build path lands). Emits a 'summary_marked_dirty' audit row on actual * state transitions (0 -> 1) via the audit() helper, which try/catches * for missing audit_log (the v27 self-heal scenario). */ export declare function markSummaryDirty(hippoRoot: string, summaryId: string, tenantId: string, actor?: string): void; /** * v0.30 / E5 — host-wide loader for L2 topic summaries without an L3 parent. * Used by consolidate phase 1.9 (buildEntityProfiles) to cluster L2s into * L3 entity profiles. Mirrors loadAllDirtySummaries pattern (E3): SQL-level * filter is cheaper than reusing in-memory `survivors` (which doesn't * contain L2s freshly created by phase 1.7 buildDag). * * Returns entries with tenantId attached so per-cluster writes stay * tenant-scoped via summary.tenantId. */ export declare function loadAllL2Summaries(hippoRoot: string): MemoryEntry[]; /** * v0.30 / E3 — host-wide variant of loadDirtySummaries. Iterates all tenants * in one query so consolidate.ts (host-wide per L106-109) does not need a * per-tenant loop. Each returned MemoryEntry carries its own tenantId (via * rowToEntry), so per-summary children + rebuild UPDATE stay tenant-scoped. * * Sort: latest_at DESC NULLS LAST, id ASC — same as per-tenant variant so * HIPPO_DAG_REBUILD_CAP takes most-recently-changed summaries first. */ export declare function loadAllDirtySummaries(hippoRoot: string): MemoryEntry[]; /** * v0.30 / E3 — load live children of a DAG summary. Used by * rebuildDirtySummaries to regenerate content from the CURRENT child set * (not the children at create-time). Skips archived. Tenant-scoped * (defence in depth — dag_parent_id is unique-ish but tenant guard is * cheap). created column is TEXT NOT NULL since db.ts schema v1. */ export declare function loadChildrenOfSummary(hippoRoot: string, summaryId: string, tenantId: string): MemoryEntry[]; /** * v0.30 / E3 — patch applied by applyRebuildResult. Two-branch shape * (bumpRebuildCount false for zero-child case, true for normal rebuild). */ export interface RebuildPatch { content: string; descendant_count: number; earliest_at: string | null; latest_at: string | null; bumpRebuildCount: boolean; zeroChildren: boolean; actor: string; } /** * v0.30 / E3 — apply a rebuild result to a dirty summary. Atomic: one * prepared UPDATE statement plus syncFtsRow inside one SAVEPOINT. * WHERE includes `AND summary_dirty = 1` so concurrent sleep's race-loser * becomes a no-op (no rebuild_count bump, no audit row). * * Returns `{ changed, refused }`. `changed` is true when this call's UPDATE * (content or metadata-only) affected a row; false on race-loss / unknown id * / archived / wrong dag_level. `refused` is true only when a tombstone hit * suppressed the content write AND the metadata UPDATE still landed — see * the return-semantics comment below for the full contract. */ export declare function applyRebuildResult(hippoRoot: string, summary: MemoryEntry, patch: RebuildPatch): { changed: boolean; refused: boolean; }; /** * v0.30 / E3 — clear summary_dirty on a freshly-built summary. Called by * buildDag immediately after the child-link loop finishes. Without this, * each member's writeEntry call fires markSummaryDirtyInTx on the just- * created parent (E2 hook at store.ts:1214), and the same sleep cycle's * E3 rebuild phase would re-rebuild every new summary (2x LLM cost). * * Idempotent: no-op + no audit if summary isn't dirty. Audit * source='buildDag-clean' distinguishes from E3-rebuild source. */ export declare function clearSummaryDirtyAfterBuild(hippoRoot: string, summaryId: string, tenantId: string, actor?: string, source?: string): void; export { getHippoDbPath }; //# sourceMappingURL=store.d.ts.map