@karmaniverous/jeeves-watcher 0.18.7 → 0.18.8

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.
@@ -27,6 +27,7 @@ import { capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radas
27
27
  import rehypeParse from 'rehype-parse';
28
28
  import { unified } from 'unified';
29
29
  import yaml from 'js-yaml';
30
+ import 'ignore';
30
31
  import chokidar from 'chokidar';
31
32
  import Fastify from 'fastify';
32
33
  import crypto from 'crypto';
@@ -39,7 +40,6 @@ import mammoth from 'mammoth';
39
40
  import { MarkdownTextSplitter, RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
40
41
  import { QdrantClient } from '@qdrant/js-client-rest';
41
42
  import 'cosmiconfig';
42
- import 'ignore';
43
43
 
44
44
  /**
45
45
  * @module api/InitialScanTracker
@@ -1029,6 +1029,91 @@ function resolveCommitMessageApiKey(provider, configApiKey, logger) {
1029
1029
  return undefined;
1030
1030
  }
1031
1031
 
1032
+ /**
1033
+ * @module vcs/CommitMessageBuilder
1034
+ * Builds commit messages — both template-based and AI-generated.
1035
+ */
1036
+ /**
1037
+ * Builds commit messages for VCS commits.
1038
+ * Supports template-based messages and AI-generated messages with fallback.
1039
+ */
1040
+ class CommitMessageBuilder {
1041
+ rootPath;
1042
+ logger;
1043
+ generator;
1044
+ constructor(rootPath, logger, generator) {
1045
+ this.rootPath = rootPath;
1046
+ this.logger = logger;
1047
+ this.generator = generator;
1048
+ }
1049
+ /**
1050
+ * Build the template commit message (no AI).
1051
+ */
1052
+ buildTemplateMessage(fileCount, isBaseline, pendingReversions) {
1053
+ if (pendingReversions.length === 0) {
1054
+ if (isBaseline) {
1055
+ return `baseline: batch (${String(fileCount)} files)`;
1056
+ }
1057
+ const timestamp = new Date().toISOString();
1058
+ return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
1059
+ }
1060
+ // Count total reverted files across all pending reversions
1061
+ const revertedPaths = new Set(pendingReversions.flatMap((r) => r.paths));
1062
+ const revertedCount = revertedPaths.size;
1063
+ const otherCount = fileCount - revertedCount;
1064
+ // Use the first reversion's glob and commit for the message
1065
+ const { glob, commit } = pendingReversions[0];
1066
+ const shortCommit = commit.slice(0, 7);
1067
+ let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
1068
+ if (otherCount > 0) {
1069
+ message += ` (+ ${String(otherCount)} other changes)`;
1070
+ }
1071
+ return message;
1072
+ }
1073
+ /**
1074
+ * Build the commit message, using AI when available with template fallback.
1075
+ */
1076
+ async buildCommitMessage(files, isBaseline, pendingReversions) {
1077
+ const fileCount = files.length;
1078
+ const templateMessage = this.buildTemplateMessage(fileCount, isBaseline, pendingReversions);
1079
+ if (!this.generator) {
1080
+ return templateMessage;
1081
+ }
1082
+ try {
1083
+ const diffSummary = await this.getStagedDiff();
1084
+ const aiMessage = await this.generator.generate(files, diffSummary);
1085
+ if (!aiMessage) {
1086
+ this.logger.warn('AI commit message generation returned null; using template');
1087
+ return templateMessage;
1088
+ }
1089
+ // For reversions: prefix with revert info + AI description
1090
+ if (pendingReversions.length > 0) {
1091
+ const { glob, commit } = pendingReversions[0];
1092
+ const shortCommit = commit.slice(0, 7);
1093
+ return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
1094
+ }
1095
+ return aiMessage;
1096
+ }
1097
+ catch (error) {
1098
+ this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
1099
+ return templateMessage;
1100
+ }
1101
+ }
1102
+ /**
1103
+ * Get the staged diff for AI context.
1104
+ */
1105
+ async getStagedDiff() {
1106
+ try {
1107
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
1108
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
1109
+ return `${stat}\n${patch}`;
1110
+ }
1111
+ catch {
1112
+ return '';
1113
+ }
1114
+ }
1115
+ }
1116
+
1032
1117
  /**
1033
1118
  * @module vcs/SquashManager
1034
1119
  * Handles scheduled squash retention for git-backed VCS roots.
@@ -1323,6 +1408,50 @@ class SquashManager {
1323
1408
  }
1324
1409
  }
1325
1410
 
1411
+ /**
1412
+ * @module vcs/vcsPush
1413
+ * Push logic for VCS commits.
1414
+ */
1415
+ /**
1416
+ * Push to the configured remote if remoteUrl is set.
1417
+ *
1418
+ * On failure: logs the error, appends to pushErrors, and returns undefined.
1419
+ * Never throws.
1420
+ *
1421
+ * @param rootPath - Absolute path of the git repository.
1422
+ * @param remoteUrl - Remote URL to push to, or undefined to skip.
1423
+ * @param accessToken - Optional token for HTTPS authentication.
1424
+ * @param pushErrors - Mutable array where push errors are recorded.
1425
+ * @param logger - Logger instance.
1426
+ * @returns ISO timestamp of a successful push, or undefined if skipped/failed.
1427
+ */
1428
+ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger) {
1429
+ if (!remoteUrl)
1430
+ return undefined;
1431
+ try {
1432
+ // Build the authenticated URL if a token is available
1433
+ const pushUrl = accessToken
1434
+ ? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
1435
+ : remoteUrl;
1436
+ await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
1437
+ cwd: rootPath,
1438
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1439
+ });
1440
+ const timestamp = new Date().toISOString();
1441
+ logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
1442
+ return timestamp;
1443
+ }
1444
+ catch (error) {
1445
+ const pushError = {
1446
+ timestamp: new Date().toISOString(),
1447
+ message: normalizeError(error).message,
1448
+ };
1449
+ pushErrors.push(pushError);
1450
+ logger.error({ root: rootPath, err: normalizeError(error) }, 'VCS push failed');
1451
+ return undefined;
1452
+ }
1453
+ }
1454
+
1326
1455
  /**
1327
1456
  * @module vcs/VcsManager
1328
1457
  * Per-root VCS manager for git-backed content versioning.
@@ -1343,7 +1472,7 @@ class VcsManager {
1343
1472
  remoteUrl;
1344
1473
  accessToken;
1345
1474
  logger;
1346
- commitMessageGenerator;
1475
+ commitMessageBuilder;
1347
1476
  pending = new Set();
1348
1477
  pendingReversions = [];
1349
1478
  _pushErrors = [];
@@ -1351,15 +1480,15 @@ class VcsManager {
1351
1480
  debounceTimer;
1352
1481
  commitInFlight = Promise.resolve();
1353
1482
  started = false;
1354
- isBaseline = false;
1483
+ isBaseline = true;
1355
1484
  _lastPushTime = null;
1356
1485
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
1357
1486
  this.rootPath = rootPath;
1358
1487
  this.config = config;
1359
1488
  this.logger = logger;
1360
- this.commitMessageGenerator = commitMessageGenerator;
1361
1489
  this.remoteUrl = remoteUrl;
1362
1490
  this.accessToken = accessToken;
1491
+ this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
1363
1492
  if (config.retention) {
1364
1493
  this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
1365
1494
  }
@@ -1371,48 +1500,23 @@ class VcsManager {
1371
1500
  return this._pushErrors;
1372
1501
  }
1373
1502
  /**
1374
- * Begin accepting file changes. Sets up the debounce mechanism.
1375
- * Enqueues a baseline commit for pre-existing files if the repo has no commits.
1503
+ * Begin accepting file changes. Sets the manager to started state and
1504
+ * starts the squash manager if configured.
1505
+ *
1506
+ * Baseline mode (commit prefix `"baseline:"`) remains active until
1507
+ * {@link endBaseline} is called by the coordinator after the initial scan.
1376
1508
  */
1377
- async start() {
1509
+ start() {
1378
1510
  this.started = true;
1379
1511
  this.squashManager?.start();
1380
1512
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
1381
- await this.enqueueBaselineIfNeeded();
1382
1513
  }
1383
1514
  /**
1384
- * If the repo has no commits, enumerate untracked files and feed them
1385
- * through fileChanged() to create a baseline commit.
1515
+ * Signal that the initial filesystem scan is complete.
1516
+ * Clears the baseline flag so subsequent commits use normal "watcher: batch" messages.
1386
1517
  */
1387
- async enqueueBaselineIfNeeded() {
1388
- try {
1389
- await execFileAsync('git', ['rev-parse', 'HEAD'], {
1390
- cwd: this.rootPath,
1391
- });
1392
- // HEAD exists — repo already has commits
1393
- return;
1394
- }
1395
- catch {
1396
- // No commits yet — proceed with baseline
1397
- }
1398
- try {
1399
- const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
1400
- const files = stdout
1401
- .split('\0')
1402
- .map((line) => line.trim())
1403
- .filter((line) => line.length > 0);
1404
- if (files.length === 0)
1405
- return;
1406
- this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
1407
- this.isBaseline = true;
1408
- const absolutePaths = files.map((f) => this.rootPath + '/' + f);
1409
- for (const filePath of absolutePaths) {
1410
- this.fileChanged(filePath);
1411
- }
1412
- }
1413
- catch (error) {
1414
- this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
1415
- }
1518
+ endBaseline() {
1519
+ this.isBaseline = false;
1416
1520
  }
1417
1521
  /**
1418
1522
  * Add a file to the pending set and reset the debounce timer.
@@ -1509,72 +1613,6 @@ class VcsManager {
1509
1613
  void this.flush();
1510
1614
  }, this.config.commitDebounceMs);
1511
1615
  }
1512
- /**
1513
- * Get the cached diff for staged files, for AI context.
1514
- */
1515
- async getStagedDiff() {
1516
- try {
1517
- const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
1518
- const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
1519
- return `${stat}\n${patch}`;
1520
- }
1521
- catch {
1522
- return '';
1523
- }
1524
- }
1525
- /**
1526
- * Build the template commit message (no AI).
1527
- */
1528
- buildTemplateMessage(fileCount) {
1529
- if (this.pendingReversions.length === 0) {
1530
- if (this.isBaseline) {
1531
- return `baseline: batch (${String(fileCount)} files)`;
1532
- }
1533
- const timestamp = new Date().toISOString();
1534
- return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
1535
- }
1536
- // Count total reverted files across all pending reversions
1537
- const revertedPaths = new Set(this.pendingReversions.flatMap((r) => r.paths));
1538
- const revertedCount = revertedPaths.size;
1539
- const otherCount = fileCount - revertedCount;
1540
- // Use the first reversion's glob and commit for the message
1541
- const { glob, commit } = this.pendingReversions[0];
1542
- const shortCommit = commit.slice(0, 7);
1543
- let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
1544
- if (otherCount > 0) {
1545
- message += ` (+ ${String(otherCount)} other changes)`;
1546
- }
1547
- return message;
1548
- }
1549
- /**
1550
- * Build the commit message, using AI when available with template fallback.
1551
- */
1552
- async buildCommitMessage(files) {
1553
- const fileCount = files.length;
1554
- const templateMessage = this.buildTemplateMessage(fileCount);
1555
- if (!this.commitMessageGenerator) {
1556
- return templateMessage;
1557
- }
1558
- try {
1559
- const diffSummary = await this.getStagedDiff();
1560
- const aiMessage = await this.commitMessageGenerator.generate(files, diffSummary);
1561
- if (!aiMessage) {
1562
- this.logger.warn('AI commit message generation returned null; using template');
1563
- return templateMessage;
1564
- }
1565
- // For reversions: prefix with revert info + AI description
1566
- if (this.pendingReversions.length > 0) {
1567
- const { glob, commit } = this.pendingReversions[0];
1568
- const shortCommit = commit.slice(0, 7);
1569
- return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
1570
- }
1571
- return aiMessage;
1572
- }
1573
- catch (error) {
1574
- this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
1575
- return templateMessage;
1576
- }
1577
- }
1578
1616
  /**
1579
1617
  * Commit a batch of files to the git repo with index.lock retry.
1580
1618
  */
@@ -1584,23 +1622,21 @@ class VcsManager {
1584
1622
  try {
1585
1623
  await retry(async (attempt) => {
1586
1624
  await gitAddViaStdin(files, this.rootPath);
1587
- // Build message after staging so getStagedDiff can see the changes
1588
- // Skip AI for baselines and retries — use template directly
1625
+ // Build message after staging so getStagedDiff can see the changes.
1626
+ // Skip AI for baselines and retries — use template directly.
1589
1627
  const message = this.isBaseline || attempt > 1
1590
- ? this.buildTemplateMessage(files.length)
1591
- : await this.buildCommitMessage(files);
1628
+ ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
1629
+ : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
1592
1630
  this.pendingReversions.length = 0;
1593
- // Only clear baseline flag when all baseline files have been committed
1594
- if (this.pending.size === 0) {
1595
- this.isBaseline = false;
1596
- }
1597
1631
  await execFileAsync('git', ['commit', '-m', message], {
1598
1632
  cwd: this.rootPath,
1599
1633
  });
1600
1634
  const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
1601
1635
  const hash = hashOut.trim();
1602
1636
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
1603
- await this.pushIfConfigured();
1637
+ const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
1638
+ if (pushTime)
1639
+ this._lastPushTime = pushTime;
1604
1640
  }, {
1605
1641
  attempts: 4,
1606
1642
  baseDelayMs: 500,
@@ -1618,34 +1654,6 @@ class VcsManager {
1618
1654
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
1619
1655
  }
1620
1656
  }
1621
- /**
1622
- * Push to the configured remote if remoteUrl is set.
1623
- * On failure: logs the error and records it in pushErrors; never throws.
1624
- */
1625
- async pushIfConfigured() {
1626
- if (!this.remoteUrl)
1627
- return;
1628
- try {
1629
- // Build the authenticated URL if a token is available
1630
- const pushUrl = this.accessToken
1631
- ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
1632
- : this.remoteUrl;
1633
- await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
1634
- cwd: this.rootPath,
1635
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1636
- });
1637
- this._lastPushTime = new Date().toISOString();
1638
- this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'VCS push succeeded');
1639
- }
1640
- catch (error) {
1641
- const pushError = {
1642
- timestamp: new Date().toISOString(),
1643
- message: normalizeError(error).message,
1644
- };
1645
- this._pushErrors.push(pushError);
1646
- this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS push failed');
1647
- }
1648
- }
1649
1657
  }
1650
1658
 
1651
1659
  /**
@@ -1701,12 +1709,10 @@ class VcsCoordinator {
1701
1709
  /**
1702
1710
  * Start all VcsManager instances.
1703
1711
  */
1704
- async start() {
1705
- const startPromises = [];
1712
+ start() {
1706
1713
  this.managers.forEach((manager) => {
1707
- startPromises.push(manager.start());
1714
+ manager.start();
1708
1715
  });
1709
- await Promise.all(startPromises);
1710
1716
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
1711
1717
  }
1712
1718
  /**
@@ -1727,6 +1733,21 @@ class VcsCoordinator {
1727
1733
  manager.fileChanged(normalizedPath);
1728
1734
  }
1729
1735
  }
1736
+ /**
1737
+ * Signal that the initial filesystem scan is complete.
1738
+ *
1739
+ * Flushes each manager's debounce buffer before ending baseline mode, ensuring
1740
+ * any files still pending in the VCS debounce are committed with a
1741
+ * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
1742
+ * until all managers have completed their flush.
1743
+ */
1744
+ async onInitialScanComplete() {
1745
+ for (const manager of this.managers.values()) {
1746
+ await manager.flush();
1747
+ manager.endBaseline();
1748
+ }
1749
+ this.logger.debug('VcsCoordinator: initial scan complete, baseline ended');
1750
+ }
1730
1751
  /**
1731
1752
  * Flush all managers and clean up.
1732
1753
  */
@@ -3129,6 +3150,68 @@ async function ensureGitignore(rootPath, alwaysOnEntries = []) {
3129
3150
  }
3130
3151
  }
3131
3152
 
3153
+ /**
3154
+ * @module app/watcherFactory
3155
+ * Factory helpers for creating and rebuilding the filesystem watcher.
3156
+ * Extracted from initialization.ts to follow SRP.
3157
+ */
3158
+ /**
3159
+ * Create file system watcher with gitignore filtering.
3160
+ */
3161
+ function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
3162
+ const respectGitignore = config.watch.respectGitignore ?? true;
3163
+ const gitignoreFilter = respectGitignore
3164
+ ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
3165
+ : undefined;
3166
+ const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
3167
+ maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
3168
+ maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
3169
+ onFatalError: runtimeOptions.onFatalError,
3170
+ gitignoreFilter,
3171
+ initialScanTracker,
3172
+ contentHashCache,
3173
+ onVcsFileChange,
3174
+ onInitialScanComplete,
3175
+ });
3176
+ return { watcher, gitignoreFilter };
3177
+ }
3178
+ /**
3179
+ * Tear down and rebuild the filesystem watcher with new config.
3180
+ * Falls back to the old watcher if the new one fails to start.
3181
+ */
3182
+ async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
3183
+ try {
3184
+ await oldState.watcher.stop();
3185
+ const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
3186
+ newWatcher.start();
3187
+ logger.info('Filesystem watcher rebuilt successfully');
3188
+ return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
3189
+ }
3190
+ catch (error) {
3191
+ logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
3192
+ try {
3193
+ oldState.watcher.start();
3194
+ }
3195
+ catch (restartError) {
3196
+ logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
3197
+ }
3198
+ return oldState;
3199
+ }
3200
+ }
3201
+ /**
3202
+ * Check whether watch-relevant config fields changed between old and new config.
3203
+ */
3204
+ function watchConfigChanged(oldConfig, newConfig) {
3205
+ return (JSON.stringify(oldConfig.watch.paths) !==
3206
+ JSON.stringify(newConfig.watch.paths) ||
3207
+ JSON.stringify(oldConfig.watch.ignored) !==
3208
+ JSON.stringify(newConfig.watch.ignored) ||
3209
+ JSON.stringify(oldConfig.watch.moveDetection) !==
3210
+ JSON.stringify(newConfig.watch.moveDetection) ||
3211
+ (oldConfig.watch.respectGitignore ?? true) !==
3212
+ (newConfig.watch.respectGitignore ?? true));
3213
+ }
3214
+
3132
3215
  /**
3133
3216
  * @module app/initialization
3134
3217
  * Initialization helpers for JeevesWatcher. Extracted to follow SRP.
@@ -3193,25 +3276,6 @@ function createProcessorConfig(config, configDir, customMapLib) {
3193
3276
  globalSchemas: config.schemas,
3194
3277
  };
3195
3278
  }
3196
- /**
3197
- * Create file system watcher with gitignore filtering.
3198
- */
3199
- function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange) {
3200
- const respectGitignore = config.watch.respectGitignore ?? true;
3201
- const gitignoreFilter = respectGitignore
3202
- ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
3203
- : undefined;
3204
- const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
3205
- maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
3206
- maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
3207
- onFatalError: runtimeOptions.onFatalError,
3208
- gitignoreFilter,
3209
- initialScanTracker,
3210
- contentHashCache,
3211
- onVcsFileChange,
3212
- });
3213
- return { watcher, gitignoreFilter };
3214
- }
3215
3279
  /**
3216
3280
  * Introspect all helpers for merged document.
3217
3281
  */
@@ -3227,42 +3291,6 @@ async function introspectHelpers(config, configDir) {
3227
3291
  function getConfigDir(configPath) {
3228
3292
  return configPath ? dirname(configPath) : '.';
3229
3293
  }
3230
- /**
3231
- * Tear down and rebuild the filesystem watcher with new config.
3232
- * Falls back to the old watcher if the new one fails to start.
3233
- */
3234
- async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
3235
- try {
3236
- await oldState.watcher.stop();
3237
- const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
3238
- newWatcher.start();
3239
- logger.info('Filesystem watcher rebuilt successfully');
3240
- return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
3241
- }
3242
- catch (error) {
3243
- logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
3244
- try {
3245
- oldState.watcher.start();
3246
- }
3247
- catch (restartError) {
3248
- logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
3249
- }
3250
- return oldState;
3251
- }
3252
- }
3253
- /**
3254
- * Check whether watch-relevant config fields changed between old and new config.
3255
- */
3256
- function watchConfigChanged(oldConfig, newConfig) {
3257
- return (JSON.stringify(oldConfig.watch.paths) !==
3258
- JSON.stringify(newConfig.watch.paths) ||
3259
- JSON.stringify(oldConfig.watch.ignored) !==
3260
- JSON.stringify(newConfig.watch.ignored) ||
3261
- JSON.stringify(oldConfig.watch.moveDetection) !==
3262
- JSON.stringify(newConfig.watch.moveDetection) ||
3263
- (oldConfig.watch.respectGitignore ?? true) !==
3264
- (newConfig.watch.respectGitignore ?? true));
3265
- }
3266
3294
  /**
3267
3295
  * Resolve package version from nearest package.json.
3268
3296
  */
@@ -7431,6 +7459,37 @@ class MoveCorrelator {
7431
7459
  }
7432
7460
  }
7433
7461
 
7462
+ /**
7463
+ * @module watcher/ScanStats
7464
+ * Tracks initial filesystem scan statistics for diagnostics.
7465
+ */
7466
+ /**
7467
+ * Mutable counter bag updated during the chokidar initial scan.
7468
+ * Extracted from the watcher start() method for clarity.
7469
+ */
7470
+ class ScanStats {
7471
+ total = 0;
7472
+ matched = 0;
7473
+ globRejected = 0;
7474
+ gitignored = 0;
7475
+ byRoot;
7476
+ constructor(roots) {
7477
+ this.byRoot = Object.fromEntries(roots.map((r) => [r, 0]));
7478
+ }
7479
+ /**
7480
+ * Attribute a newly discovered file to its root for per-root diagnostics.
7481
+ */
7482
+ classifyByRoot(path) {
7483
+ const normalized = path.replace(/\\/g, '/').toLowerCase();
7484
+ for (const root of Object.keys(this.byRoot)) {
7485
+ if (normalized.startsWith(`${root}/`) || normalized === root) {
7486
+ this.byRoot[root] = (this.byRoot[root] ?? 0) + 1;
7487
+ break;
7488
+ }
7489
+ }
7490
+ }
7491
+ }
7492
+
7434
7493
  /**
7435
7494
  * @module watcher
7436
7495
  * Filesystem watcher wrapping chokidar. I/O: watches files/directories for add/change/unlink events, enqueues to processing queue.
@@ -7448,6 +7507,7 @@ class FileSystemWatcher {
7448
7507
  initialScanTracker;
7449
7508
  contentHashCache;
7450
7509
  onVcsFileChange;
7510
+ onInitialScanComplete;
7451
7511
  moveCorrelator;
7452
7512
  globMatches;
7453
7513
  watcher;
@@ -7469,6 +7529,7 @@ class FileSystemWatcher {
7469
7529
  this.initialScanTracker = options.initialScanTracker;
7470
7530
  this.contentHashCache = options.contentHashCache;
7471
7531
  this.onVcsFileChange = options.onVcsFileChange;
7532
+ this.onInitialScanComplete = options.onInitialScanComplete;
7472
7533
  this.globMatches = () => true;
7473
7534
  const healthOptions = {
7474
7535
  maxRetries: options.maxRetries,
@@ -7496,24 +7557,9 @@ class FileSystemWatcher {
7496
7557
  ? resolveIgnored(this.config.ignored)
7497
7558
  : undefined;
7498
7559
  // Track initial scan statistics per root for diagnostics.
7499
- const scanStats = {
7500
- total: 0,
7501
- matched: 0,
7502
- globRejected: 0,
7503
- gitignored: 0,
7504
- byRoot: Object.fromEntries(roots.map((r) => [r, 0])),
7505
- };
7560
+ const scanStats = new ScanStats(roots);
7506
7561
  let initialScanComplete = false;
7507
7562
  this.initialScanTracker?.start();
7508
- const classifyByRoot = (path) => {
7509
- const normalized = path.replace(/\\/g, '/').toLowerCase();
7510
- for (const root of roots) {
7511
- if (normalized.startsWith(root + '/') || normalized === root) {
7512
- scanStats.byRoot[root] = (scanStats.byRoot[root] ?? 0) + 1;
7513
- break;
7514
- }
7515
- }
7516
- };
7517
7563
  // Create move correlator if move detection is configured and cache is available.
7518
7564
  const moveConfig = this.config.moveDetection;
7519
7565
  if (moveConfig?.enabled && this.contentHashCache) {
@@ -7546,7 +7592,7 @@ class FileSystemWatcher {
7546
7592
  this.watcher.on('add', (path) => {
7547
7593
  if (!initialScanComplete) {
7548
7594
  scanStats.total++;
7549
- classifyByRoot(path);
7595
+ scanStats.classifyByRoot(path);
7550
7596
  }
7551
7597
  this.handleGitignoreChange(path);
7552
7598
  if (!this.globMatches(path)) {
@@ -7567,8 +7613,7 @@ class FileSystemWatcher {
7567
7613
  this.initialScanTracker?.incrementEnqueued();
7568
7614
  }
7569
7615
  this.logger.debug({ path }, 'File added');
7570
- if (initialScanComplete)
7571
- this.onVcsFileChange?.(path, 'add');
7616
+ this.onVcsFileChange?.(path, 'add');
7572
7617
  if (correlator && initialScanComplete) {
7573
7618
  void correlator.handleAdd(path);
7574
7619
  }
@@ -7607,6 +7652,7 @@ class FileSystemWatcher {
7607
7652
  initialScanComplete = true;
7608
7653
  this.initialScanTracker?.complete();
7609
7654
  this.logger.info({ scanStats }, 'Initial scan complete');
7655
+ void this.onInitialScanComplete?.();
7610
7656
  });
7611
7657
  this.watcher.on('error', (error) => {
7612
7658
  this.logger.error({ err: normalizeError(error) }, 'Watcher error');
@@ -7768,6 +7814,12 @@ class JeevesWatcher {
7768
7814
  * Start the watcher, API server, and all components.
7769
7815
  */
7770
7816
  async start() {
7817
+ await this.initInfrastructure();
7818
+ await this.initPipeline();
7819
+ await this.startServicesAndWatcher();
7820
+ }
7821
+ /** Set up logger, VCS, embedding provider, and vector store. */
7822
+ async initInfrastructure() {
7771
7823
  const logger = this.factories.createLogger(this.config.logging);
7772
7824
  this.logger = logger;
7773
7825
  await initVcs(this.config, logger);
@@ -7777,6 +7829,10 @@ class JeevesWatcher {
7777
7829
  if (this.config.search?.hybrid?.enabled) {
7778
7830
  await vectorStore.ensureTextIndex('chunk_text');
7779
7831
  }
7832
+ }
7833
+ /** Build the processing pipeline: rules, templates, processor, queue, VCS. */
7834
+ async initPipeline() {
7835
+ const logger = this.logger;
7780
7836
  const compiledRules = this.factories.compileRules(this.config.inferenceRules ?? []);
7781
7837
  const configDir = getConfigDir(this.configPath);
7782
7838
  const { templateEngine, customMapLib } = await buildTemplateEngineAndCustomMapLib(this.config, configDir);
@@ -7786,36 +7842,39 @@ class JeevesWatcher {
7786
7842
  this.issuesManager = new IssuesManager(stateDir, logger);
7787
7843
  this.valuesManager = new ValuesManager(stateDir, logger);
7788
7844
  this.enrichmentStore = new EnrichmentStore(stateDir, logger);
7789
- const enrichmentStore = this.enrichmentStore;
7790
7845
  this.contentHashCache = new ContentHashCache();
7791
- const contentHashCache = this.contentHashCache;
7792
- const processor = this.factories.createDocumentProcessor({
7846
+ this.processor = this.factories.createDocumentProcessor({
7793
7847
  config: processorConfig,
7794
- embeddingProvider,
7795
- vectorStore,
7848
+ embeddingProvider: this.embeddingProvider,
7849
+ vectorStore: this.vectorStore,
7796
7850
  compiledRules,
7797
7851
  logger,
7798
7852
  templateEngine,
7799
- enrichmentStore,
7853
+ enrichmentStore: this.enrichmentStore,
7800
7854
  issuesManager: this.issuesManager,
7801
7855
  valuesManager: this.valuesManager,
7802
- contentHashCache,
7856
+ contentHashCache: this.contentHashCache,
7803
7857
  });
7804
- this.processor = processor;
7805
7858
  this.queue = this.factories.createEventQueue({
7806
7859
  debounceMs: this.config.watch.debounceMs ?? 2000,
7807
7860
  concurrency: this.config.embedding.concurrency ?? 5,
7808
7861
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
7809
7862
  });
7810
- const vcsCoordinator = new VcsCoordinator(this.config, logger);
7811
- this.vcsCoordinator = vcsCoordinator;
7812
- const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache, (filePath, event) => {
7863
+ this.vcsCoordinator = new VcsCoordinator(this.config, logger);
7864
+ }
7865
+ /** Wire up watcher and VCS coordinator, then start all services. */
7866
+ async startServicesAndWatcher() {
7867
+ const logger = this.logger;
7868
+ const vcsCoordinator = this.vcsCoordinator;
7869
+ const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, this.processor, logger, this.runtimeOptions, this.initialScanTracker, this.contentHashCache, (filePath, event) => {
7813
7870
  vcsCoordinator.onFileChange(filePath, event);
7871
+ }, () => {
7872
+ void vcsCoordinator.onInitialScanComplete();
7814
7873
  });
7815
7874
  this.watcher = watcher;
7816
7875
  this.gitignoreFilter = gitignoreFilter;
7817
7876
  this.server = await this.startApiServer();
7818
- await vcsCoordinator.start();
7877
+ vcsCoordinator.start();
7819
7878
  this.watcher.start();
7820
7879
  this.startConfigWatch();
7821
7880
  logger.info('jeeves-watcher started');