import { type ConversationMessage, type ConversationMeta, type FileStatEntry } from "@threadbase-sh/scanner"; import Database from "better-sqlite3"; import { type Stats } from "fs"; import { type ProviderName } from "./providers"; import { ConversationClassifier } from "./services/conversations/classification"; import { type LineSpan } from "./utils/fileIdentity"; export interface ConversationCacheOptions { includeSubagentSessions?: boolean; filterAgentConversations?: boolean; agentEntrypoints?: ReadonlySet; onAgentFileDetected?: (filePath: string) => void; } export interface ConversationListItem { hasMessages?: boolean | null; isSubagent?: boolean | null; parentConversationId?: string | null; id: string; filePath: string; projectId: string | null; projectPath: string | null; projectName: string | null; title: string | null; model: string | null; account: string | null; branch: string | null; messageCount: number; lastActivity: string; firstMessage: string | null; lastMessage: string | null; preview: string | null; source: string | null; provider: ProviderName; isImportedFromClaude: boolean; isImportedFromCodex: boolean; isImportedFromCursor: boolean; /** ISO timestamp if soft-deleted from the cache, else null. The JSONL is untouched. */ deletedAt: string | null; } export type ConversationInclude = "all" | "conversations" | "subagents"; export interface ConversationListFilters { project?: string; provider?: string; include?: ConversationInclude; isImportedFromClaude?: boolean; isImportedFromCodex?: boolean; isImportedFromCursor?: boolean; } export interface CachedTailMessage { role: string; timestamp: string; text: string; content?: unknown[]; } export interface CachedTail { conversationId: string; messages: CachedTailMessage[]; tailSize: number; } /** A row of `conversation_file_state` — per-file offset-index resume state. */ export interface FileStateRow { path: string; identity: string; size: number; mtime_ms: number; byte_offset: number; last_message_index: number; } /** A row of `conversation_message_index` — one indexed message's byte span. */ export interface MessageIndexRow { conversation_id: string; message_index: number; byte_offset: number; byte_length: number; uuid: string | null; role: string | null; ts: number | null; } export interface ScannerMeta { id: string; sessionId?: string; filePath: string; projectPath?: string; projectName?: string; title?: string; sessionName?: string; model?: string; account?: string; gitBranch?: string; messageCount?: number; timestamp?: string; firstMessage?: unknown; lastMessage?: unknown; preview?: string; provider?: ProviderName; isImportedFromClaude?: boolean; isImportedFromCodex?: boolean; isImportedFromCursor?: boolean; } export declare class ConversationCache { private classifiers; readonly includeSubagentSessions: boolean; private classifyFull; private classifyAppend; private fileAliases; private db; private tailSize; private fileIndex; private fileIndexLoaded; private indexParseState; private backfillInFlight; private tailSeq; private nameSeq; private stmts; private migrationsDir?; private filterAgentConversations; private agentEntrypoints; private onAgentFileDetected?; private constructor(); /** * Expose the underlying handle so projects/cache_metadata repositories can * share the same connection. Internal API; not part of the public surface. */ getDatabase(): Database.Database; getFileState(path: string): FileStateRow | null; upsertFileState(row: FileStateRow): void; /** Drop a file's index rows + file_state (truncation / identity change). */ deleteFileIndex(path: string, conversationId: string): void; /** Append/replace index rows in one transaction. */ appendMessageIndexRows(rows: MessageIndexRow[]): void; /** Rows for message_index in [fromIndex, toIndex), ordered ascending. */ getMessageIndexWindow(conversationId: string, fromIndex: number, toIndex: number): MessageIndexRow[]; getIndexedMessageCount(conversationId: string): number; /** * Conversation id for a JSONL path — the filename stem (matches the pseudo-id * updateFromLine derives and the uuid the detail read path resolves). The * offset index keys on this so the window select and the cursor agree. */ static conversationIdForFile(filePath: string): string; /** * The line reducer for `filePath`, or null when the file must not be indexed. * * Returning the parser and the eligibility together is deliberate: indexing a * file with the wrong provider's reducer does not fail, it indexes ZERO * messages and then serves empty windows for a real conversation — the * silent-wrong-data bug hotfixed after 1.28.0. Making the caller ask for a * parser rather than a boolean removes the arrangement where those two facts * can disagree. * * Claude's reducer is stateful (`extractToolResultBlocks` resolves a * tool_result's type from `pendingToolUses`), so its parser closes over one * `JsonlParseState` per call site. Codex's is stateless — every discriminator * lives on the single line — so it needs none, and a Codex window needs no * lookback to be correct. * * Resolve by file_path, NOT by conversationIdForFile: codex rollout files are * named rollout--.jsonl, so the filename stem is not the meta row's * id and an id lookup silently misses. A file with no meta row at all is NOT * indexable — provider unknown means indexing is unsafe; a later request * backfills once the meta row exists. * file_path is stored canonicalized (forward slashes), so the lookup key must * be canonicalized too. Passing a native Windows path here matches no row, * which reads as "not indexable" and silently disables the offset index for * every conversation. */ private lineParserFor; /** * Incremental offset-index writer: extend the index for a burst of appended * lines (one watcher read) using their byte spans. Each line is classified * with the scanner's parseJsonlLine (a running per-file reducer state), so the * message ordering can never drift from the scanner's. Message lines get an * index row at the next message_index; non-message lines (summary/sidecar) * get no row but still advance byte_offset. file_state is updated to the end * of the last consumed span. * * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read * path can detect truncation/replacement. * * `readFrom` is the absolute byte offset the watcher read started at, and * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it * doesn't — the watcher attached at EOF after the server was down, or an * append raced an in-flight backfill — extending would assign wrong * message_index values over a hole. In that case this writes nothing and * returns null so the caller drops the index and backfills. * * On success returns the message_index assigned to each input span (null for a * non-message line) so the caller can stamp WS `seq`. Empty array when spans * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's * offset and file_state.byte_offset are the same number by construction. */ extendMessageIndex(filePath: string, spans: LineSpan[], stat: Stats, readFrom: number, endOffset: number): (number | null)[] | null; clearIndexParseState(filePath: string): void; /** * On-demand full backfill of the offset index for a file with no/stale * file_state (cold conversation, or after a truncation/replacement). Rebuilds * from byte 0: drops any existing rows, walks the whole file in chunks with a * running parse state, yields to the event loop every ~1000 lines so a large * file never blocks, and writes index rows + file_state. * * Single-flighted per path: concurrent callers await the same walk. The * triggering detail request is served by the scanner fallback while this runs. */ backfillIndex(filePath: string): Promise; private runBackfill; /** * Windowed detail read straight from the offset index — the hot path. * Returns the parsed messages for message_index in [fromIndex, toIndex) plus * the total indexed count, or null when the index can't serve this file (no * file_state, identity/size mismatch = truncation/replacement, or cold index) * so the caller falls back to the scanner and enqueues a backfill. * * On a match it SQL-selects the window's byte ranges and preads exactly those * ranges from the JSONL (never the whole file), parsing only the sliced lines. * Returns messages in the same ConversationMessage shape parseJsonlLine * produces during a scan, so the payload is identical to the scanner path. */ readMessageWindow(filePath: string, fromIndex: number, toIndex: number): { messages: ConversationMessage[]; total: number; fromIndex: number; } | null; private agentEntrypointsKey; private classifyAgentFile; isAgentFileCached(filePath: string): boolean; static open(dbPath: string, tailSize?: number, migrationsDir?: string, options?: ConversationCacheOptions): ConversationCache; close(): void; getPopularProjects(limit: number): Array<{ path: string; name: string; sessionCount: number; }>; /** Every project with at least one cached conversation, most recently active * first. Paths are the raw `project_path` values, which is what * /api/conversations?project= matches on exactly — so a summary row is * always joinable against the page it describes. */ listProjectSummaries(opts: { limit: number; offset: number; }): { projects: Array<{ path: string; name: string; conversationCount: number; lastActivity: string; }>; total: number; }; private ensureFileIndex; /** NULL stays visible until a completed parse proves emptiness. */ isVisible(id: string): boolean; /** * Every row, ignoring the visibility predicate. Cache health is a property of * the whole cache: `listMissingFiles` counts rows a list query never returns, * so a filtered denominator would divide two different corpora. */ countAllRows(): number; isExcludedSubagent(id: string): boolean; reconcileClassification(filePath: string): ConversationClassifier | null; private classifyAppendedLines; /** Repair both legacy parent-keyed rows and live-tail aliases for this file only. */ private removeFileAliases; updateFromLine(filePath: string, rawLine: string): void; /** * Batched form of updateFromLine: applies a burst of newly-appended lines * (one chokidar read) in a single transaction with one message_count bump, * one meta write, and one tail read/write — instead of 2-4 synchronous * writes per line. Semantics are identical to replaying each line through * updateFromLine in order: the agent filter short-circuits the whole batch, * project context is backfilled last-wins, message_count increases by the * number of surviving message lines, and last_activity/last_message reflect * the newest message line by timestamp (a monotonic guard keeps them from * moving backward when an interleaved writer appends an older line — P0.3). */ updateFromLines(filePath: string, rawLines: string[]): void; upsertFromScannerMeta(metas: ScannerMeta[]): string[]; deleteByFilePath(filePath: string): boolean; populateTailFromFile(convId: string, filePath: string): boolean; listConversations(opts: ConversationListFilters & { limit: number; offset: number; }): { conversations: ConversationListItem[]; total: number; }; private get listColumns(); private conversationListWhere; /** Returns a map of filePath → { mtimeMs, size } for all rows that have * stat data stored. Used by the server to build the statCache passed to * ConversationScanner.scan() so unchanged files are skipped. */ getFileStats(): Map; getScannerStatCache(): Map; /** * Conversation id for a JSONL path, or null when no row exists yet. Resolves * by file_path (NOT conversationIdForFile) so codex rollout files — named * rollout--.jsonl, whose stem is not the row id — resolve correctly. */ getIdByFilePath(filePath: string): string | null; getMetaById(id: string): ConversationListItem | null; setConversationProjectId(conversationId: string, projectId: string): void; markAsStreamer(id: string): void; getLatestConversation(): { id: string; lastActivity: string | null; } | null; hasOrphanProjectId(): boolean; listConversationsForProjectBackfill(): Array<{ id: string; projectPath: string | null; projectId: string | null; lastActivity: string | null; }>; getConversationTail(id: string): CachedTail | null; hasConversation(id: string): boolean; /** * Hides a conversation from every list/get read path without touching its * JSONL or the row itself — just a `deleted_at` flag, so an upsert from a * later rescan (the file is still on disk) leaves it alone rather than * resurrecting it. Returns true if this call is what set the flag. * * Not preserved across the cache-integrity monitor's `reset_rescan` action, * which wipes and rebuilds `conversation_meta` from scratch — an explicit * operator recovery step, not routine background rescanning. */ softDeleteConversation(id: string): boolean; upsertSessionName(sessionId: string, name: string): void; getSessionName(sessionId: string): string | null; listSessionNames(): Record; invalidate(id?: string): void; /** * Drop the cached row for a file. Two callers with opposite intent: * - a directory-watch "change" event (the file was appended to) — pass * `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change * event fires on every external append; deleting here flickers the * conversation out of /api/conversations — whether it's a live-tailed row * the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers * fire on the same append with no ordering guarantee) OR a refresh-created * untailed row (a ?refresh=1 upsert never populates a tail, so the old * "delete when untailed" behavior made it vanish on its next append with no * client action). The live-tail path owns the row's content and the * debounced rescan re-derives metadata, so leaving the row loses nothing. * - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the * row is always removed, otherwise a deleted session ghosts in the cache. */ invalidateByFilePath(filePath: string, opts?: { skipIfTailed?: boolean; }): string | null; /** * Drop rows whose `file_path` no longer exists on disk AND which have no * cached tail to fall back to. Rows with a tail are left alone so * `handleGetConversation` can still serve the cached tail even when the * JSONL has been deleted. */ pruneGhostFiles(exists?: (filePath: string) => boolean): string[]; /** * Reconcile the cache against the authoritative set of conversation file * paths a fresh scan surfaced: drop any cached row whose `file_path` is not * in `livePaths` (removed from disk, or now filtered out — e.g. became an * agent JSONL). This is the "removed conversations" half of a ?refresh=1 * reconcile; the additions/updates half is upsertFromScannerMeta. * * Skip semantics depend on whether the file still exists on disk: * - File GONE from disk → always removed, tail or not. This matches the old * invalidate()+rebuild behavior (a deleted conversation must disappear on * refresh) and keeps refresh=1 truthful about removals. NOTE: this is an * INTENTIONAL divergence from pruneGhostFiles(), which KEEPS tailed ghosts * so their cached history stays viewable on a background prune. refresh=1 * has the opposite contract (mobile relies on removals being reflected), so * do not "unify" the two — they serve different purposes. * - File STILL on disk but absent from `livePaths` → the CRITICAL #2 race: * the scan snapshot predates a just-created (and now live-tailed) file. * A tailed row here is actively maintained from real content, so it is * kept — dropping it would flicker the active conversation out of * /api/conversations. An untailed on-disk row not in the snapshot is a * transient scan/discovery gap; it is left alone (not removed) and the * next reconcile picks it up, rather than risk removing a real file the * scan simply hasn't surfaced yet. * Returns the removed IDs. */ reconcileDeletions(livePaths: Set, opts?: { exists?: (filePath: string) => boolean; }): string[]; /** * Read-only: list cached rows whose `file_path` no longer exists on disk. * Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags * rows that still have cached history (which pruneGhostFiles would keep). */ listMissingFiles(exists?: (filePath: string) => boolean): { id: string; filePath: string; title: string | null; tailed: boolean; }[]; /** * Drop the given conversation ids outright — main row, tail, and message * index — regardless of whether they have a tail. Used by the cache-integrity * resolution actions (prune_all / prune_selected). Returns the count dropped. */ dropRowsById(ids: string[]): number; /** * Wipe all cached conversation state — meta, tails, and message index — and * reset the in-memory file index. Only called by the `reset_rescan` * resolution action, which repopulates from a fresh disk scan afterward. */ clearAll(): void; } //# sourceMappingURL=conversation-cache.d.ts.map