@karmaniverous/jeeves-watcher 0.18.7 → 0.18.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,12 @@ import { packageDirectorySync } from 'package-directory';
7
7
  import { readFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync } from 'node:fs';
8
8
  import { cosmiconfig } from 'cosmiconfig';
9
9
  import { ZodError } from 'zod';
10
- import { readdir, stat } from 'node:fs/promises';
10
+ import { readdir, stat, access } from 'node:fs/promises';
11
11
  import { parallel } from 'radash';
12
12
  import ignore from 'ignore';
13
13
  import picomatch from 'picomatch';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
14
16
 
15
17
  /**
16
18
  * @module api/handlers/configMerge
@@ -224,7 +226,7 @@ async function startFromConfig(configPath, descriptor) {
224
226
  const config = await loadConfig(resolvedPath);
225
227
  // Dynamic import breaks the circular dependency between this module
226
228
  // and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
227
- const { JeevesWatcher } = await import('./index-CS1wrtes.js');
229
+ const { JeevesWatcher } = await import('./index-xP2XrV4W.js');
228
230
  const app = new JeevesWatcher(config, resolvedPath, descriptor);
229
231
  installShutdownHandlers(() => app.stop());
230
232
  await app.start();
@@ -613,6 +615,77 @@ async function retry(fn, options) {
613
615
  throw lastError;
614
616
  }
615
617
 
618
+ /**
619
+ * @module vcs/gitExec
620
+ * Shared git execution utilities.
621
+ */
622
+ /** Promisified execFile for git commands. */
623
+ const execFileAsync = promisify(execFile);
624
+ /**
625
+ * Stage files via stdin to avoid ENAMETOOLONG on Windows.
626
+ *
627
+ * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
628
+ * through stdin (NUL-delimited) instead of passed as command-line
629
+ * arguments. This sidesteps the Windows CreateProcessW 32 767-char
630
+ * limit that triggers ENAMETOOLONG when batches contain many files
631
+ * with long absolute paths.
632
+ *
633
+ * @param files - Absolute paths of files to stage.
634
+ * @param cwd - Repository root (working directory for git).
635
+ */
636
+ function gitAddViaStdin(files, cwd) {
637
+ return new Promise((resolve, reject) => {
638
+ const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
639
+ if (error)
640
+ reject(error);
641
+ else
642
+ resolve();
643
+ });
644
+ if (!child.stdin) {
645
+ reject(new Error('Failed to open stdin for git add'));
646
+ return;
647
+ }
648
+ // Suppress EPIPE — expected if git exits before we finish writing
649
+ child.stdin.on('error', (err) => {
650
+ if (err.code !== 'EPIPE') {
651
+ reject(err);
652
+ }
653
+ });
654
+ child.stdin.end(files.join('\0'));
655
+ });
656
+ }
657
+ /**
658
+ * Normalize a path for case-insensitive comparison on Windows.
659
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
660
+ *
661
+ * @param p - The path string to normalize.
662
+ * @param platform - The platform to check against (default: `process.platform`).
663
+ * @returns The normalized path.
664
+ */
665
+ function normalizePathCase(p, platform = process.platform) {
666
+ return platform === 'win32' ? p.toLowerCase() : p;
667
+ }
668
+ /**
669
+ * Find the longest-prefix-match root for a normalized path.
670
+ * Roots must be sorted longest-first for correct nested matching.
671
+ *
672
+ * @param roots - Root paths sorted longest-first.
673
+ * @param normalizedPath - Normalized absolute path (forward slashes).
674
+ * @param platform - The platform to check against (default: `process.platform`).
675
+ * @returns The matching root, or undefined.
676
+ */
677
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
678
+ const comparePath = normalizePathCase(normalizedPath, platform);
679
+ for (const root of roots) {
680
+ const compareRoot = normalizePathCase(root, platform);
681
+ if (comparePath === compareRoot ||
682
+ comparePath.startsWith(compareRoot + '/')) {
683
+ return root;
684
+ }
685
+ }
686
+ return undefined;
687
+ }
688
+
616
689
  /**
617
690
  * Best-effort base directory inference for a glob pattern.
618
691
  *
@@ -830,7 +903,7 @@ function groupByRoot(filePaths, watchPaths) {
830
903
  /** Default page size for prune scroll (smaller than search to reduce connection strain). */
831
904
  const PRUNE_SCROLL_PAGE_SIZE = 500;
832
905
  /** Max retry attempts for scroll page failures. */
833
- const SCROLL_RETRY_ATTEMPTS = 3;
906
+ const SCROLL_RETRY_ATTEMPTS = 5;
834
907
  /** Base delay for scroll retry backoff (ms). */
835
908
  const SCROLL_RETRY_BASE_DELAY_MS = 1000;
836
909
  /**
@@ -856,26 +929,42 @@ async function scrollPageWithRetry(vectorStore, cursor, logger) {
856
929
  }
857
930
  throw lastError;
858
931
  }
932
+ /** Concurrency limit for fs.access() disk-existence checks. */
933
+ const FS_ACCESS_CONCURRENCY = 100;
859
934
  /**
860
- * Compute the plan for a prune operation.
861
- * Scrolls all Qdrant points page-by-page with retry/resume,
862
- * checks each file_path against watch scope + gitignore,
863
- * returns the list of orphaned point IDs and the plan.
935
+ * Compute the plan for a prune operation using a filter-first approach.
936
+ *
937
+ * 1. Enumerate all watched files from the filesystem → `watchedFiles` set.
938
+ * 2. Scroll Qdrant points. For each point:
939
+ * - No `file_path` → orphan.
940
+ * - `file_path` not in `watchedFiles` → orphan (out of watch scope).
941
+ * - `file_path` in `watchedFiles` → check disk existence via `fs.access()`.
942
+ * If missing from disk → orphan (deleted file still in index).
943
+ * 3. Batch `fs.access()` checks with bounded concurrency.
864
944
  */
865
945
  async function computePrunePlan(deps) {
866
946
  const { config, vectorStore, gitignoreFilter, logger } = deps;
867
947
  if (!vectorStore) {
868
948
  throw new Error('vectorStore is required for prune scope');
869
949
  }
950
+ // Step 1: Build set of watched files from filesystem
951
+ const isGitignored = createIsGitignored(gitignoreFilter);
952
+ const watchedFileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
953
+ const watchedFiles = new Set(watchedFileList.map((f) => normalizePathCase(normalizeSlashes(f))));
954
+ logger.info({ watchedFileCount: watchedFiles.size }, 'Prune: enumerated watched files from filesystem');
955
+ // Step 2: Scroll Qdrant and classify points
870
956
  const seenFiles = new Set();
871
957
  const orphanedFiles = new Set();
958
+ const diskCheckFiles = new Set();
872
959
  const orphanedIds = [];
960
+ // Map from file_path → point IDs, for points needing disk-existence check
961
+ const diskCheckPointIds = new Map();
873
962
  let totalPoints = 0;
874
963
  let incomplete = false;
875
964
  let cursor;
876
965
  try {
877
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cursor is updated each iteration
878
- while (true) {
966
+ let hasMore = true;
967
+ while (hasMore) {
879
968
  const page = await scrollPageWithRetry(vectorStore, cursor, logger);
880
969
  for (const point of page.points) {
881
970
  totalPoints++;
@@ -884,32 +973,62 @@ async function computePrunePlan(deps) {
884
973
  orphanedIds.push(point.id);
885
974
  continue;
886
975
  }
887
- if (seenFiles.has(filePath)) {
888
- if (orphanedFiles.has(filePath)) {
976
+ const normalized = normalizePathCase(normalizeSlashes(filePath));
977
+ if (seenFiles.has(normalized)) {
978
+ if (orphanedFiles.has(normalized)) {
889
979
  orphanedIds.push(point.id);
890
980
  }
981
+ else if (diskCheckFiles.has(normalized)) {
982
+ // Already queued for disk check — track its ID
983
+ const ids = diskCheckPointIds.get(normalized);
984
+ if (ids)
985
+ ids.push(point.id);
986
+ }
891
987
  continue;
892
988
  }
893
- seenFiles.add(filePath);
894
- const watched = isPathWatched(filePath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
895
- const gitignored = gitignoreFilter
896
- ? gitignoreFilter.isIgnored(filePath)
897
- : false;
898
- if (!watched || gitignored) {
899
- orphanedFiles.add(filePath);
989
+ seenFiles.add(normalized);
990
+ if (!watchedFiles.has(normalized)) {
991
+ // Not in watch scope → definitely orphaned
992
+ orphanedFiles.add(normalized);
900
993
  orphanedIds.push(point.id);
901
994
  }
995
+ else {
996
+ // In watch scope → needs disk-existence check
997
+ diskCheckFiles.add(normalized);
998
+ diskCheckPointIds.set(normalized, [point.id]);
999
+ }
1000
+ }
1001
+ // Progress logging every 100K points
1002
+ if (totalPoints > 0 && totalPoints % 100000 === 0) {
1003
+ logger.info({ totalPoints, orphanedCount: orphanedIds.length }, 'Prune scroll progress');
1004
+ }
1005
+ if (!page.nextCursor) {
1006
+ hasMore = false;
1007
+ }
1008
+ else {
1009
+ cursor = page.nextCursor;
902
1010
  }
903
- if (!page.nextCursor)
904
- break;
905
- cursor = page.nextCursor;
906
1011
  }
907
1012
  }
908
1013
  catch (error) {
909
- // All retries exhausted - return partial results
910
1014
  logger.error({ err: normalizeError(error), totalPoints, cursor }, 'Prune scroll failed after retries; returning partial plan');
911
1015
  incomplete = true;
912
1016
  }
1017
+ // Step 3: Batch fs.access() checks for files in watch scope
1018
+ const filesToCheck = [...diskCheckFiles];
1019
+ await parallel(FS_ACCESS_CONCURRENCY, filesToCheck, async (filePath) => {
1020
+ try {
1021
+ await access(filePath);
1022
+ }
1023
+ catch {
1024
+ // File doesn't exist on disk → orphaned
1025
+ orphanedFiles.add(filePath);
1026
+ const pointIds = diskCheckPointIds.get(filePath);
1027
+ if (pointIds) {
1028
+ orphanedIds.push(...pointIds);
1029
+ }
1030
+ }
1031
+ });
913
1032
  const orphanedFileList = [...orphanedFiles];
914
1033
  const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
915
1034
  return {
@@ -967,6 +1086,12 @@ async function executeReindex(deps, scope, path, dryRun = false) {
967
1086
  deps.queue?.resume();
968
1087
  return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
969
1088
  }
1089
+ // Abort live prune when plan is incomplete — never silently delete partial data
1090
+ if (plan.incomplete) {
1091
+ deps.queue?.resume();
1092
+ logger.error({ totalPoints: plan.total, pointsToDelete: plan.toDelete }, 'Aborting live prune — scroll was incomplete; partial deletes are unsafe');
1093
+ return { filesProcessed: 0, durationMs: 0, errors: 1, plan };
1094
+ }
970
1095
  // Execute prune
971
1096
  reindexTracker?.start(scope);
972
1097
  const startTime = Date.now();
@@ -1456,4 +1581,4 @@ const watcherDescriptor = {
1456
1581
  const program = createServiceCli(watcherDescriptor);
1457
1582
  program.parse();
1458
1583
 
1459
- export { CONFIG_WATCH_VALID_SCOPES as C, GitignoreFilter as G, VALID_REINDEX_SCOPES as V, normalizeSlashes as a, startFromConfig as b, executeReindex as e, getWatchRootBases as g, isPathWatched as i, loadConfig as l, mergeInferenceRules as m, normalizeError as n, retry as r, sleep as s };
1584
+ export { CONFIG_WATCH_VALID_SCOPES as C, GitignoreFilter as G, VALID_REINDEX_SCOPES as V, executeReindex as a, gitAddViaStdin as b, normalizePathCase as c, normalizeSlashes as d, execFileAsync as e, findRootForPath as f, getWatchRootBases as g, startFromConfig as h, isPathWatched as i, loadConfig as l, mergeInferenceRules as m, normalizeError as n, retry as r, sleep as s };
package/dist/index.d.ts CHANGED
@@ -1252,10 +1252,9 @@ declare class CommitMessageGenerator {
1252
1252
  }
1253
1253
 
1254
1254
  /**
1255
- * @module vcs/VcsManager
1256
- * Per-root VCS manager for git-backed content versioning.
1255
+ * @module vcs/types
1256
+ * Shared types for the VCS subsystem.
1257
1257
  */
1258
-
1259
1258
  /** Metadata for a pending reversion to include in the commit message. */
1260
1259
  interface PendingReversion {
1261
1260
  glob: string;
@@ -1267,6 +1266,12 @@ interface PushError {
1267
1266
  timestamp: string;
1268
1267
  message: string;
1269
1268
  }
1269
+
1270
+ /**
1271
+ * @module vcs/VcsManager
1272
+ * Per-root VCS manager for git-backed content versioning.
1273
+ */
1274
+
1270
1275
  /**
1271
1276
  * Per-root VCS manager for git-backed content versioning.
1272
1277
  *
@@ -1283,13 +1288,14 @@ declare class VcsManager {
1283
1288
  readonly remoteUrl: string | undefined;
1284
1289
  private readonly accessToken;
1285
1290
  private readonly logger;
1286
- private readonly commitMessageGenerator;
1291
+ private readonly commitMessageBuilder;
1287
1292
  private readonly pending;
1288
1293
  private readonly pendingReversions;
1289
1294
  private readonly _pushErrors;
1290
1295
  private readonly squashManager;
1291
1296
  private debounceTimer;
1292
1297
  private commitInFlight;
1298
+ private consecutiveCommitFailures;
1293
1299
  private started;
1294
1300
  private isBaseline;
1295
1301
  private _lastPushTime;
@@ -1297,15 +1303,18 @@ declare class VcsManager {
1297
1303
  get lastPushTime(): string | null;
1298
1304
  get pushErrors(): readonly PushError[];
1299
1305
  /**
1300
- * Begin accepting file changes. Sets up the debounce mechanism.
1301
- * Enqueues a baseline commit for pre-existing files if the repo has no commits.
1306
+ * Begin accepting file changes. Sets the manager to started state and
1307
+ * starts the squash manager if configured.
1308
+ *
1309
+ * Baseline mode (commit prefix `"baseline:"`) remains active until
1310
+ * {@link endBaseline} is called by the coordinator after the initial scan.
1302
1311
  */
1303
- start(): Promise<void>;
1312
+ start(): void;
1304
1313
  /**
1305
- * If the repo has no commits, enumerate untracked files and feed them
1306
- * through fileChanged() to create a baseline commit.
1314
+ * Signal that the initial filesystem scan is complete.
1315
+ * Clears the baseline flag so subsequent commits use normal "watcher: batch" messages.
1307
1316
  */
1308
- private enqueueBaselineIfNeeded;
1317
+ endBaseline(): void;
1309
1318
  /**
1310
1319
  * Add a file to the pending set and reset the debounce timer.
1311
1320
  * If the pending set exceeds maxBatchSize, flush immediately.
@@ -1347,26 +1356,14 @@ declare class VcsManager {
1347
1356
  */
1348
1357
  private resetDebounce;
1349
1358
  /**
1350
- * Get the cached diff for staged files, for AI context.
1359
+ * Check for and remove stale index.lock files before retrying.
1351
1360
  */
1352
- private getStagedDiff;
1361
+ private removeStaleLock;
1353
1362
  /**
1354
- * Build the template commit message (no AI).
1355
- */
1356
- private buildTemplateMessage;
1357
- /**
1358
- * Build the commit message, using AI when available with template fallback.
1359
- */
1360
- private buildCommitMessage;
1361
- /**
1362
- * Commit a batch of files to the git repo with index.lock retry.
1363
+ * Commit a batch of files to the git repo with index.lock retry,
1364
+ * stale lock detection, circuit breaker, and re-queue cap.
1363
1365
  */
1364
1366
  private commitBatch;
1365
- /**
1366
- * Push to the configured remote if remoteUrl is set.
1367
- * On failure: logs the error and records it in pushErrors; never throws.
1368
- */
1369
- private pushIfConfigured;
1370
1367
  }
1371
1368
 
1372
1369
  /**
@@ -1386,7 +1383,7 @@ declare class VcsCoordinator {
1386
1383
  /**
1387
1384
  * Start all VcsManager instances.
1388
1385
  */
1389
- start(): Promise<void>;
1386
+ start(): void;
1390
1387
  /**
1391
1388
  * Route a file event to the correct root's VcsManager.
1392
1389
  *
@@ -1394,6 +1391,15 @@ declare class VcsCoordinator {
1394
1391
  * @param event - The event type: add, change, or unlink.
1395
1392
  */
1396
1393
  onFileChange(filePath: string, event: 'add' | 'change' | 'unlink'): void;
1394
+ /**
1395
+ * Signal that the initial filesystem scan is complete.
1396
+ *
1397
+ * Flushes each manager's debounce buffer before ending baseline mode, ensuring
1398
+ * any files still pending in the VCS debounce are committed with a
1399
+ * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
1400
+ * until all managers have completed their flush.
1401
+ */
1402
+ onInitialScanComplete(): Promise<void>;
1397
1403
  /**
1398
1404
  * Flush all managers and clean up.
1399
1405
  */
@@ -1539,6 +1545,8 @@ interface FileSystemWatcherOptions {
1539
1545
  contentHashCache?: ContentHashCache;
1540
1546
  /** Optional callback for VCS file change notifications. */
1541
1547
  onVcsFileChange?: (filePath: string, event: 'add' | 'change' | 'unlink') => void;
1548
+ /** Optional callback invoked when the initial filesystem scan completes. */
1549
+ onInitialScanComplete?: () => void | Promise<void>;
1542
1550
  }
1543
1551
  /**
1544
1552
  * Filesystem watcher that maps chokidar events to the processing queue.
@@ -1553,6 +1561,7 @@ declare class FileSystemWatcher {
1553
1561
  private readonly initialScanTracker?;
1554
1562
  private readonly contentHashCache?;
1555
1563
  private readonly onVcsFileChange?;
1564
+ private readonly onInitialScanComplete?;
1556
1565
  private moveCorrelator?;
1557
1566
  private globMatches;
1558
1567
  private watcher;
@@ -1808,6 +1817,12 @@ declare class JeevesWatcher {
1808
1817
  * Start the watcher, API server, and all components.
1809
1818
  */
1810
1819
  start(): Promise<void>;
1820
+ /** Set up logger, VCS, embedding provider, and vector store. */
1821
+ private initInfrastructure;
1822
+ /** Build the processing pipeline: rules, templates, processor, queue, VCS. */
1823
+ private initPipeline;
1824
+ /** Wire up watcher and VCS coordinator, then start all services. */
1825
+ private startServicesAndWatcher;
1811
1826
  /**
1812
1827
  * Gracefully stop all components.
1813
1828
  */