import { type Conversation, type ConversationMeta, ConversationScanner, type FileStatEntry } from "@threadbase-sh/scanner"; import type { ConversationCache } from "./conversation-cache"; import type { CacheMetadataRepository } from "./db/repositories/cacheMetadata.repository"; import type { ConversationsRepository } from "./db/repositories/conversations.repository"; import type { ProjectsRepository } from "./db/repositories/projects.repository"; import type { CacheIntegrityMonitor } from "./services/cache-integrity/cacheIntegrityMonitor"; import { debounce } from "./utils/debounce"; export declare const REFRESH_TTL_MS = 2000; /** What a refresh request actually did — see refreshFileForRead/AfterWrite. */ export type RefreshOutcome = /** This request started the parse that covers it. */ "refreshed" /** It awaited a parse another request had already started or queued. */ | "joined" /** Read path only: a parse fulfilled within REFRESH_TTL_MS, so none ran. */ | "skipped"; export type RefreshResult = { outcome: RefreshOutcome; /** * The scanner's own answer for the covering pass: fresh metadata, or null * for a file that no longer parses (missing/empty — refreshFile drops it * from the indexes and returns null). Always null for "skipped", which is * the outcome to check before reading anything into a null meta. */ meta: ConversationMeta | null; }; export type ConversationReconcileMode = "files" | "full"; export type ScanProfile = { id: string; label: string; configDir: string; enabled: boolean; emoji: string; }; /** * Everything ScannerManager reads from the server. Nullable collaborators are * thunks rather than values because they are opened during listen() and * rebound by the integrity monitor's reset-and-rescan — the same reason * ApiDeps passes `cache: () => ConversationCache | null`. */ export type ScannerManagerDeps = { scanProfiles: ScanProfile[] | undefined; codexRoots: string[]; cursorRoots: string[]; directoryDebounceMs: number; persistenceDisabled: boolean; cache: () => ConversationCache | null; cacheMonitor: () => CacheIntegrityMonitor | null; projectsRepo: () => ProjectsRepository | null; conversationsRepo: () => ConversationsRepository | null; cacheMetadataRepo: () => CacheMetadataRepository | null; trackCacheWrite: (task: Promise) => void; }; /** * Owns the conversation scanner's lifecycle and freshness state: which scanner * instance is current, whether a scan is in flight, which files went stale, and * the cache↔disk reconcile that closes the gap. * * Extracted from StreamerServer so scanner work stops editing the server file * (see docs/plans/2026-07-12-server-ts-split.md, PR 2). */ export declare class ScannerManager { private deps; private scanner; private scannerReady; private scannerStale; private persistenceDisabled; private allScanners; private stalePaths; private reconcileInFlight; private lastAutoFullReconcileAt; private static readonly AUTO_FULL_RECONCILE_COOLDOWN_MS; private refreshState; private log; /** * Trailing-debounced "the directory changed" signal. Kept public because the * server wires it straight into the watcher callback and cancels it in * close(); `.cancel()` is part of its contract. */ readonly markStaleDebounced: ReturnType; constructor(deps: ScannerManagerDeps); /** The current scanner without triggering a scan; null before the first one. */ get current(): ConversationScanner | null; /** The in-flight (or settled) scan promise; null when no scanner was ever built. */ get ready(): Promise | null; get stale(): boolean; set stale(value: boolean); /** The stale-path set itself, so callers can add/clear/size it in place. */ get staleFiles(): Set; /** Drop the current scanner so the next get() builds a fresh one. */ invalidate(): void; /** * A new JSONL bound to a live session: the index must pick it up. * * Arm the stale flag when a scan already exists, so the next get() reconciles * in place. With no scanner yet there is nothing to mark, so drop it instead * and let the next get() build one. Exactly one of the two happens — dropping * the scanner in both cases would throw away a live index on every bind. */ markStaleOrDrop(): void; /** Persistent indexes are disabled for the rest of this process's life. */ disablePersistence(): void; /** Track a scanner the server built itself so close() still tears it down. */ track(scanner: ConversationScanner): void; /** * Adopt the warm-up scanner as the live one, but only if nothing else claimed * the slot while the warm-up scan ran. */ adoptIfUnclaimed(scanner: ConversationScanner): boolean; buildStatCache(previousScanner: ConversationScanner | null): Map | undefined; codexScanOpts(): { providers: ("claude-code" | "codex-cli" | "cursor")[]; codexRoots: string[]; cursorRoots: string[]; }; newScanner(options?: ConstructorParameters[0]): ConversationScanner; /** * The projects dirs disk discovery should walk — the single source of truth * for "where do this server's JSONLs live", mirroring the warm-up watcher * (see listen()). Derived from the enabled scanProfiles' configDirs, or the * real ~/.claude/projects when no profiles are configured. An all-disabled * profile set intentionally yields [] (nothing to discover), matching the * watcher — it does NOT fall back to home in that case. */ projectsDirs(): string[]; takeStaleFiles(): string[]; refreshStaleFiles(scanner: ConversationScanner, paths: string[]): Promise; isConversationSnapshotStale(conv: Conversation): boolean; /** * Read-path refresh: throttled and coalesced, for a caller that would rather * serve the current snapshot than pay a parse. * * - a pass already reading the file → await it ("joined"); * - a pass that fulfilled within REFRESH_TTL_MS → skip ("skipped"), so N * stacked detail requests on a live, actively-appended file cost one * parse per window rather than one each; * - otherwise parse ("refreshed"). * * Deliberately NOT content-aware: skipping only when the file is unchanged * would defeat the throttle exactly where it earns its keep, since a live * rollout changes on every append. */ refreshFileForRead(scanner: ConversationScanner, filePath: string): Promise; /** * Post-write refresh, for a caller that knows the file just changed (the * end of an agent turn, a user's input, a directory event). * * The guarantee: when the returned promise fulfils, a parse that STARTED * after this call has completed. It cannot be thrown away by the read * throttle, and it cannot be satisfied by an older parse — completion of a * parse that began before the write is no proof it observed the write, and * the scanner would hand exactly that parse to a direct caller. * * So: no pass running → parse now; a pass running → wait for a follow-up * that starts when it settles. Every post-write request arriving during a * pass shares that one follow-up, so a burst costs one extra parse, not one * per caller. Requests that arrive after the follow-up has STARTED are not * covered by it and get the next one. * * What it does not promise: that the writer's bytes have reached disk. This * is "everything visible in the file when this was called is indexed", not * a flush protocol — a waiting_input signal is not proof of a flush. */ refreshFileAfterWrite(scanner: ConversationScanner, filePath: string): Promise; private refreshStateFor; private startRefreshPass; private queueRefreshPass; private finishRefreshPass; private drainRefreshPasses; get(skipStaleRescan?: boolean): Promise; getFresh(): Promise; rescanForRefresh(onProgress?: (scanned: number, total: number) => void): Promise; /** * Full-glob scan + cache upsert/delete reconcile. Used by ?refresh=1 and by * the automatic freshness path when the directory watcher marked the scanner * stale or shouldRefreshProjectsFromHdd detected disk drift. */ reconcileFromDisk(onProgress?: (scanned: number, total: number) => void): Promise; startBackgroundReconcile(mode?: ConversationReconcileMode): void; reconcileMode(): ConversationReconcileMode | null; private reconcileStaleFilesFromDisk; close(): Promise; } //# sourceMappingURL=scanner-manager.d.ts.map