/** * McpWatcher — incremental re-indexer for the MCP server's --watch mode. * * Watches source files for changes and incrementally updates: * 1. signatures in llm-context.json (always) * 2. vector index (only when embed: true and an embedding server is reachable) * * The call graph is deliberately excluded — rebuilding it requires full * tree-sitter analysis of all call sites and is too expensive for a watch loop. * It stays current via the post-commit hook (openlore analyze --force --embed). * * Spec 13.1 (watch-mode performance): freshness is O(change), not O(repo). * • Per-file events COALESCE into one batched flush (single debounce timer + * hard max-batch ceiling), so a burst / branch-switch runs the pipeline once, * not once per file. * • The patched llm-context is handed to the MCP read cache in place * (primeContextCache), so the next tool call is a cache HIT — no 2.1 MB * cold re-parse — even after the disk write. * • Vector updates are row-level (VectorIndex.updateFiles), not a full-corpus * read+overwrite, and run on a separate lower-priority lane so signature * freshness never blocks on embedding. * • VCS-flood / bulk batches are detected and collapsed to a single refresh. * • stderr emits one summary line per batch by default (per-file detail behind * OPENLORE_WATCH_DEBUG). */ export interface McpWatcherOptions { /** Absolute path to the project root being watched */ rootPath: string; /** Absolute path to .openlore/analysis/ — where llm-context.json lives */ outputPath?: string; /** Milliseconds to debounce file-change events (default: WATCH_DEBOUNCE_MS) */ debounceMs?: number; /** Hard flush ceiling under a continuous change stream (default: WATCH_MAX_BATCH_MS) */ maxBatchMs?: number; /** Batch size that trips VCS-flood handling (default: WATCH_BULK_THRESHOLD) */ bulkThreshold?: number; /** Run the live vector update; false = signatures-only (default: true) */ embed?: boolean; /** Above this many watched source files, auto-degrade to signatures-only */ embedFileCeiling?: number; /** * Per-changed-file closure work budget (default DEFAULT_CLOSURE_BUDGET). The * max number of other files one save re-resolves before the rest are marked * explicitly stale. Exposed mainly so tests can force the budget-exceeded path. */ closureBudget?: number; /** Extra glob patterns to ignore in addition to defaults */ ignore?: string[]; /** * Fired after each coalesced batch is flushed to disk (signatures + vector). * Lets a host — e.g. the `openlore serve` daemon — schedule heavier work, such * as a debounced full call-graph re-analyze, off the watcher's own lane. The * watcher deliberately excludes the call graph (too expensive synchronously), * so this is the seam where continuous call-graph freshness is layered on. */ onBatchFlushed?: (changedAbsPaths: string[]) => void; /** * Call-graph freshness without the commit hook (change: make-index-self-healing). * Fired — debounced and coalesced — when the graph has fallen behind in a way an * incremental patch cannot repair: a `.git` HEAD ref change (branch switch / pull) * or a stale region that crossed the incremental work budget. A host that already * owns a rebuild coordinator (the `serve` daemon) wires this to its coordinator so * the two rebuild paths coalesce. When provided, the watcher delegates the rebuild * to this callback and does NOT spawn one itself. */ onGraphStale?: (reason: GraphStaleReason) => void; /** * When true AND no `onGraphStale` host handler is provided, the watcher itself * spawns the debounced, coalesced background `analyze --force` on a graph-stale * trigger (a repeatable singleflight, distinct from the once-per-process schema- * reset heal). Set by the in-process MCP watcher, which — unlike `serve` — has no * rebuild coordinator of its own, so its graph would otherwise age with every * branch switch. Default false: the plain signatures-only watcher is unchanged. */ selfRebuild?: boolean; } /** Why the call graph fell behind in a way only a full rebuild can repair. */ export type GraphStaleReason = 'head-change' | 'stale-region'; /** * True if a root-relative path should never be watched. Evaluated as a cheap * segment scan before any FD is opened, so it stays allocation-light. A path is * ignored if ANY of its segments is a known build/dependency/VCS directory * name, or it has a test-file suffix. Exported for testing. * * @param relPath path relative to the watch root (forward- or back-slashed) */ export declare function isIgnoredRelPath(relPath: string): boolean; export declare class McpWatcher { private readonly rootPath; private readonly outputPath; private readonly contextPath; private readonly debounceMs; private readonly maxBatchMs; private readonly bulkThreshold; private readonly embedFileCeiling; private readonly closureBudget; private readonly extraIgnore; private readonly debug; private readonly onBatchFlushed?; private readonly onGraphStale?; private readonly selfRebuild; private fsWatcher?; private gitWatcher?; private graphStaleTimer?; private graphStalePendingReason?; private graphRebuildRunning; private graphRebuildPending; private pending; private pendingDeletions; private debounceTimer?; private maxBatchTimer?; private running; private vcsBulkFlag; private embed; private embedDegraded; private embedFiles; private embedNodes; private embedTimer?; private embedRunning; private lastEmbedContext?; constructor(options: McpWatcherOptions); start(): Promise; stop(): Promise; /** * Add a changed path to the pending set and (re)arm a single debounce timer, * plus a one-shot hard ceiling so a continuous stream still flushes. */ private enqueue; /** Queue a file deletion for the next flush (reuses the same debounce). */ private enqueueDeletion; private armFlush; /** A .git ref changed — settle, then flush whatever changed as one bulk batch. */ private onVcsEvent; /** * Drain the pending set into a single batch. Single-flight: if a flush is * already running, leave the new paths in `pending` and reschedule once it * finishes — never interleave two flushes. */ private flush; /** * Re-index a single changed file. Exposed for unit testing without needing a * real file watcher; flushes synchronously so callers observe the update on * disk immediately. Internally this is just a batch of one. */ handleChange(absPath: string): Promise; /** * Process a coalesced batch of changed files as ONE pipeline pass: * • per-file incremental edge update (content-hash skip), all under one open * EdgeStore; * • ONE signature patch + ONE llm-context persist + ONE read-cache handoff; * • ONE vector update (inline when syncFlush, else on the embed lane). */ private handleBatch; /** * Self-heal a schema-reset graph by spawning one detached `analyze --force` * (BM25-only, no network). Runs at most once per process (`backgroundRebuild * Triggered`); a spawn failure logs and falls back to the existing "run * analyze" note rather than retrying — no thundering herd, no loop (B10). */ private scheduleBackgroundRebuild; /** Test-only: drive the graph-stale trigger without a real git/fs event. */ _triggerGraphStaleForTesting(reason: GraphStaleReason): void; /** * Schedule a debounced, coalesced full-graph rebuild after a trigger an * incremental patch cannot repair (change: make-index-self-healing). Rapid * successive triggers (a `git pull` touching many refs) collapse into one * rebuild. No-op unless a host wired `onGraphStale` or `selfRebuild` is set, so * the plain signatures-only watcher is byte-for-byte unchanged. */ private scheduleGraphRebuild; /** * Repeatable singleflight full `analyze --force` (BM25-only, no network) for the * in-process watcher, which has no host rebuild coordinator. Distinct from the * once-per-process schema-reset heal: this must re-fire across a session (every * branch switch), so it coalesces a trigger that arrives mid-rebuild into one * follow-up run rather than latching forever. Never throws. */ private spawnGraphRebuild; /** * True when this watcher writes to the canonical `/.openlore/analysis` * layout that the MCP read handlers cache against. Only then is the shared * in-memory read cache (primeContextCache) the right channel to prime; a custom * `outputPath` (tests / non-standard installs) writes only to disk. */ private get usesStandardLayout(); /** * Load the context the watcher is about to patch. This ALWAYS reads fresh from * disk — never through the shared read cache — because the cache is a read-path * (tool-call) optimization, and patching a possibly-stale cached object could * silently drop signatures written by a concurrent `analyze` between events. * The writer reads ground truth; persistContext then primes the read cache with * the result so the next tool call is still a hit (Step 2a, G1). */ private loadContext; private persistContext; private scheduleEmbed; private runEmbedLane; /** * Row-level vector update for the changed files only (Step 3). Falls back to a * silent no-op when no embedding service and no index are available. */ private updateVectors; /** * Row-level literal-text line update for the changed files. No-op when the * text-line index has not been built. Never throws into the batch loop. */ private updateTextLines; /** * Incrementally patch dependency-graph.json's file→file import edges for the * changed files. `get_file_dependencies` reads that static artifact, so without * this an import edit goes stale until a full `analyze`. O(change): re-resolve * each changed file's imports (reusing the builder's `computeFileImportEdges`, * so resolution can't drift), replace that file's import edges, and recompute * in/out-degree. HTTP- and call-graph-synthesized edges are preserved (the * watcher does not rebuild them). Global metrics (pageRank, betweenness, * clusters) are O(graph) and deliberately left to the next full `analyze`. * No-op when no dependency graph exists. Never throws into the batch loop. */ private updateDependencyGraph; /** * Keep style-fingerprint.json live for the changed (and deleted) files (change: * add-codebase-style-fingerprint). Re-tally each changed file's idioms with the same extractor * the full build uses; splice it into the persisted raw per-file counters; drop deleted/now- * unsupported files; then re-roll-up byLanguage + per-file + regions, reusing the STORED * file→region map (communities are O(graph), refreshed on the next full analyze — a brand-new * file is simply unattributed to a region until then). Best-effort + atomic; never throws into * the batch. No-op when no fingerprint exists yet (a full analyze creates it). */ private updateStyleFingerprint; /** * Keep parse-health.json live for the changed (and deleted) files (change: * add-parse-health-boundary-disclosure). Unlike the style fingerprint (which every supported repo * has), this artifact is ABSENT on a clean repo — so this lane must be able to CREATE it when a * changed file newly degrades, and DELETE it when the last degraded file is repaired or removed. * Re-tally each changed file with the same dispatch the full build uses; a changed file that is * now clean drops its entry. Best-effort + atomic; never throws into the batch. */ private updateParseHealth; /** * Reconcile file DELETIONS across every lane so a removed file leaves no * phantom state: call-graph nodes/edges (incoming and outgoing), signatures, * text-line rows, vector rows, and dependency-graph node + edges. Best-effort; * a failure in one lane does not block the others. */ private handleDeletions; /** * Remove deleted files' nodes and any edge referencing them from * dependency-graph.json, recompute degrees, and persist atomically. */ private removeFromDependencyGraph; /** Bounded count of watched source files; stops early once `cap` is exceeded. */ private countSourceFiles; } /** * Re-parse changedFile + the given callerFiles (the closure the caller already * bounded by the work budget — fix-transitive-incremental-staleness). Returns * fresh edges (all files in the subset) and nodes (changedFile only — callerFiles * nodes are untouched since their function signatures didn't change). * * Exported for unit testing (locks the HTML-blanking node-refresh contract). */ export declare function buildGraphSubset(changedRel: string, changedContent: string, callerFiles: string[], rootDir: string, resolutionNodes?: import('../analyzer/call-graph.js').FunctionNode[]): Promise<{ edges: import('../analyzer/call-graph.js').CallEdge[]; nodes: import('../analyzer/call-graph.js').FunctionNode[]; cfgs: Array<{ functionId: string; filePath: string; cfg: import('../analyzer/cfg.js').FunctionCfg; }>; /** * callerFiles the caller asked to re-resolve but that could NOT be read * (permissions / transient I/O / a lock). The caller must NOT delete-and-empty * these — it preserves their edges and marks them stale instead, so an * unreadable file is never silently emptied-and-asserted-fresh * (fix-transitive-incremental-staleness). */ skipped: string[]; }>; //# sourceMappingURL=mcp-watcher.d.ts.map