import type Database from 'better-sqlite3'; import { type MemoryItemState } from '../memory/items.js'; import { type ExtractedMemoryReference } from '../memory/references.js'; /** * A note subject as the `notes` table stores it: trimmed, lower-cased, and * `null` when nothing is left. * * Exported because `insertNote` is no longer the only caller — the Story 5.3 * memory guard has to ask about a subject before the write happens, and * `deferred-work.md` already records one incident of exactly this normalisation * drifting between two subject lookups. */ export declare function normalizeNoteSubject(subject: string | null | undefined): string | null; /** What a note write would do to existing notes, without doing it. */ export interface NoteWritePreview { /** Ids this write would flip to `superseded`. */ supersededIds: string[]; /** Ids this write would mark as contested. */ conflictIds: string[]; } export interface NoteRow { id: string; session_id: string; timestamp: string; kind: string; subject: string | null; content: string; alternatives: string | null; status: string; conflict: number; } export interface StateRow { id: string; session_id: string | null; layer: string; content: string; created_at: string; } export interface LedgerRow { id: string; session_id: string; type: string; direction: string; tokens: number; timestamp: string; } export interface SessionRow { id: string; parent_session_id: string | null; started_at: string; ended_at: string | null; focus: string | null; agent_type: string; agent_id: string | null; status: string; git_root: string | null; worktree_path: string | null; branch_ref: string | null; head_oid: string | null; scope_type: string; scope_key: string | null; } export interface EventRow { id: string; session_id: string; timestamp: string; type: string; target: string | null; metadata_json: string | null; } export interface ParsedEvent { id: string; session_id: string; timestamp: string; type: string; target: string | null; metadata: Record; } export interface BranchSnapshotRow { id: string; scope_key: string; git_root: string | null; worktree_path: string | null; branch_ref: string | null; head_oid: string | null; focus: string | null; summary: string; recent_files: string[]; intents: string[]; blockers: string[]; last_session_id: string | null; updated_at: string; } export interface CommandRunRow { id: string; session_id: string; event_id: string | null; timestamp: string; category: string | null; command_summary: string | null; exit_code: number | null; stdout_tail: string | null; stderr_tail: string | null; files_touched_json: string | null; } export interface ParsedCommandRun { id: string; session_id: string; event_id: string | null; timestamp: string; category: string | null; command_summary: string | null; exit_code: number | null; stdout_tail: string | null; stderr_tail: string | null; files_touched: string[]; } export interface EpisodeRow { id: string; session_id: string | null; kind: string; summary: string; target: string | null; metadata_json: string | null; source_state_id: string | null; created_at: string; } export interface ParsedEpisode { id: string; session_id: string | null; kind: string; summary: string; target: string | null; metadata: Record; source_state_id: string | null; created_at: string; } export interface ProjectSnapshotRow { id: string; git_root: string | null; scope_key: string; summary: string; note_digest: string | null; updated_at: string; } export interface MemoryItemRow { id: string; session_id: string | null; scope_type: string; scope_key: string; kind: string; source_table: string | null; source_id: string | null; subject: string | null; text: string; state: string; importance: number; access_count: number; last_accessed_at: string | null; created_at: string; } export interface MemoryItemSemanticRow { memory_item_id: string; summary: string; concepts_json: string; entities_json: string; embedding_model: string; embedding_json: string; source_hash: string; updated_at: string; } export interface ContentDigestRow { scope_key: string; path: string; sha256: string | null; byte_size: number; mtime: string | null; session_id: string; agent_id: string | null; oversize: number; read_count: number; recorded_at: string; refund_eligible: number; } export interface ParsedContentDigest { scopeKey: string; path: string; sha256: string | null; byteSize: number; mtime: string | null; sessionId: string; agentId: string | null; oversize: boolean; readCount: number; recordedAt: string; /** * Whether this digest provably describes the bytes the recorded read * RETURNED (Story 4.5 review round). False when the read was followed in its * flush batch by an edit of the same path or by any command — either can * rewrite the file before the flush hashes it, in which case the digest * describes bytes the reader never saw. A refund (substitution or a read * offer) may only be made from an eligible record; change detection uses the * digest regardless. */ refundEligible: boolean; } export interface NegativeResultRow { scope_key: string; query_key: string; tool: string; pattern: string; root: string; params_json: string | null; head_oid: string | null; census_sha256: string; census_files: number; census_bytes: number; recorded_at: string; } /** * FR-12's negative cache (Story 4.3). `censusSha256` is the assertion's entire * evidence (AD-6): the search root's working-tree fingerprint at flush time, * re-derived and compared at query. `headOid` is verdict metadata — rendered * in `no-matches-at `, never compared. `pattern` is stored redacted; * `queryKey` hashes the raw pattern, so distinct secret-bearing searches stay * distinct without the secret persisting. */ export interface ParsedNegativeResult { scopeKey: string; queryKey: string; tool: string; pattern: string; root: string; paramsJson: string | null; headOid: string | null; censusSha256: string; censusFiles: number; censusBytes: number; recordedAt: string; } export interface UpsertNegativeResultOpts { scopeKey: string; queryKey: string; tool: string; pattern: string; root: string; paramsJson?: string | null; headOid?: string | null; censusSha256: string; censusFiles: number; censusBytes: number; recordedAt?: string; } export interface SubagentDispatchRow { id: string; scope_key: string; host_session_id: string; prompt_id: string; agent_type: string; tool_use_id: string | null; description: string; prompt_digest: string | null; prompt_prefix: string | null; prompt_chars: number; captured_at: string; consumed_at: string | null; consumed_by_agent_id: string | null; } /** * A subagent dispatch seen at `PreToolUse` on the `Agent` tool (FR-18, Story * 5.2), waiting to be consumed by the matching `SubagentStart`. * * `hostSessionId` and `promptId` are the HOST's identifiers, not Cortex session * ids — see the table's own docstring in `schema.ts` for why the names differ. */ export interface ParsedSubagentDispatch { id: string; scopeKey: string; hostSessionId: string; promptId: string; agentType: string; toolUseId: string | null; description: string; promptDigest: string | null; promptPrefix: string | null; promptChars: number; capturedAt: string; consumedAt: string | null; consumedByAgentId: string | null; } export interface InsertSubagentDispatchOpts { scopeKey: string; hostSessionId: string; promptId: string; agentType: string; toolUseId?: string | null; description: string; promptDigest?: string | null; promptPrefix?: string | null; promptChars?: number; capturedAt?: string; } /** The pairing key (Story 5.2). Every part earns its place — see `schema.ts`. */ export interface SubagentDispatchKey { hostSessionId: string; promptId: string; agentType: string; } export declare function parseContentDigestRow(row: ContentDigestRow): ParsedContentDigest; export interface UpsertContentDigestOpts { scopeKey: string; path: string; sha256: string | null; byteSize: number; mtime?: string | null; sessionId: string; agentId?: string | null; oversize?: boolean; recordedAt?: string; /** * The reading session's parent, when it has one. Used to protect an * ancestor's recorded read from being erased by its own descendant — see * `upsertContentDigest`. */ readerParentSessionId?: string | null; /** * See `ParsedContentDigest.refundEligible`. Defaults to TRUE, which is * correct for every direct caller (CLI `log read`, `hook-entry post`): those * compute the digest at event time, so nothing can have rewritten the file * between the read and the hash. The spool flush — the one caller whose * digests are computed a whole turn after the reads they describe — passes * the value its batch pre-pass computed. */ refundEligible?: boolean; /** * The scope's worktree root. Paths under it are stored relative to it — the * repo prefix is redundant with `scope_key`, and carrying it twice breached * Story 3.1's 400 byte/file ceiling. Omit only when genuinely unknown. */ scopeRoot?: string | null; } export interface CurrentAppGraphRow { scope_key: string; scope_type: string; git_root: string | null; worktree_path: string | null; branch_ref: string | null; head_oid: string | null; files_json: string; file_count: number; updated_at: string; } export interface ParsedCurrentAppGraph { scope_key: string; scope_type: string; git_root: string | null; worktree_path: string | null; branch_ref: string | null; head_oid: string | null; files: string[]; file_count: number; updated_at: string; } export type MemoryReferenceStatus = 'exists' | 'missing' | 'moved' | 'unknown' | 'external'; export interface MemoryReferenceRow { id: string; memory_item_id: string; reference_type: string; raw_reference: string; normalized_path: string; status: MemoryReferenceStatus; checked_at: string | null; moved_to: string | null; } export interface ParsedMemoryReference { id: string; memory_item_id: string; reference_type: string; raw_reference: string; normalized_path: string; status: MemoryReferenceStatus; checked_at: string | null; moved_to: string | null; } export interface FileRenameRow { id: string; scope_key: string; old_path: string; new_path: string; head_oid: string | null; detected_at: string; } export interface ParsedMemoryItem { id: string; session_id: string | null; scope_type: string; scope_key: string; kind: string; source_table: string | null; source_id: string | null; subject: string | null; text: string; state: MemoryItemState; importance: number; access_count: number; last_accessed_at: string | null; created_at: string; } export interface SearchMemoryItemResult extends ParsedMemoryItem { fts_rank: number; } export interface ParsedMemoryItemSemantic { memory_item_id: string; summary: string; concepts: string[]; entities: string[]; embedding_model: string; embedding: number[]; source_hash: string; updated_at: string; } export interface SemanticMemoryItemResult extends ParsedMemoryItem { semantic_score: number; semantic: ParsedMemoryItemSemantic; } export interface RetrievalLogRow { id: string; session_id: string | null; topic: string; query_text: string | null; result_ids_json: string | null; total_candidates: number; returned_count: number; token_estimate: number; created_at: string; } export interface ParsedRetrievalLog { id: string; session_id: string | null; topic: string; query_text: string | null; result_ids: string[]; total_candidates: number; returned_count: number; token_estimate: number; created_at: string; } export interface CreateSessionOpts { parentSessionId?: string; agentType?: string; /** Host-provided subagent id; absent for a primary session (AD-9). */ agentId?: string; focus?: string; gitRoot?: string; worktreePath?: string; branchRef?: string; headOid?: string; scopeType?: string; scopeKey?: string; } export interface InsertEventOpts { sessionId: string; type: string; target?: string; metadata?: Record; } export interface InsertNoteOpts { sessionId: string; kind: 'insight' | 'decision' | 'intent' | 'blocker' | 'focus'; content: string; subject?: string; alternatives?: string[]; /** * Skip FR-1 contradiction detection for this write. Only for explicit user * resolution (`cortex_resolve` with a replacement), where the note being * replaced is still active and would otherwise contest its own replacement. */ skipConflictDetection?: boolean; } export interface ParsedNote { id: string; session_id: string; timestamp: string; kind: string; subject: string | null; content: string; alternatives: string[] | null; status: string; conflict: boolean; } /** A prior note the incoming write was found to contradict (FR-1). */ export interface NoteConflict { id: string; subject: string; timestamp: string; content: string; /** Which detector fired — see `src/memory/conflict.ts`. */ signal: 'negation' | 'antonym'; } /** * `insertNote`'s return. Widened rather than replaced so every existing caller * that expects a `ParsedNote` keeps compiling. `conflicts` is present only when * the write actually contradicted something. */ export type InsertedNote = ParsedNote & { conflicts?: NoteConflict[]; }; export interface InsertStateOpts { sessionId?: string; layer: 'session' | 'project'; content: string; } /** * What a ledger row records (FR-8). * * `injected` is what Cortex put into the context; it was stored as `'spent'` * until Story 3.5 and is migrated. `saved` is credit for an action that did not * happen, and may only be written with evidence. `unrealized` is an offer the * agent declined — capability that existed and was not taken — which AC #6 * requires be visible *separately* from savings rather than folded into them. * `estimated` is the retired, evidence-free consolidation credit: kept for * history, never counted as a saving. */ export type LedgerDirection = 'injected' | 'saved' | 'unrealized' | 'estimated'; /** * The evidence a `saved` or `unrealized` row must carry (AC #2). * * `size` is bytes for an avoided read, output size for an avoided command, and * result count for an avoided search — the three shapes the AC names. `ref` * identifies the thing avoided (a path, a command, a query). */ export interface LedgerEvidence { kind: 'read' | 'command' | 'search'; ref: string; size: number; } export interface InsertLedgerOpts { sessionId: string; type: string; direction: LedgerDirection; tokens: number; evidence?: LedgerEvidence; /** * A caller-supplied stable id, so replaying the same fact is a no-op instead * of a second row. Used by the spool's credit replay; omitted elsewhere, in * which case a UUID is minted. */ id?: string; } export interface LedgerTypeTotals { spent: number; saved: number; unrealized: number; estimated: number; } /** * Ledger sums keyed by the stored direction values (FR-9). * * `injected` here is the same quantity `getTotalTokens()` reports as `spent` — * that older field name is kept so existing readers compile (Story 3.5); new * FR-9 surfaces use the stored vocabulary and this comment states the mapping * once. */ export interface LedgerDirectionTotals { injected: number; saved: number; unrealized: number; estimated: number; } /** One session's ledger sum for one direction; feeds the FR-9 session block. */ export interface SessionLedgerTotalRow { session_id: string; direction: string; tokens: number; } export declare const LEDGER_DIRECTIONS: ReadonlySet; /** * The one direction fold (FR-9). Both aggregate readers and the stats layer * fold `{direction, tokens}` rows through this so a fifth direction added * later has exactly one place to be missed — and a miss is at least a shared * miss, not two folds drifting independently. Unknown directions cannot enter * through `insertLedgerEntry` (it throws), only through a raw INSERT — the * documented `cortex gc` bypass — and they fold to nothing here, an * undercount, the PM-preferred direction. */ export declare function foldLedgerDirectionTotals(rows: Array<{ direction: string; tokens: number; }>): LedgerDirectionTotals; export interface InsertCommandRunOpts { id?: string; sessionId: string; eventId?: string | null; timestamp?: string; category?: string | null; commandSummary?: string | null; exitCode?: number | null; stdoutTail?: string | null; stderrTail?: string | null; filesTouched?: string[]; } export interface InsertEpisodeOpts { id?: string; sessionId?: string | null; kind: string; summary: string; target?: string | null; metadata?: Record; sourceStateId?: string | null; createdAt?: string; } export interface UpsertBranchSnapshotOpts { scopeKey: string; gitRoot?: string | null; worktreePath?: string | null; branchRef?: string | null; headOid?: string | null; focus?: string | null; summary: string; recentFiles?: string[]; intents?: string[]; blockers?: string[]; lastSessionId?: string | null; updatedAt?: string; } export interface UpsertProjectSnapshotOpts { id?: string; gitRoot?: string | null; scopeKey: string; summary: string; noteDigest?: string | null; updatedAt?: string; } export interface UpsertMemoryItemOpts { id?: string; sessionId?: string | null; scopeType: string; scopeKey: string; kind: string; sourceTable?: string | null; sourceId?: string | null; subject?: string | null; text: string; state?: MemoryItemState; importance?: number; accessCount?: number; lastAccessedAt?: string | null; createdAt?: string; } /** * Source tables `deleteMemoryItemCascade` knows how to remove, including their * upstream producers. Exported so the deletion preview promises exactly what * the delete performs — a table missing here must not be advertised as "deleted * too", and a future source table must be added in both places at once. */ export declare const DELETABLE_SOURCE_TABLES: ReadonlySet; /** An audit-trail row (FR-22). Outlives the item it names — see the DDL. */ export interface ParsedMemoryCorrection { id: string; memory_item_id: string; source_table: string | null; source_id: string | null; scope_key: string | null; operation: string; prior_text: string; new_text: string | null; prior_subject: string | null; created_at: string; } export interface RecordMemoryCorrectionOpts { id?: string; memoryItemId: string; sourceTable?: string | null; sourceId?: string | null; scopeKey?: string | null; operation: 'edit' | 'delete'; priorText: string; newText?: string | null; priorSubject?: string | null; createdAt?: string; } /** Column filters for the memory-item listing (FR-21). Absent = unfiltered. */ export interface MemoryItemFilter { scopeKeys?: string[]; kinds?: string[]; states?: string[]; } export interface ListMemoryItemsOpts extends MemoryItemFilter { limit?: number; offset?: number; } export interface UpsertMemoryItemSemanticOpts { memoryItemId: string; summary: string; concepts?: string[]; entities?: string[]; embeddingModel: string; embedding: number[]; sourceHash: string; updatedAt?: string; } export interface UpsertCurrentAppGraphOpts { scopeKey: string; scopeType: string; gitRoot?: string | null; worktreePath?: string | null; branchRef?: string | null; headOid?: string | null; files: string[]; updatedAt?: string; } export interface UpsertMemoryReferenceOpts extends ExtractedMemoryReference { status?: MemoryReferenceStatus; checkedAt?: string | null; } export interface InsertRetrievalLogOpts { id?: string; sessionId?: string | null; topic: string; queryText?: string | null; resultIds?: string[]; totalCandidates?: number; returnedCount?: number; tokenEstimate?: number; createdAt?: string; } export interface UpdateMemoryItemStateOpts { id: string; state: MemoryItemState; } export interface TableCounts { sessions: number; events: number; notes: number; state: number; token_ledger: number; command_runs: number; episodes: number; branch_snapshots: number; project_snapshots: number; memory_items: number; memory_item_semantics: number; current_app_graphs: number; memory_references: number; retrieval_log: number; file_renames: number; content_digests: number; } export declare function parseNoteRow(row: NoteRow): ParsedNote; export declare function parseEventRow(row: EventRow): ParsedEvent; export declare function parseCommandRunRow(row: CommandRunRow): ParsedCommandRun; export declare function parseEpisodeRow(row: EpisodeRow): ParsedEpisode; export declare function parseMemoryItemRow(row: MemoryItemRow): ParsedMemoryItem; export declare function parseMemoryItemSemanticRow(row: MemoryItemSemanticRow): ParsedMemoryItemSemantic; export declare function parseCurrentAppGraphRow(row: CurrentAppGraphRow): ParsedCurrentAppGraph; export declare function parseMemoryReferenceRow(row: MemoryReferenceRow): ParsedMemoryReference; export declare function parseRetrievalLogRow(row: RetrievalLogRow): ParsedRetrievalLog; export declare class CortexStore { readonly db: Database.Database; /** * The underlying handle. * * Public so callers that must operate on the *file* rather than its rows — * the WAL checkpoint of FR-25 — can do so without opening a second * connection. * * `readonly` is a **compile-time** annotation, not a runtime guarantee: this * package ships `dist/`, and a JavaScript consumer can reassign it or call * `close()` on it, after which every method here throws. Stated rather than * implied, because an earlier version of this comment claimed the annotation * prevented what it only discourages. */ constructor(db: Database.Database); private resolveSessionScope; private getProjectScopeKey; private syncMemoryItemForNote; /** * The FR-4 demotion step: one tier colder, exactly once, at the moment a * note becomes superseded. Kept out of `syncMemoryItemForNote` so re-syncs * cannot repeat it. */ private demoteMemoryItemForNote; private syncMemoryItemForCommandRun; private syncMemoryItemForEpisode; private syncMemoryItemForBranchSnapshot; private syncMemoryItemForProjectSnapshot; getMeta(key: string): string | undefined; setMeta(key: string, value: string): void; /** * Increment a numeric `meta` counter in ONE statement. * * A read-modify-write across two connections loses updates even under * `busy_timeout`: that setting serialises writes, it does not make * read-then-write atomic, so two processes can both read `5` and both write * `6`. Hook processes are independent by construction, so any counter they * share needs the increment to happen inside the database. * * The digit guard is not decoration. A bare `CAST(value AS INTEGER)` parses a * numeric PREFIX — `'12 fires'` becomes 12 — which is precisely the * fail-forward behaviour `parseInt` was banned for after four incidents, just * arriving through SQL instead of JS. Only an all-digit value counts; anything * else restarts at 1, matching the `Number`-based readers that treat a corrupt * value as 0. */ incrementMetaCounter(key: string): void; createSession(opts?: CreateSessionOpts): SessionRow; getSession(id: string): SessionRow | undefined; /** * The active *primary* session. Child sessions stay active for as long as * their subagent runs, so without the parentage filter the newest subagent * would become "the current session" and every primary-path caller would * start writing into it (AD-9). */ getCurrentSession(): SessionRow | undefined; /** * Resolve a child session by its AD-9 identity. Deliberately unfiltered by * status and parent: a subagent's entries can be replayed from the spool * after its parent has ended, and must still find their own session. */ getSessionByAgentId(scopeKey: string, agentId: string): SessionRow | undefined; updateSessionAgentType(id: string, agentType: string): void; updateSessionFocus(id: string, focus: string): void; updateSessionScope(id: string, scope: { gitRoot?: string | null; worktreePath?: string | null; branchRef?: string | null; headOid?: string | null; scopeType: string; scopeKey: string; }): void; endSession(id: string): void; /** * End a session together with its still-active children. Nothing else ends a * child — session rotation and `inject-header` both act on the active * primary — so without this a subagent's session stays `active` forever and * is structurally exempt from consolidation and event GC, both of which * require `status = 'ended'`. */ endSessionTree(id: string): void; getRecentSessions(limit: number): SessionRow[]; /** * Recent primary sessions. Callers deriving "where am I working" must use * this rather than `getRecentSessions`: children sort ahead of their parent * (they are created later), so an orphaned child would otherwise become the * scope anchor once the primary has ended. */ getRecentPrimarySessions(limit: number): SessionRow[]; /** * Primary sessions only. These feed branch snapshots, the recent-session * tail and the consult gate; a child inherits its parent's scope_key, so * without the filter subagent activity would surface as scope history. * Child timelines are reached explicitly via getChildSessions. */ getRecentSessionsByScope(scopeKey: string, limit: number): SessionRow[]; getSessionCountByScope(scopeKey: string): number; getUnconsolidatedSessions(): SessionRow[]; getUnconsolidatedSessionsByScope(scopeKey: string): SessionRow[]; getChildSessions(parentId: string): SessionRow[]; /** * The session's own id followed by each ancestor, nearest first (AD-16). * * A read is refund-eligible when the recording session is the requester or an * *ancestor* of it, so this list is exactly the eligibility set — membership * is the whole predicate, which keeps the rule in one place instead of spread * across the caller. * * Walked rather than computed as `parent_session_id ?? id`. Depth is 2 today * by construction (a subagent's parent is the scope's active *primary*, never * another subagent), and the shorthand would be correct for exactly that * shape — which is why it is not used: a future nesting change would silently * start reporting a grandparent's read as someone else's, and the failure * mode of AD-16 is a wrong "you read it". The visited set is not decoration * either: `parent_session_id` carries no CHECK preventing a cycle, and a * cycle here would hang the query surface rather than answer it wrongly. */ getSessionAncestorIds(sessionId: string, maxDepth?: number): string[]; /** * Whether `sessionId` edited or wrote the file behind `filePath` after `after`. * * The evidence behind Story 3.3's `edited-by-you-since`, and the reason it * lives in the store: `events.target` holds the **raw** path the tool * reported, while `content_digests.path` is normalized and scope-root-relative. * Comparing the two directly never matches, and the failure is silent — the * verdict degrades to a bare `changed-since` with nothing to indicate the * join was the problem. * * The SQL `LIKE` is a **prefilter only**, never the decision. It narrows to * events whose raw target ends in the same basename so a session with tens of * thousands of edits does not stream them all into JS; the exact answer is the * key comparison below, which is the same derivation the write used. A * prefilter that over-matches costs nothing; one that under-matches loses the * edit fact silently, degrading `edited-by-you-since` to a bare * `changed-since` with nothing to indicate why. * * **So it is applied only to a pure-ASCII basename.** An earlier version of * this comment claimed the prefilter could not under-match, because the * basename is invariant under every transformation `toScopeRelativeKey` * applies except case, and SQLite's `LIKE` is case-insensitive. That claim was * false and the code inherited the bug: SQLite's `LIKE` folds case for **ASCII * only**, while `normalizeFilePathKey` uses JavaScript `toLowerCase()`, which * folds the full Unicode range on win32 and darwin. Measured — `Unicode-Ü.ts` * normalises to the key `unicode-ü.ts`, the raw event target still holds `Ü`, * `LIKE` matched zero rows, and AC #4 was **unreachable for that file** on * both case-insensitive platforms while `Ascii.ts` passed. Skipping the * prefilter there costs a full scan of one session's edit events and always * returns the right answer; the ASCII path keeps the optimisation. */ sessionEditedPathAfter(opts: { sessionId: string; scopeKey: string; path: string; after: string; scopeRoot?: string | null; }): boolean; getSessionCount(): number; getTableCounts(): TableCounts; insertEvent(opts: InsertEventOpts): string; getEventsBySession(sessionId: string): ParsedEvent[]; getEventsByType(sessionId: string, type: string): ParsedEvent[]; getEventCount(sessionId: string): number; deleteEventsBySession(sessionId: string): void; insertCommandRun(opts: InsertCommandRunOpts): ParsedCommandRun; getCommandRun(id: string): ParsedCommandRun | undefined; getCommandRunsBySession(sessionId: string): ParsedCommandRun[]; getCommandRunByEvent(eventId: string): ParsedCommandRun | undefined; insertEpisode(opts: InsertEpisodeOpts): ParsedEpisode; /** Newest episode of a kind whose summary matches the base text (with or without a repeat suffix). */ /** * A session and everyone it shares a primary with: the root primary plus all * of its children. Used to scope folds and evidence collection to one turn's * work without merging across unrelated sessions. */ getSessionTreeIds(sessionId: string): string[]; /** * Scoped to the recording session. The fold exists to collapse a retry loop * within one session, not to merge across sessions — unscoped, whichever * session hit an identical failure *first* owned the episode, so a subagent * failing before its parent left the parent with no episode at all and * reheated the child's row on the parent's activity. Two agents independently * hitting one failure are two observations and each keeps its own episode. */ findRecentEpisodeBySummary(kind: string, baseSummary: string, sinceIso: string, sessionId: string): ParsedEpisode | undefined; /** * Fold a repeated occurrence into an existing episode: bump the counter, * refresh recency, and keep one searchable row instead of N duplicates. * The repeat count is itself retrieval signal. */ bumpEpisodeOccurrence(id: string, baseSummary: string): ParsedEpisode | undefined; /** * Replace an episode's metadata, leaving `summary` and `created_at` alone. * * Deliberately narrower than `bumpEpisodeOccurrence`, which is for recording * a new occurrence and therefore refreshes recency. This one is for marking * an episode that has not changed. Touching `created_at` here would re-heat * the row, so a bookkeeping flag would silently promote the thing it is only * supposed to annotate — and the memory item is deliberately not re-synced, * because `buildEpisodeMemoryText` reads no key any caller of this writes. * Returns false when the id matches nothing, so a caller can tell a * successful mark from a no-op instead of assuming. */ setEpisodeMetadata(id: string, metadata: Record): boolean; getEpisode(id: string): ParsedEpisode | undefined; getEpisodesBySession(sessionId: string): ParsedEpisode[]; /** * What a note write would do to the notes already in the store, computed * WITHOUT writing it. * * Exists so the Story 5.3 `PreToolUse` guard can ask "would this call retire * somebody else's memory?" and get the same answer `insertNote` would produce * — by running the same code, not by re-deriving the rule. The story ordered * the predicate "mirrored exactly" and named three ways an earlier draft got * it wrong: that auto-supersede is same-kind only (a `decision` retires prior * decisions, never intents — the `kind = 'decision' OR kind = ?` clause in the * query below is the wider set fetched for CONTRADICTION detection and is * partitioned afterwards); that the AD-17 veto excludes contested priors; and * that the subject is `trim().toLowerCase()`. A guard that re-implements any * of those denies writes that would supersede nothing. Sharing the computation * makes the drift the test would have to catch impossible instead. * * Read-only and safe outside a transaction. `insertNote` calls it INSIDE its * own, which is what keeps the detect→supersede→insert sequence atomic. */ previewNoteWrite(opts: { kind: string; content: string; subject?: string | null; sessionId: string; skipConflictDetection?: boolean; }): NoteWritePreview; /** * The shared body of `previewNoteWrite` and `insertNote`'s decision phase. * `subject` arrives already normalized; passing a raw one is the drift this * extraction exists to prevent. */ private analyzeNoteWrite; insertNote(opts: InsertNoteOpts): InsertedNote; /** Scope key of a session, or null for an unscoped one. */ private scopeKeyForSession; getNote(id: string): ParsedNote | undefined; getNotesBySession(sessionId: string): ParsedNote[]; getActiveNotes(sessionId?: string): ParsedNote[]; getActiveNotesByScope(scopeKey: string): ParsedNote[]; getNotesByKindAndSubject(kind: string, subject: string): ParsedNote[]; getNotesByStatus(status: string): ParsedNote[]; getNotesByStatusAndScope(status: string, scopeKey: string): ParsedNote[]; updateNoteStatus(id: string, status: 'active' | 'superseded' | 'resolved'): void; findActiveNoteBySubject(subject: string): ParsedNote | undefined; markConflict(id: string): void; clearConflict(id: string): void; /** * Close the contest on a subject once a side has been resolved. * * A contest is subject-scoped, so resolving one side settles it: every * remaining note on that subject drops its marker. Without this the flag was * write-only — `markConflict` was the column's only writer — so a resolved * pair kept rendering `[contested]` forever, `cortex_note`'s own advice to * "close it with cortex_resolve" was false, and SM-5's resolution rate was * unmeasurable because resolution left no trace. A later contradicting write * simply re-flags. */ clearConflictsForSubject(subject: string, scopeKey: string | null): string[]; /** * Active notes on a subject, newest first. Deliberately scope-blind to match * `findActiveNoteBySubject`, which is what callers resolve through. */ getActiveNotesBySubject(subject: string): ParsedNote[]; /** Scope key of the session that wrote a note, or null. */ getScopeKeyForNote(noteId: string): string | null; insertState(opts: InsertStateOpts): string; getSessionState(sessionId: string): StateRow | undefined; getProjectState(): StateRow | undefined; replaceProjectState(content: string): void; getRecentStates(limit: number): StateRow[]; getRecentStatesByScope(scopeKey: string, limit: number): StateRow[]; getBranchSnapshot(scopeKey: string): BranchSnapshotRow | undefined; upsertBranchSnapshot(opts: UpsertBranchSnapshotOpts): BranchSnapshotRow; getProjectSnapshot(scopeKey: string): ProjectSnapshotRow | undefined; upsertProjectSnapshot(opts: UpsertProjectSnapshotOpts): ProjectSnapshotRow; upsertMemoryItem(opts: UpsertMemoryItemOpts): ParsedMemoryItem; upsertMemoryItemSemantic(opts: UpsertMemoryItemSemanticOpts): ParsedMemoryItemSemantic; getMemoryItem(id: string): ParsedMemoryItem | undefined; getMemoryItemSemantic(memoryItemId: string): ParsedMemoryItemSemantic | undefined; upsertCurrentAppGraph(opts: UpsertCurrentAppGraphOpts): ParsedCurrentAppGraph; getCurrentAppGraph(scopeKey: string): ParsedCurrentAppGraph | undefined; replaceMemoryReferences(memoryItemId: string, references: UpsertMemoryReferenceOpts[]): ParsedMemoryReference[]; getMemoryReferences(memoryItemId: string): ParsedMemoryReference[]; getMemoryReferencesForItems(memoryItemIds: string[]): Map; updateMemoryReferenceStatuses(updates: Array<{ id: string; status: MemoryReferenceStatus; checkedAt?: string; movedTo?: string | null; }>): void; insertFileRenames(opts: { scopeKey: string; renames: Array<{ oldPath: string; newPath: string; }>; headOid?: string | null; detectedAt?: string; }): number; /** Follow the rename chain for a path within a scope (a -> b -> c resolves to c). */ resolveFileRename(scopeKey: string, oldPath: string, maxHops?: number): string | null; getFileRenames(scopeKey: string): FileRenameRow[]; getMemoryItemBySource(sourceTable: string, sourceId: string): ParsedMemoryItem | undefined; listMemoryItemsByScopes(scopeKeys: string[], limit?: number, includeArchived?: boolean): ParsedMemoryItem[]; /** * Memory items matching every supplied filter, newest first (FR-21). * * Deliberately unlike `listMemoryItemsByScopes`, which hard-excludes * `archived`: this is the inspection path, and a listing that hides rows * cannot answer "what does Cortex actually hold". Callers narrow explicitly. * * `rowid DESC` is not decoration. Seeding and same-transaction projection * produce items sharing `created_at` to the millisecond, and `LIMIT`/`OFFSET` * over a non-total order silently repeats some rows and skips others. */ listMemoryItemsFiltered(opts?: ListMemoryItemsOpts): ParsedMemoryItem[]; /** How many items the same filter matches, ignoring limit/offset. */ countMemoryItemsFiltered(filter?: MemoryItemFilter): number; /** * Retrievals that returned this memory item, newest first (FR-21). * * `result_ids_json` is a JSON array, so the match goes through `json_each` * rather than `LIKE '%id%'` — a substring scan matches any id that merely * *contains* this one, and every id here is caller-supplied. * * Guarding malformed rows is load-bearing, not defensive noise: `json_each` * over a malformed or NULL value raises, and the raise takes the whole query * with it rather than skipping the row — one bad row would make access * history unreadable for every item in the store. The guard is applied * *inside* `json_each`'s argument rather than as a sibling `AND` term, * because SQLite does not contractually fix the evaluation order of WHERE * conjuncts; substituting an empty array cannot be reordered away. */ getRetrievalLogsForItem(memoryItemId: string, limit: number): ParsedRetrievalLog[]; searchMemoryItems(queryText: string, limit: number): SearchMemoryItemResult[]; searchMemoryItemSemantics(embedding: number[], limit: number, embeddingModel?: string): SemanticMemoryItemResult[]; listRecentMemoryItems(limit: number): ParsedMemoryItem[]; updateMemoryItemStates(items: UpdateMemoryItemStateOpts[]): void; touchMemoryItems(ids: string[], touchedAt?: string): void; insertRetrievalLog(opts: InsertRetrievalLogOpts): ParsedRetrievalLog; getRetrievalLog(id: string): ParsedRetrievalLog | undefined; getRetrievalLogsBySession(sessionId: string): ParsedRetrievalLog[]; /** * Write one ledger row. * * **A credit without evidence is refused, not silently written** (AC #3). * Before Story 3.5 the only `saved` producer in the codebase was * `writeSessionSummary`, computing * `estimateTokens(JSON.stringify(events)) - estimateTokens(summary)` — the * difference between a summary and pasting every captured event as raw JSON, * against a baseline no one would ever have paid. That single line was the * whole of the 657.6k "Saved" and the 93% "Efficiency" the product displayed. * A ledger whose credit side cannot be checked is worse than no ledger, * because it is quoted. * * Enforced here rather than by a table CHECK: this method is the single write * path (seven call sites, all through it), and adding a CHECK to a populated * table means a full rebuild in SQLite. Stated because it is a real * trade-off — a hand-written INSERT bypasses this guard, and nothing at the * storage layer would stop it. */ insertLedgerEntry(opts: InsertLedgerOpts): void; /** * Record that Cortex told a session it already has this file's content. * * **An offer is pending state, not an accounting fact, and that distinction * is the whole of AC #6.** Written into `token_ledger` as an `unrealized` * row — as this did briefly — an offer counted as adoption failure the * instant it was *made*: an agent that adopted every offer scored identically * to one that ignored every offer, and the figure rendered "offered, not * taken" actually meant "offered". It also forced a DELETE on consumption, * against AD-8's "every ledger row is append-only". * * Keyed on `(session_id, scope_key, path)` and upserted, so asking the same * question five times leaves one offer rather than five — otherwise following * the documented best practice ("ask before re-reading") would monotonically * inflate the adoption-failure metric. */ upsertReadOffer(opts: { sessionId: string; scopeKey: string; path: string; byteSize: number; tokens: number; }): void; /** * Consume an open offer for this file and book the decline, atomically. * * Returns the `unrealized` row's evidence, or null when no offer was open — * in which case nothing is recorded, because a read Cortex never offered to * save is not a declined offer. * * **The consume and the booking are one transaction.** As two statements, a * failure between them destroyed the offer and recorded nothing — silently * losing the exact fact AC #6 exists to capture. This runs on `hook-entry * post` and `cli log read`, where nothing else wraps it. * * **Matched across the session's ancestry, not just the exact session.** The * offer is made to whichever session called the tool — always the primary, * since `cortex_read_ledger` resolves without an agent id — while a * subagent's Read replays under its own child session. Filtering on equality * meant a delegated read could never be seen as a decline, and delegated work * is the majority of tool calls. The ancestry rule is AD-16's, reused: a read * by you or by a descendant answers an offer made to you. */ consumeReadOffer(sessionId: string, scopeKey: string, filePath: string, withinMs?: number): { path: string; byteSize: number; tokens: number; } | null; /** Offers that expired unconsumed. Never counted — an unread offer is not a decline. */ pruneExpiredReadOffers(withinMs?: number): number; getLedgerBySession(sessionId: string): LedgerRow[]; /** * Ledger totals, by direction. * * `spent` is retained as the field name for `injected` so existing readers * keep compiling; `estimated` and `unrealized` are reported separately and * are deliberately **not** folded into `saved`. Folding them is the whole * failure this story corrects — a credit that cannot be evidenced, added to * one that can, produces a headline number nobody can check. */ getTotalTokens(): { spent: number; saved: number; unrealized: number; estimated: number; }; /** * Totals plus a per-type breakdown. * * `byType` carries all four directions. It carried only `spent`/`saved`, so * `unrealized` and `estimated` rows fell out of it entirely — a consumer * would have under-reported without any indication that rows were missing. */ getLedgerStats(): { spent: number; saved: number; unrealized: number; estimated: number; byType: Record; }; /** * Per-session, per-direction ledger sums for an explicit session list — * the FR-9 session block, called with a primary's tree * (`getSessionTreeIds`). Returning rows rather than folded totals lets the * caller both total the tree and see whether any child contributed. */ getSessionLedgerTotals(sessionIds: string[]): SessionLedgerTotalRow[]; /** * Cumulative ledger sums for a set of scope keys (FR-9's "cumulatively for * the scope"). The ledger carries no scope column, so attribution joins * through `sessions`; children inherit their primary's scope_key (Epic 0) * and GC rollups keep their session_id, so both stay inside the total. A * row whose session is gone drops out — an undercount, the direction FR-9's * PM note prefers ("over-reporting is fatal"); nothing deletes sessions * today. */ getScopeTokenTotals(scopeKeys: string[]): LedgerDirectionTotals; /** * Ledger rows no scope view can reach: sessions whose `scope_key` is NULL * (the column was added by migration with no backfill, so pre-scope stores * hold such sessions) and rows whose session row is gone. The scope join * drops both silently — an undercount, safe for the ratio, but the * `estimated` history FR-8 promised to keep visible would vanish from every * surface without this. Measured 0 on this repo's store; the query exists * for the stores where it is not. */ getUnattributedTokenTotals(): LedgerDirectionTotals; /** Item counts by state, store-wide (FR-9 retrieval health; D5). */ getMemoryItemStateCounts(): Record; /** * Items retrieval has never reinforced. `access_count` is bumped only by * `touchMemoryItems` and preserved by every re-sync path (FR-22), so zero * means exactly "never retrieved". */ countNeverRetrievedMemoryItems(): number; /** * The most-retrieved items, store-wide. `access_count > 0` because padding * a "most-retrieved" list with never-retrieved rows fabricates retrieval * history. The tiebreakers are load-bearing: seeded and same-transaction * rows share timestamps to the millisecond, and an unstable order over a * partial order silently reshuffles between runs (the FR-21 paging lesson). */ getMostRetrievedMemoryItems(limit: number): ParsedMemoryItem[]; recordMemoryCorrection(opts: RecordMemoryCorrectionOpts): ParsedMemoryCorrection; /** Corrections recorded against an item, newest first. Outlives the item. */ getMemoryCorrections(memoryItemId: string): ParsedMemoryCorrection[]; /** * Replace an item's text and re-derive everything that hangs off it (FR-22). * * A note-backed item is corrected **through its note**: `notes.content` is * updated and the existing projection rebuilds the item, so the trailer * (`Subject:` / `Alternatives:` / `Conflict:` / `Status:`) stays consistent * with the columns it mirrors. Patching `memory_items.text` directly instead * would desynchronise the two — the exact drift `inspect-memory` reports as * `diverged`, introduced by the command meant to repair memory. * * Access counters and state are deliberately preserved: a correction is not * a new memory, and reheating one as a side effect of fixing a typo would * change retrieval ranking for a reason the user never asked for. */ /** * The text a correction command reads and writes for an item: a note-backed * item is edited through `notes.content`, everything else through * `memory_items.text`. Keeps `prior_text` round-trippable. */ private editableTextFor; updateMemoryItemText(id: string, text: string): boolean; /** * Delete a memory item, its source row, and everything derived from it — * in one transaction (FR-22). * * Deleting the `memory_items` row alone is not a deletion. `backfillMemoryItems` * re-inserts from `notes`, `episodes`, `project_snapshots` and `command_runs` * on every `ensureCortexSchema`, which every CLI command triggers, so the item * returns with its original id on the next invocation. The source row is what * makes the removal durable. * * `memory_references`, `memory_item_semantics` and the FTS row follow the * item automatically — the first two by `ON DELETE CASCADE` (which relies on * `openDatabase` setting `foreign_keys = ON`), the third by an AFTER DELETE * trigger. They are pinned by test rather than trusted. */ deleteMemoryItemCascade(id: string): boolean; /** * Delete the rows a source row is itself re-derived from. * * Three of the six source tables are **second-order**: the backfill rebuilds * them from `events` and `state`, reusing the same primary key. Deleting only * the source row therefore looks correct and is undone by the next * `ensureCortexSchema` — the very failure `deleteMemoryItemCascade` exists to * prevent, one level further up than it originally looked. * * command_runs ← events (type='cmd'; `handleCmdEvent` reuses the event id) * episodes ← state (layer='session'; `writeSessionSummary` reuses the state id) * project_snapshots ← state (layer='project'; `insertState` reuses its own id) * * A `state` row is itself upstream of the other two, so deleting a * `state`-backed item takes its twin projection with it — one piece of * content is otherwise projected as two memory items sharing an id. */ private deleteUpstreamOf; runInTransaction(fn: () => T): T; /** * Like `runInTransaction`, but takes the write lock up front. * * Required for any read-then-write sequence: a DEFERRED transaction upgrades * lazily, and the upgrade fails with `SQLITE_BUSY_SNAPSHOT`, which **bypasses * the busy handler** — so `busy_timeout` never applies and the work is * discarded instead of waiting. `insertNote` and `updateNoteStatus` use this * for the same reason; two sessions share one database file. */ runInImmediateTransaction(fn: () => T): T; /** * Record what a file's bytes were when a session read it. * * Keyed by `(scope_key, path)`, so a re-read overwrites rather than appending * — the ledger answers "has this changed since I read it", which needs the * latest digest, not a history. `read_count` accumulates across the upsert * because Story 3.4 orders its brief line by read frequency and a keyed row * cannot recover that number afterwards. * * `session_id`/`agent_id` record the reader, and the update is **not** * unconditional last-writer-wins. AD-16 asks whether the requesting session * *or an ancestor* read the file, so a descendant overwriting its ancestor * destroys the stronger claim: measured, a parent read followed by its own * subagent reading the same file left zero rows attributable to the parent, * and the parent would later be told a subagent read a file it read itself. * Under-crediting is tolerable (SM-C3); misattributing an ancestor's read to * its descendant is not. * * So when the existing recorder is the incoming reader's parent, the existing * reader is kept while the content columns still update. Sessions nest exactly * one level — Epic 0 creates child sessions directly under the active primary * — so "ancestor" is the parent, and this is a comparison rather than a walk. * An unrelated newer session still takes over, which is correct: it is not a * descendant, so nothing stronger is being discarded. */ /** * The worktree root recorded for a scope, memoized. * * The store derives the digest key itself — relative to this root — on both * write and read, so a caller cannot supply one and forget the other. That * asymmetry is silent and total: the write would key `src/a.ts` while the * read looked up `c:/repo/src/a.ts`, and the ledger would answer "unread" for * every file it had just recorded. Memoized because a flush resolves the same * scope for hundreds of entries, the same reason `sessionByAgent` exists. */ private readonly scopeRootCache; /** * Public because a caller that must resolve an on-disk path for a scope has * to use the SAME root the key derivation uses. * * The read ledger resolves a relative input against the scope root in order * to hash it, while `getContentDigest` derives the lookup key against this * one. Taking the requesting session's own `worktree_path` for the first and * leaving the store to resolve the second is two roots for one query — the * shape Story 3.2 was bitten by, and the shape `recordReadDigest` carries * three lines of comment to avoid. Content-derived comparison happens to * absorb the divergence today (hashing the wrong file can only produce a * *content* mismatch, never a false `unchanged`), which is exactly why it * would sit undetected until a path-identity check is added. */ resolveScopeRoot(scopeKey: string): string | null; private scopeRootFor; upsertContentDigest(opts: UpsertContentDigestOpts): ParsedContentDigest; getContentDigest(scopeKey: string, filePath: string, scopeRoot?: string | null): ParsedContentDigest | undefined; /** * Record a certified zero-result search (FR-12, Story 4.3). Plain * last-writer-wins upsert: a re-search that still found nothing refreshes * the census, the head, and `recorded_at`. No retention CASE like * `upsertContentDigest`'s — there is no per-session attribution to preserve * (negatives are scope facts), and each certified capture fully supersedes * the prior evidence. */ upsertNegativeResult(opts: UpsertNegativeResultOpts): ParsedNegativeResult; /** Exact-key lookup; the `scope_key` equality IS the AC #5 boundary. */ getNegativeResult(scopeKey: string, queryKey: string): ParsedNegativeResult | undefined; /** Record a dispatch seen at `PreToolUse` on the `Agent` tool. */ insertSubagentDispatch(opts: InsertSubagentDispatchOpts): ParsedSubagentDispatch; getSubagentDispatch(id: string): ParsedSubagentDispatch | undefined; /** * The capture a given subagent consumed, if it consumed one. * * The audit at `SubagentStop` (FR-19, Story 5.3) closes what Story 5.2 could * only defer: 5.2 proved its pairing UNAMBIGUOUS, never RIGHT, because * `SubagentStart` carries no `tool_use_id`. The host's per-agent sidecar does, * and it is written strictly after every `SubagentStart` hook returns — so * `SubagentStop` is the first moment the guess can be checked against ground * truth. This is the lookup that finds the guess. * * Scoped by host session as well as agent id: `agent_id` is recycled across * conversations, and an unscoped lookup would compare this run's sidecar * against a dispatch from a previous one and report a mispairing that never * happened. Newest first, because a re-fire onto a recycled id can leave more * than one row bearing the same consumer. */ getSubagentDispatchByConsumer(hostSessionId: string, agentId: string): ParsedSubagentDispatch | undefined; /** * How many unconsumed captures match the pairing key inside the horizon. * * Read separately from the consume so the ambiguous case can be COUNTED. More * than one match means only dispatch order separates the candidates — N * same-type subagents dispatched in one assistant message — and a design whose * safety rests on that ordering has to report how often the assumption is * being tested (AD-12). */ countPendingSubagentDispatches(key: SubagentDispatchKey, notOlderThan: string): number; /** * Claim the oldest unconsumed capture matching the key, in ONE statement. * * A conditional `UPDATE ... RETURNING` rather than select-then-update: * `SubagentStart` hooks are independent OS processes, and `busy_timeout` * serialises writes without making read-then-write atomic — Story 5.1's review * reproduced two hook processes losing an increment through exactly that * shape. Here the same race would hand ONE capture to TWO subagents, so the * claim has to happen inside the database. A returned row is the row count: * exactly one caller sees it. * * `captured_at > notOlderThan` is CORRECTNESS, not housekeeping, and must not * be confused with the GC rule that also prunes this table. GC runs at most * once per 24 hours, so a capture orphaned at 09:00 — a dispatch the user * denied, or one the host never started — would otherwise stay eligible to * mis-brief a later same-type subagent all day. * * The `NOT EXISTS` clause is ONE SUBAGENT, ONE CAPTURE. `SubagentStart` can * fire more than once for a single `agent_id` — the host supports continuing * an existing agent, and Story 5.1's deferred work already records a re-fire * onto a recycled id as reachable — and the claim is otherwise per-CAPTURE, not * per-agent. Reproduced in review: two captures pending, `SubagentStart(alpha)` * twice and `SubagentStart(bravo)` once gave alpha TWO briefs (the second * carrying bravo's topic), bravo none, and both injections billed to alpha. * Kept inside the same statement so it stays atomic across hook processes. */ consumeSubagentDispatch(key: SubagentDispatchKey, notOlderThan: string, agentId: string, consumedAt?: string): ParsedSubagentDispatch | undefined; } //# sourceMappingURL=store.d.ts.map