@liendev/lien 0.27.0 → 0.28.1

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.
package/dist/index.js CHANGED
@@ -3708,8 +3708,7 @@ import {
3708
3708
  DEFAULT_EMBEDDING_BATCH_SIZE,
3709
3709
  DEFAULT_CHUNK_SIZE,
3710
3710
  DEFAULT_CHUNK_OVERLAP,
3711
- DEFAULT_GIT_POLL_INTERVAL_MS,
3712
- DEFAULT_DEBOUNCE_MS
3711
+ DEFAULT_GIT_POLL_INTERVAL_MS
3713
3712
  } from "@liendev/core";
3714
3713
  async function statusCommand() {
3715
3714
  const rootDir = process.cwd();
@@ -3766,7 +3765,7 @@ async function statusCommand() {
3766
3765
  console.log(chalk3.dim("Git detection:"), chalk3.yellow("Not a git repo"));
3767
3766
  }
3768
3767
  console.log(chalk3.dim("File watching:"), chalk3.green("\u2713 Enabled (default)"));
3769
- console.log(chalk3.dim(" Debounce:"), `${DEFAULT_DEBOUNCE_MS}ms`);
3768
+ console.log(chalk3.dim(" Batch window:"), "500ms (collects rapid changes, force-flush after 5s)");
3770
3769
  console.log(chalk3.dim(" Disable with:"), chalk3.bold("lien serve --no-watch"));
3771
3770
  console.log(chalk3.bold("\nIndexing Settings (defaults):"));
3772
3771
  console.log(chalk3.dim("Concurrency:"), DEFAULT_CONCURRENCY);
@@ -3993,7 +3992,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3993
3992
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3994
3993
  import { createRequire as createRequire2 } from "module";
3995
3994
  import { fileURLToPath as fileURLToPath2 } from "url";
3996
- import { dirname as dirname2, join as join2 } from "path";
3995
+ import { dirname as dirname2, join as join2, resolve } from "path";
3997
3996
  import {
3998
3997
  LocalEmbeddings,
3999
3998
  GitStateTracker,
@@ -4004,18 +4003,35 @@ import {
4004
4003
  isGitRepo as isGitRepo2,
4005
4004
  VERSION_CHECK_INTERVAL_MS,
4006
4005
  DEFAULT_GIT_POLL_INTERVAL_MS as DEFAULT_GIT_POLL_INTERVAL_MS2,
4007
- createVectorDB
4006
+ createVectorDB,
4007
+ computeContentHash,
4008
+ normalizeToRelativePath
4008
4009
  } from "@liendev/core";
4009
4010
 
4010
4011
  // src/watcher/index.ts
4011
4012
  import chokidar from "chokidar";
4012
4013
  import path3 from "path";
4013
- import { detectAllFrameworks, getFrameworkDetector, DEFAULT_DEBOUNCE_MS as DEFAULT_DEBOUNCE_MS2 } from "@liendev/core";
4014
+ import { detectAllFrameworks, getFrameworkDetector } from "@liendev/core";
4014
4015
  var FileWatcher = class {
4015
4016
  watcher = null;
4016
- debounceTimers = /* @__PURE__ */ new Map();
4017
4017
  rootDir;
4018
4018
  onChangeHandler = null;
4019
+ // Batch state for aggregating rapid changes
4020
+ pendingChanges = /* @__PURE__ */ new Map();
4021
+ batchTimer = null;
4022
+ batchInProgress = false;
4023
+ // Track if handler is currently processing a batch
4024
+ BATCH_WINDOW_MS = 500;
4025
+ // Collect changes for 500ms before processing
4026
+ MAX_BATCH_WAIT_MS = 5e3;
4027
+ // Force flush after 5s even if changes keep coming
4028
+ firstChangeTimestamp = null;
4029
+ // Track when batch started
4030
+ // Git watching state
4031
+ gitChangeTimer = null;
4032
+ gitChangeHandler = null;
4033
+ GIT_DEBOUNCE_MS = 1e3;
4034
+ // Git operations touch multiple files
4019
4035
  constructor(rootDir) {
4020
4036
  this.rootDir = rootDir;
4021
4037
  }
@@ -4102,7 +4118,11 @@ var FileWatcher = class {
4102
4118
  return;
4103
4119
  }
4104
4120
  this.watcher.on("add", (filepath) => this.handleChange("add", filepath)).on("change", (filepath) => this.handleChange("change", filepath)).on("unlink", (filepath) => this.handleChange("unlink", filepath)).on("error", (error) => {
4105
- console.error(`[Lien] File watcher error: ${error}`);
4121
+ try {
4122
+ const message = "[FileWatcher] Error: " + (error instanceof Error ? error.stack || error.message : String(error)) + "\n";
4123
+ process.stderr.write(message);
4124
+ } catch {
4125
+ }
4106
4126
  });
4107
4127
  }
4108
4128
  /**
@@ -4114,17 +4134,17 @@ var FileWatcher = class {
4114
4134
  }
4115
4135
  let readyFired = false;
4116
4136
  await Promise.race([
4117
- new Promise((resolve) => {
4137
+ new Promise((resolve2) => {
4118
4138
  const readyHandler = () => {
4119
4139
  readyFired = true;
4120
- resolve();
4140
+ resolve2();
4121
4141
  };
4122
4142
  this.watcher.once("ready", readyHandler);
4123
4143
  }),
4124
- new Promise((resolve) => {
4144
+ new Promise((resolve2) => {
4125
4145
  setTimeout(() => {
4126
4146
  if (!readyFired) {
4127
- resolve();
4147
+ resolve2();
4128
4148
  }
4129
4149
  }, 1e3);
4130
4150
  })
@@ -4146,34 +4166,200 @@ var FileWatcher = class {
4146
4166
  await this.waitForReady();
4147
4167
  }
4148
4168
  /**
4149
- * Handles a file change event with debouncing.
4150
- * Debouncing prevents rapid reindexing when files are saved multiple times quickly.
4169
+ * Enable watching .git directory for git operations.
4170
+ * Call this after start() to enable event-driven git detection.
4171
+ *
4172
+ * @param onGitChange - Callback invoked when git operations detected
4173
+ */
4174
+ watchGit(onGitChange) {
4175
+ if (!this.watcher) {
4176
+ throw new Error("Cannot watch git - watcher not started");
4177
+ }
4178
+ this.gitChangeHandler = onGitChange;
4179
+ this.watcher.add([
4180
+ path3.join(this.rootDir, ".git/HEAD"),
4181
+ path3.join(this.rootDir, ".git/index"),
4182
+ path3.join(this.rootDir, ".git/refs/**"),
4183
+ path3.join(this.rootDir, ".git/MERGE_HEAD"),
4184
+ path3.join(this.rootDir, ".git/REBASE_HEAD"),
4185
+ path3.join(this.rootDir, ".git/CHERRY_PICK_HEAD"),
4186
+ path3.join(this.rootDir, ".git/logs/refs/stash")
4187
+ // git stash operations
4188
+ ]);
4189
+ }
4190
+ /**
4191
+ * Check if a filepath is a git-related change
4192
+ */
4193
+ isGitChange(filepath) {
4194
+ const normalized = filepath.replace(/\\/g, "/");
4195
+ return normalized.includes(".git/");
4196
+ }
4197
+ /**
4198
+ * Handle git-related file changes with debouncing
4199
+ */
4200
+ handleGitChange() {
4201
+ if (this.gitChangeTimer) {
4202
+ clearTimeout(this.gitChangeTimer);
4203
+ }
4204
+ this.gitChangeTimer = setTimeout(async () => {
4205
+ try {
4206
+ await this.gitChangeHandler?.();
4207
+ } catch (error) {
4208
+ }
4209
+ this.gitChangeTimer = null;
4210
+ }, this.GIT_DEBOUNCE_MS);
4211
+ }
4212
+ /**
4213
+ * Handles a file change event with smart batching.
4214
+ * Collects rapid changes across multiple files and processes them together.
4215
+ * Forces flush after MAX_BATCH_WAIT_MS even if changes keep arriving.
4216
+ *
4217
+ * If a batch is currently being processed by an async handler, waits for completion
4218
+ * before starting a new batch to prevent race conditions.
4151
4219
  */
4152
4220
  handleChange(type, filepath) {
4153
- const existingTimer = this.debounceTimers.get(filepath);
4154
- if (existingTimer) {
4155
- clearTimeout(existingTimer);
4156
- }
4157
- const timer = setTimeout(() => {
4158
- this.debounceTimers.delete(filepath);
4159
- if (this.onChangeHandler) {
4160
- const absolutePath = path3.isAbsolute(filepath) ? filepath : path3.join(this.rootDir, filepath);
4161
- try {
4162
- const result = this.onChangeHandler({
4163
- type,
4164
- filepath: absolutePath
4165
- });
4166
- if (result instanceof Promise) {
4167
- result.catch((error) => {
4168
- console.error(`[Lien] Error handling file change: ${error}`);
4169
- });
4170
- }
4171
- } catch (error) {
4172
- console.error(`[Lien] Error handling file change: ${error}`);
4173
- }
4221
+ if (this.gitChangeHandler && this.isGitChange(filepath)) {
4222
+ this.handleGitChange();
4223
+ return;
4224
+ }
4225
+ if (!this.onChangeHandler) {
4226
+ return;
4227
+ }
4228
+ if (this.pendingChanges.size === 0) {
4229
+ this.firstChangeTimestamp = Date.now();
4230
+ }
4231
+ this.pendingChanges.set(filepath, type);
4232
+ const now = Date.now();
4233
+ const elapsed = now - this.firstChangeTimestamp;
4234
+ if (elapsed >= this.MAX_BATCH_WAIT_MS) {
4235
+ if (this.batchTimer) {
4236
+ clearTimeout(this.batchTimer);
4237
+ this.batchTimer = null;
4238
+ }
4239
+ this.flushBatch();
4240
+ return;
4241
+ }
4242
+ if (this.batchTimer) {
4243
+ clearTimeout(this.batchTimer);
4244
+ }
4245
+ if (!this.batchInProgress) {
4246
+ this.batchTimer = setTimeout(() => {
4247
+ this.flushBatch();
4248
+ }, this.BATCH_WINDOW_MS);
4249
+ }
4250
+ }
4251
+ /**
4252
+ * Group pending changes by type and convert to absolute paths.
4253
+ * Returns arrays of added, modified, and deleted files.
4254
+ */
4255
+ groupPendingChanges(changes) {
4256
+ const added = [];
4257
+ const modified = [];
4258
+ const deleted = [];
4259
+ for (const [filepath, type] of changes) {
4260
+ const absolutePath = path3.isAbsolute(filepath) ? filepath : path3.join(this.rootDir, filepath);
4261
+ switch (type) {
4262
+ case "add":
4263
+ added.push(absolutePath);
4264
+ break;
4265
+ case "change":
4266
+ modified.push(absolutePath);
4267
+ break;
4268
+ case "unlink":
4269
+ deleted.push(absolutePath);
4270
+ break;
4271
+ }
4272
+ }
4273
+ return { added, modified, deleted };
4274
+ }
4275
+ /**
4276
+ * Handle completion of async batch handler.
4277
+ * Triggers flush of accumulated changes if any.
4278
+ */
4279
+ handleBatchComplete() {
4280
+ this.batchInProgress = false;
4281
+ if (this.pendingChanges.size > 0 && !this.batchTimer) {
4282
+ this.batchTimer = setTimeout(() => {
4283
+ this.flushBatch();
4284
+ }, this.BATCH_WINDOW_MS);
4285
+ }
4286
+ }
4287
+ /**
4288
+ * Dispatch batch event to handler and track async state.
4289
+ * Caller must ensure at least one of added/modified/deleted is non-empty.
4290
+ */
4291
+ dispatchBatch(added, modified, deleted) {
4292
+ if (!this.onChangeHandler) return;
4293
+ const allFiles = [...added, ...modified];
4294
+ const firstFile = allFiles.length > 0 ? allFiles[0] : deleted[0];
4295
+ if (!firstFile) {
4296
+ return;
4297
+ }
4298
+ try {
4299
+ this.batchInProgress = true;
4300
+ const result = this.onChangeHandler({
4301
+ type: "batch",
4302
+ filepath: firstFile,
4303
+ added,
4304
+ modified,
4305
+ deleted
4306
+ });
4307
+ if (result instanceof Promise) {
4308
+ result.catch(() => {
4309
+ }).finally(() => this.handleBatchComplete());
4310
+ } else {
4311
+ this.handleBatchComplete();
4174
4312
  }
4175
- }, DEFAULT_DEBOUNCE_MS2);
4176
- this.debounceTimers.set(filepath, timer);
4313
+ } catch (error) {
4314
+ this.handleBatchComplete();
4315
+ }
4316
+ }
4317
+ /**
4318
+ * Flush pending changes and dispatch batch event.
4319
+ * Tracks async handler state to prevent race conditions.
4320
+ */
4321
+ flushBatch() {
4322
+ if (this.batchTimer) {
4323
+ clearTimeout(this.batchTimer);
4324
+ this.batchTimer = null;
4325
+ }
4326
+ if (this.pendingChanges.size === 0) return;
4327
+ const changes = new Map(this.pendingChanges);
4328
+ this.pendingChanges.clear();
4329
+ this.firstChangeTimestamp = null;
4330
+ const { added, modified, deleted } = this.groupPendingChanges(changes);
4331
+ if (added.length === 0 && modified.length === 0 && deleted.length === 0) {
4332
+ return;
4333
+ }
4334
+ this.dispatchBatch(added, modified, deleted);
4335
+ }
4336
+ /**
4337
+ * Flush final batch during shutdown.
4338
+ * Handles edge case where watcher is stopped while batch is pending.
4339
+ */
4340
+ async flushFinalBatch(handler) {
4341
+ if (this.pendingChanges.size === 0) return;
4342
+ const changes = new Map(this.pendingChanges);
4343
+ this.pendingChanges.clear();
4344
+ const { added, modified, deleted } = this.groupPendingChanges(changes);
4345
+ if (added.length === 0 && modified.length === 0 && deleted.length === 0) {
4346
+ return;
4347
+ }
4348
+ try {
4349
+ const allFiles = [...added, ...modified];
4350
+ const firstFile = allFiles.length > 0 ? allFiles[0] : deleted[0];
4351
+ if (!firstFile) {
4352
+ return;
4353
+ }
4354
+ await handler({
4355
+ type: "batch",
4356
+ filepath: firstFile,
4357
+ added,
4358
+ modified,
4359
+ deleted
4360
+ });
4361
+ } catch (error) {
4362
+ }
4177
4363
  }
4178
4364
  /**
4179
4365
  * Stops the file watcher and cleans up resources.
@@ -4182,13 +4368,25 @@ var FileWatcher = class {
4182
4368
  if (!this.watcher) {
4183
4369
  return;
4184
4370
  }
4185
- for (const timer of this.debounceTimers.values()) {
4186
- clearTimeout(timer);
4371
+ const handler = this.onChangeHandler;
4372
+ this.onChangeHandler = null;
4373
+ this.gitChangeHandler = null;
4374
+ if (this.gitChangeTimer) {
4375
+ clearTimeout(this.gitChangeTimer);
4376
+ this.gitChangeTimer = null;
4377
+ }
4378
+ while (this.batchInProgress) {
4379
+ await new Promise((resolve2) => setTimeout(resolve2, 50));
4380
+ }
4381
+ if (this.batchTimer) {
4382
+ clearTimeout(this.batchTimer);
4383
+ this.batchTimer = null;
4384
+ }
4385
+ if (handler && this.pendingChanges.size > 0) {
4386
+ await this.flushFinalBatch(handler);
4187
4387
  }
4188
- this.debounceTimers.clear();
4189
4388
  await this.watcher.close();
4190
4389
  this.watcher = null;
4191
- this.onChangeHandler = null;
4192
4390
  }
4193
4391
  /**
4194
4392
  * Gets the list of files currently being watched.
@@ -8277,7 +8475,7 @@ var NEVER = INVALID;
8277
8475
  // src/mcp/schemas/search.schema.ts
8278
8476
  var SemanticSearchSchema = external_exports.object({
8279
8477
  query: external_exports.string().min(3, "Query must be at least 3 characters").max(500, "Query too long (max 500 characters)").describe(
8280
- "Natural language description of what you're looking for.\n\nUse full sentences describing functionality, not exact names.\n\nGood examples:\n - 'handles user authentication'\n - 'validates email format'\n - 'processes payment transactions'\n\nBad examples:\n - 'auth' (too vague)\n - 'validateEmail' (use grep for exact names)"
8478
+ "Natural language description of what you're looking for.\n\nUse full sentences describing functionality, not exact names.\n\nGood examples:\n - 'How does the code handle user authentication?'\n - 'Where are email addresses validated?'\n - 'How are payment transactions processed?'\n\nBad examples:\n - 'auth' (too vague)\n - 'validateEmail' (use grep for exact names)"
8281
8479
  ),
8282
8480
  limit: external_exports.number().int().min(1, "Limit must be at least 1").max(50, "Limit cannot exceed 50").default(5).describe(
8283
8481
  "Number of results to return.\n\nDefault: 5\nIncrease to 10-15 for broad exploration."
@@ -8372,8 +8570,10 @@ var tools = [
8372
8570
  `Search codebase by MEANING, not text. Complements grep - use this for discovery and understanding, grep for exact matches.
8373
8571
 
8374
8572
  Examples:
8375
- - "Where is authentication handled?" \u2192 semantic_search({ query: "handles user authentication" })
8376
- - "How does payment work?" \u2192 semantic_search({ query: "processes payment transactions" })
8573
+ - "Where is authentication handled?" \u2192 semantic_search({ query: "How does the code handle user authentication?" })
8574
+ - "How does payment work?" \u2192 semantic_search({ query: "How are payment transactions processed and validated?" })
8575
+
8576
+ IMPORTANT: Phrase queries as full questions starting with "How", "Where", "What", etc. Full questions produce significantly better relevance than keyword phrases.
8377
8577
 
8378
8578
  Use natural language describing what the code DOES, not function names. For exact string matching, use grep instead.
8379
8579
 
@@ -9579,6 +9779,134 @@ function registerMCPHandlers(server, toolContext, log) {
9579
9779
  });
9580
9780
  }
9581
9781
 
9782
+ // src/mcp/reindex-state-manager.ts
9783
+ function checkForStuckState(inProgress, lastStateChangeTimestamp, activeOperations, pendingFilesCount) {
9784
+ if (!inProgress) return;
9785
+ const STUCK_STATE_THRESHOLD_MS = 5 * 60 * 1e3;
9786
+ const stuckDuration = Date.now() - lastStateChangeTimestamp;
9787
+ if (stuckDuration > STUCK_STATE_THRESHOLD_MS) {
9788
+ console.warn(
9789
+ `[Lien] HEALTH CHECK: Reindex stuck in progress for ${Math.round(stuckDuration / 1e3)}s. This indicates an operation crashed without cleanup. Active operations: ${activeOperations}, Pending files: ${pendingFilesCount}. Consider using resetIfStuck() to recover.`
9790
+ );
9791
+ }
9792
+ }
9793
+ function mergePendingFiles(pendingFiles, newFiles) {
9794
+ const existing = new Set(pendingFiles);
9795
+ for (const file of newFiles) {
9796
+ if (!existing.has(file)) {
9797
+ pendingFiles.push(file);
9798
+ }
9799
+ }
9800
+ }
9801
+ function createReindexStateManager() {
9802
+ let state = {
9803
+ inProgress: false,
9804
+ pendingFiles: [],
9805
+ lastReindexTimestamp: null,
9806
+ lastReindexDurationMs: null
9807
+ };
9808
+ let activeOperations = 0;
9809
+ let lastStateChangeTimestamp = Date.now();
9810
+ return {
9811
+ /**
9812
+ * Get a copy of the current reindex state.
9813
+ * Returns a deep copy to prevent external mutation of nested arrays.
9814
+ */
9815
+ getState: () => {
9816
+ checkForStuckState(
9817
+ state.inProgress,
9818
+ lastStateChangeTimestamp,
9819
+ activeOperations,
9820
+ state.pendingFiles.length
9821
+ );
9822
+ return { ...state, pendingFiles: [...state.pendingFiles] };
9823
+ },
9824
+ /**
9825
+ * Start a new reindex operation.
9826
+ *
9827
+ * **Important**: Silently ignores empty or null file arrays without incrementing
9828
+ * activeOperations. This is intentional - if there's no work to do, no operation
9829
+ * is started. Callers should check for empty arrays before calling if they need
9830
+ * to track "attempted" operations.
9831
+ *
9832
+ * @param files - Array of file paths to reindex. Empty/null arrays are ignored.
9833
+ */
9834
+ startReindex: (files) => {
9835
+ if (!files || files.length === 0) {
9836
+ return;
9837
+ }
9838
+ activeOperations += 1;
9839
+ state.inProgress = true;
9840
+ lastStateChangeTimestamp = Date.now();
9841
+ mergePendingFiles(state.pendingFiles, files);
9842
+ },
9843
+ /**
9844
+ * Mark a reindex operation as complete.
9845
+ *
9846
+ * Logs a warning if called without a matching startReindex.
9847
+ * Only clears state when all concurrent operations finish.
9848
+ *
9849
+ * @param durationMs - Duration of the reindex operation in milliseconds
9850
+ */
9851
+ completeReindex: (durationMs) => {
9852
+ if (activeOperations === 0) {
9853
+ console.warn("[Lien] completeReindex called without matching startReindex");
9854
+ return;
9855
+ }
9856
+ activeOperations -= 1;
9857
+ if (activeOperations === 0) {
9858
+ state.inProgress = false;
9859
+ state.pendingFiles = [];
9860
+ state.lastReindexTimestamp = Date.now();
9861
+ state.lastReindexDurationMs = durationMs;
9862
+ lastStateChangeTimestamp = Date.now();
9863
+ }
9864
+ },
9865
+ /**
9866
+ * Mark a reindex operation as failed.
9867
+ *
9868
+ * Logs a warning if called without a matching startReindex.
9869
+ * Only clears state when all concurrent operations finish/fail.
9870
+ */
9871
+ failReindex: () => {
9872
+ if (activeOperations === 0) {
9873
+ console.warn("[Lien] failReindex called without matching startReindex");
9874
+ return;
9875
+ }
9876
+ activeOperations -= 1;
9877
+ if (activeOperations === 0) {
9878
+ state.inProgress = false;
9879
+ state.pendingFiles = [];
9880
+ lastStateChangeTimestamp = Date.now();
9881
+ }
9882
+ },
9883
+ /**
9884
+ * Manually reset state if it's stuck.
9885
+ *
9886
+ * **WARNING**: Only use this if you're certain operations have crashed without cleanup.
9887
+ * This will forcibly clear the inProgress flag and reset activeOperations counter.
9888
+ *
9889
+ * Use this when getState() health check detects stuck state and you've verified
9890
+ * no legitimate operations are running.
9891
+ *
9892
+ * @returns true if state was reset, false if state was already clean
9893
+ */
9894
+ resetIfStuck: () => {
9895
+ if (state.inProgress && activeOperations > 0) {
9896
+ console.warn(
9897
+ `[Lien] Manually resetting stuck reindex state. Active operations: ${activeOperations}, Pending files: ${state.pendingFiles.length}`
9898
+ );
9899
+ activeOperations = 0;
9900
+ state.inProgress = false;
9901
+ state.pendingFiles = [];
9902
+ lastStateChangeTimestamp = Date.now();
9903
+ return true;
9904
+ }
9905
+ return false;
9906
+ }
9907
+ };
9908
+ }
9909
+
9582
9910
  // src/mcp/server.ts
9583
9911
  var __filename2 = fileURLToPath2(import.meta.url);
9584
9912
  var __dirname2 = dirname2(__filename2);
@@ -9589,6 +9917,9 @@ try {
9589
9917
  } catch {
9590
9918
  packageJson2 = require3(join2(__dirname2, "../../package.json"));
9591
9919
  }
9920
+ function getRootDirFromDbPath(dbPath) {
9921
+ return resolve(dbPath, "../../..");
9922
+ }
9592
9923
  async function initializeDatabase(rootDir, log) {
9593
9924
  const embeddings = new LocalEmbeddings();
9594
9925
  log("Creating vector database...");
@@ -9621,7 +9952,96 @@ async function handleAutoIndexing(vectorDB, rootDir, log) {
9621
9952
  }
9622
9953
  }
9623
9954
  }
9624
- async function setupGitDetection(rootDir, vectorDB, embeddings, verbose, log) {
9955
+ async function handleGitStartup(gitTracker, vectorDB, embeddings, _verbose, log, reindexStateManager) {
9956
+ log("Checking for git changes...");
9957
+ const changedFiles = await gitTracker.initialize();
9958
+ if (changedFiles && changedFiles.length > 0) {
9959
+ const startTime = Date.now();
9960
+ reindexStateManager.startReindex(changedFiles);
9961
+ log(`\u{1F33F} Git changes detected: ${changedFiles.length} files changed`);
9962
+ try {
9963
+ const count = await indexMultipleFiles(changedFiles, vectorDB, embeddings, { verbose: false });
9964
+ const duration = Date.now() - startTime;
9965
+ reindexStateManager.completeReindex(duration);
9966
+ log(`\u2713 Reindexed ${count} files in ${duration}ms`);
9967
+ } catch (error) {
9968
+ reindexStateManager.failReindex();
9969
+ throw error;
9970
+ }
9971
+ } else {
9972
+ log("\u2713 Index is up to date with git state");
9973
+ }
9974
+ }
9975
+ function createGitPollInterval(gitTracker, vectorDB, embeddings, _verbose, log, reindexStateManager) {
9976
+ return setInterval(async () => {
9977
+ try {
9978
+ const changedFiles = await gitTracker.detectChanges();
9979
+ if (changedFiles && changedFiles.length > 0) {
9980
+ const currentState = reindexStateManager.getState();
9981
+ if (currentState.inProgress) {
9982
+ log(
9983
+ `Background reindex already in progress (${currentState.pendingFiles.length} files pending), skipping git poll cycle`,
9984
+ "debug"
9985
+ );
9986
+ return;
9987
+ }
9988
+ const startTime = Date.now();
9989
+ reindexStateManager.startReindex(changedFiles);
9990
+ log(`\u{1F33F} Git change detected: ${changedFiles.length} files changed`);
9991
+ try {
9992
+ const count = await indexMultipleFiles(changedFiles, vectorDB, embeddings, { verbose: false });
9993
+ const duration = Date.now() - startTime;
9994
+ reindexStateManager.completeReindex(duration);
9995
+ log(`\u2713 Background reindex complete: ${count} files in ${duration}ms`);
9996
+ } catch (error) {
9997
+ reindexStateManager.failReindex();
9998
+ log(`Git background reindex failed: ${error}`, "warning");
9999
+ }
10000
+ }
10001
+ } catch (error) {
10002
+ log(`Git detection check failed: ${error}`, "warning");
10003
+ }
10004
+ }, DEFAULT_GIT_POLL_INTERVAL_MS2);
10005
+ }
10006
+ function createGitChangeHandler(gitTracker, vectorDB, embeddings, _verbose, log, reindexStateManager) {
10007
+ let gitReindexInProgress = false;
10008
+ let lastGitReindexTime = 0;
10009
+ const GIT_REINDEX_COOLDOWN_MS = 5e3;
10010
+ return async () => {
10011
+ const { inProgress: globalInProgress } = reindexStateManager.getState();
10012
+ if (gitReindexInProgress || globalInProgress) {
10013
+ log("Git reindex already in progress, skipping", "debug");
10014
+ return;
10015
+ }
10016
+ const timeSinceLastReindex = Date.now() - lastGitReindexTime;
10017
+ if (timeSinceLastReindex < GIT_REINDEX_COOLDOWN_MS) {
10018
+ log(`Git change ignored (cooldown: ${GIT_REINDEX_COOLDOWN_MS - timeSinceLastReindex}ms remaining)`, "debug");
10019
+ return;
10020
+ }
10021
+ log("\u{1F33F} Git change detected (event-driven)");
10022
+ const changedFiles = await gitTracker.detectChanges();
10023
+ if (!changedFiles || changedFiles.length === 0) {
10024
+ return;
10025
+ }
10026
+ gitReindexInProgress = true;
10027
+ const startTime = Date.now();
10028
+ reindexStateManager.startReindex(changedFiles);
10029
+ log(`Reindexing ${changedFiles.length} files from git change`);
10030
+ try {
10031
+ const count = await indexMultipleFiles(changedFiles, vectorDB, embeddings, { verbose: false });
10032
+ const duration = Date.now() - startTime;
10033
+ reindexStateManager.completeReindex(duration);
10034
+ log(`\u2713 Reindexed ${count} files in ${duration}ms`);
10035
+ lastGitReindexTime = Date.now();
10036
+ } catch (error) {
10037
+ reindexStateManager.failReindex();
10038
+ log(`Git reindex failed: ${error}`, "warning");
10039
+ } finally {
10040
+ gitReindexInProgress = false;
10041
+ }
10042
+ };
10043
+ }
10044
+ async function setupGitDetection(rootDir, vectorDB, embeddings, verbose, log, reindexStateManager, fileWatcher) {
9625
10045
  const gitAvailable = await isGitAvailable();
9626
10046
  const isRepo = await isGitRepo2(rootDir);
9627
10047
  if (!gitAvailable) {
@@ -9635,34 +10055,204 @@ async function setupGitDetection(rootDir, vectorDB, embeddings, verbose, log) {
9635
10055
  log("\u2713 Detected git repository");
9636
10056
  const gitTracker = new GitStateTracker(rootDir, vectorDB.dbPath);
9637
10057
  try {
9638
- log("Checking for git changes...");
9639
- const changedFiles = await gitTracker.initialize();
9640
- if (changedFiles && changedFiles.length > 0) {
9641
- log(`\u{1F33F} Git changes detected: ${changedFiles.length} files changed`);
9642
- const count = await indexMultipleFiles(changedFiles, vectorDB, embeddings, { verbose });
9643
- log(`\u2713 Reindexed ${count} files`);
9644
- } else {
9645
- log("\u2713 Index is up to date with git state");
9646
- }
10058
+ await handleGitStartup(gitTracker, vectorDB, embeddings, verbose, log, reindexStateManager);
9647
10059
  } catch (error) {
9648
10060
  log(`Failed to check git state on startup: ${error}`, "warning");
9649
10061
  }
10062
+ if (fileWatcher) {
10063
+ const gitChangeHandler = createGitChangeHandler(
10064
+ gitTracker,
10065
+ vectorDB,
10066
+ embeddings,
10067
+ verbose,
10068
+ log,
10069
+ reindexStateManager
10070
+ );
10071
+ fileWatcher.watchGit(gitChangeHandler);
10072
+ log("\u2713 Git detection enabled (event-driven via file watcher)");
10073
+ return { gitTracker, gitPollInterval: null };
10074
+ }
9650
10075
  const pollIntervalSeconds = DEFAULT_GIT_POLL_INTERVAL_MS2 / 1e3;
9651
- log(`\u2713 Git detection enabled (checking every ${pollIntervalSeconds}s)`);
9652
- const gitPollInterval = setInterval(async () => {
10076
+ log(`\u2713 Git detection enabled (polling fallback every ${pollIntervalSeconds}s)`);
10077
+ const gitPollInterval = createGitPollInterval(gitTracker, vectorDB, embeddings, verbose, log, reindexStateManager);
10078
+ return { gitTracker, gitPollInterval };
10079
+ }
10080
+ async function handleFileDeletion(filepath, vectorDB, log) {
10081
+ log(`\u{1F5D1}\uFE0F File deleted: ${filepath}`);
10082
+ const manifest = new ManifestManager(vectorDB.dbPath);
10083
+ try {
10084
+ await vectorDB.deleteByFile(filepath);
10085
+ await manifest.removeFile(filepath);
10086
+ log(`\u2713 Removed ${filepath} from index`);
10087
+ } catch (error) {
10088
+ log(`Failed to remove ${filepath}: ${error}`, "warning");
10089
+ throw error;
10090
+ }
10091
+ }
10092
+ async function handleSingleFileChange(filepath, type, vectorDB, embeddings, _verbose, log, reindexStateManager) {
10093
+ const action = type === "add" ? "added" : "changed";
10094
+ const rootDir = getRootDirFromDbPath(vectorDB.dbPath);
10095
+ if (type === "change") {
10096
+ const manifest = new ManifestManager(vectorDB.dbPath);
10097
+ const normalizedPath = normalizeToRelativePath(filepath, rootDir);
9653
10098
  try {
9654
- const changedFiles = await gitTracker.detectChanges();
9655
- if (changedFiles && changedFiles.length > 0) {
9656
- log(`\u{1F33F} Git change detected: ${changedFiles.length} files changed`);
9657
- indexMultipleFiles(changedFiles, vectorDB, embeddings, { verbose }).then((count) => log(`\u2713 Background reindex complete: ${count} files`)).catch((error) => log(`Background reindex failed: ${error}`, "warning"));
10099
+ const existingEntry = await manifest.transaction(async (manifestData) => {
10100
+ return manifestData.files[normalizedPath];
10101
+ });
10102
+ const { shouldReindex, newMtime } = await shouldReindexFile(filepath, existingEntry, log);
10103
+ if (!shouldReindex && newMtime && existingEntry) {
10104
+ const skipReindex = await manifest.transaction(async (manifestData) => {
10105
+ const entry = manifestData.files[normalizedPath];
10106
+ if (entry) {
10107
+ entry.lastModified = newMtime;
10108
+ return true;
10109
+ }
10110
+ return false;
10111
+ });
10112
+ if (skipReindex) {
10113
+ return;
10114
+ }
9658
10115
  }
9659
10116
  } catch (error) {
9660
- log(`Git detection check failed: ${error}`, "warning");
10117
+ log(`Content hash check failed, will reindex: ${error}`, "warning");
9661
10118
  }
9662
- }, DEFAULT_GIT_POLL_INTERVAL_MS2);
9663
- return { gitTracker, gitPollInterval };
10119
+ }
10120
+ const startTime = Date.now();
10121
+ reindexStateManager.startReindex([filepath]);
10122
+ log(`\u{1F4DD} File ${action}: ${filepath}`);
10123
+ try {
10124
+ await indexSingleFile(filepath, vectorDB, embeddings, { verbose: false, rootDir });
10125
+ const duration = Date.now() - startTime;
10126
+ reindexStateManager.completeReindex(duration);
10127
+ } catch (error) {
10128
+ reindexStateManager.failReindex();
10129
+ log(`Failed to reindex ${filepath}: ${error}`, "warning");
10130
+ }
10131
+ }
10132
+ async function shouldReindexFile(filepath, existingEntry, log) {
10133
+ if (!existingEntry?.contentHash) {
10134
+ return { shouldReindex: true };
10135
+ }
10136
+ const currentHash = await computeContentHash(filepath);
10137
+ if (currentHash && currentHash === existingEntry.contentHash) {
10138
+ log(`\u23ED\uFE0F File mtime changed but content unchanged: ${filepath}`, "debug");
10139
+ try {
10140
+ const fs5 = await import("fs/promises");
10141
+ const stats = await fs5.stat(filepath);
10142
+ return { shouldReindex: false, newMtime: stats.mtimeMs };
10143
+ } catch {
10144
+ return { shouldReindex: true };
10145
+ }
10146
+ }
10147
+ return { shouldReindex: true };
10148
+ }
10149
+ async function filterModifiedFilesByHash(modifiedFiles, vectorDB, log) {
10150
+ if (modifiedFiles.length === 0) {
10151
+ return [];
10152
+ }
10153
+ const manifest = new ManifestManager(vectorDB.dbPath);
10154
+ const rootDir = getRootDirFromDbPath(vectorDB.dbPath);
10155
+ const manifestData = await manifest.transaction(async (data) => data);
10156
+ if (!manifestData) {
10157
+ return modifiedFiles;
10158
+ }
10159
+ const checkResults = [];
10160
+ for (const filepath of modifiedFiles) {
10161
+ const normalizedPath = normalizeToRelativePath(filepath, rootDir);
10162
+ const existingEntry = manifestData.files[normalizedPath];
10163
+ const { shouldReindex, newMtime } = await shouldReindexFile(filepath, existingEntry, log);
10164
+ checkResults.push({
10165
+ filepath,
10166
+ normalizedPath,
10167
+ shouldReindex,
10168
+ newMtime
10169
+ });
10170
+ }
10171
+ await manifest.transaction(async (data) => {
10172
+ for (const result of checkResults) {
10173
+ if (!result.shouldReindex && result.newMtime) {
10174
+ const entry = data.files[result.normalizedPath];
10175
+ if (entry) {
10176
+ entry.lastModified = result.newMtime;
10177
+ }
10178
+ }
10179
+ }
10180
+ return null;
10181
+ });
10182
+ return checkResults.filter((r) => r.shouldReindex).map((r) => r.filepath);
10183
+ }
10184
+ async function prepareFilesForReindexing(event, vectorDB, log) {
10185
+ const addedFiles = event.added || [];
10186
+ const modifiedFiles = event.modified || [];
10187
+ const deletedFiles = event.deleted || [];
10188
+ let modifiedFilesToReindex = [];
10189
+ try {
10190
+ modifiedFilesToReindex = await filterModifiedFilesByHash(modifiedFiles, vectorDB, log);
10191
+ } catch (error) {
10192
+ log(`Hash-based filtering failed, will reindex all modified files: ${error}`, "warning");
10193
+ modifiedFilesToReindex = modifiedFiles;
10194
+ }
10195
+ const filesToIndex = [...addedFiles, ...modifiedFilesToReindex];
10196
+ return { filesToIndex, deletedFiles };
10197
+ }
10198
+ async function executeReindexOperations(filesToIndex, deletedFiles, vectorDB, embeddings, log) {
10199
+ const operations = [];
10200
+ if (filesToIndex.length > 0) {
10201
+ log(`\u{1F4C1} ${filesToIndex.length} file(s) changed, reindexing...`);
10202
+ operations.push(indexMultipleFiles(filesToIndex, vectorDB, embeddings, { verbose: false }));
10203
+ }
10204
+ if (deletedFiles.length > 0) {
10205
+ operations.push(
10206
+ Promise.all(
10207
+ deletedFiles.map((deleted) => handleFileDeletion(deleted, vectorDB, log))
10208
+ )
10209
+ );
10210
+ }
10211
+ await Promise.all(operations);
10212
+ }
10213
+ async function handleBatchEvent(event, vectorDB, embeddings, _verbose, log, reindexStateManager) {
10214
+ const { filesToIndex, deletedFiles } = await prepareFilesForReindexing(event, vectorDB, log);
10215
+ const allFiles = [...filesToIndex, ...deletedFiles];
10216
+ if (allFiles.length === 0) {
10217
+ return;
10218
+ }
10219
+ const startTime = Date.now();
10220
+ reindexStateManager.startReindex(allFiles);
10221
+ try {
10222
+ await executeReindexOperations(filesToIndex, deletedFiles, vectorDB, embeddings, log);
10223
+ const duration = Date.now() - startTime;
10224
+ reindexStateManager.completeReindex(duration);
10225
+ log(`\u2713 Processed ${filesToIndex.length} file(s) + ${deletedFiles.length} deletion(s) in ${duration}ms`);
10226
+ } catch (error) {
10227
+ reindexStateManager.failReindex();
10228
+ log(`Batch reindex failed: ${error}`, "warning");
10229
+ }
10230
+ }
10231
+ async function handleUnlinkEvent(filepath, vectorDB, log, reindexStateManager) {
10232
+ const startTime = Date.now();
10233
+ reindexStateManager.startReindex([filepath]);
10234
+ try {
10235
+ await handleFileDeletion(filepath, vectorDB, log);
10236
+ const duration = Date.now() - startTime;
10237
+ reindexStateManager.completeReindex(duration);
10238
+ } catch (error) {
10239
+ reindexStateManager.failReindex();
10240
+ log(`Failed to process deletion for ${filepath}: ${error}`, "warning");
10241
+ }
10242
+ }
10243
+ function createFileChangeHandler(vectorDB, embeddings, verbose, log, reindexStateManager) {
10244
+ return async (event) => {
10245
+ const { type } = event;
10246
+ if (type === "batch") {
10247
+ await handleBatchEvent(event, vectorDB, embeddings, verbose, log, reindexStateManager);
10248
+ } else if (type === "unlink") {
10249
+ await handleUnlinkEvent(event.filepath, vectorDB, log, reindexStateManager);
10250
+ } else {
10251
+ await handleSingleFileChange(event.filepath, type, vectorDB, embeddings, verbose, log, reindexStateManager);
10252
+ }
10253
+ };
9664
10254
  }
9665
- async function setupFileWatching(watch, rootDir, vectorDB, embeddings, verbose, log) {
10255
+ async function setupFileWatching(watch, rootDir, vectorDB, embeddings, verbose, log, reindexStateManager) {
9666
10256
  const fileWatchingEnabled = watch !== void 0 ? watch : true;
9667
10257
  if (!fileWatchingEnabled) {
9668
10258
  return null;
@@ -9670,24 +10260,8 @@ async function setupFileWatching(watch, rootDir, vectorDB, embeddings, verbose,
9670
10260
  log("\u{1F440} Starting file watcher...");
9671
10261
  const fileWatcher = new FileWatcher(rootDir);
9672
10262
  try {
9673
- await fileWatcher.start(async (event) => {
9674
- const { type, filepath } = event;
9675
- if (type === "unlink") {
9676
- log(`\u{1F5D1}\uFE0F File deleted: ${filepath}`);
9677
- try {
9678
- await vectorDB.deleteByFile(filepath);
9679
- const manifest = new ManifestManager(vectorDB.dbPath);
9680
- await manifest.removeFile(filepath);
9681
- log(`\u2713 Removed ${filepath} from index`);
9682
- } catch (error) {
9683
- log(`Failed to remove ${filepath}: ${error}`, "warning");
9684
- }
9685
- } else {
9686
- const action = type === "add" ? "added" : "changed";
9687
- log(`\u{1F4DD} File ${action}: ${filepath}`);
9688
- indexSingleFile(filepath, vectorDB, embeddings, { verbose }).catch((error) => log(`Failed to reindex ${filepath}: ${error}`, "warning"));
9689
- }
9690
- });
10263
+ const handler = createFileChangeHandler(vectorDB, embeddings, verbose, log, reindexStateManager);
10264
+ await fileWatcher.start(handler);
9691
10265
  log(`\u2713 File watching enabled (watching ${fileWatcher.getWatchedFiles().length} files)`);
9692
10266
  return fileWatcher;
9693
10267
  } catch (error) {
@@ -9714,7 +10288,7 @@ function setupCleanupHandlers(versionCheckInterval, gitPollInterval, fileWatcher
9714
10288
  process.exit(0);
9715
10289
  };
9716
10290
  }
9717
- function setupVersionChecking(vectorDB, log) {
10291
+ function setupVersionChecking(vectorDB, log, reindexStateManager) {
9718
10292
  const checkAndReconnect = async () => {
9719
10293
  try {
9720
10294
  if (await vectorDB.checkVersion()) {
@@ -9725,10 +10299,19 @@ function setupVersionChecking(vectorDB, log) {
9725
10299
  log(`Version check failed: ${error}`, "warning");
9726
10300
  }
9727
10301
  };
9728
- const getIndexMetadata = () => ({
9729
- indexVersion: vectorDB.getCurrentVersion(),
9730
- indexDate: vectorDB.getVersionDate()
9731
- });
10302
+ const getIndexMetadata = () => {
10303
+ const reindex = reindexStateManager.getState();
10304
+ return {
10305
+ indexVersion: vectorDB.getCurrentVersion(),
10306
+ indexDate: vectorDB.getVersionDate(),
10307
+ reindexInProgress: reindex.inProgress,
10308
+ pendingFileCount: reindex.pendingFiles.length,
10309
+ lastReindexDurationMs: reindex.lastReindexDurationMs,
10310
+ // Note: msSinceLastReindex is computed at call time, not cached.
10311
+ // This ensures AI assistants always get current freshness info.
10312
+ msSinceLastReindex: reindex.lastReindexTimestamp ? Date.now() - reindex.lastReindexTimestamp : null
10313
+ };
10314
+ };
9732
10315
  const interval = setInterval(checkAndReconnect, VERSION_CHECK_INTERVAL_MS);
9733
10316
  return { interval, checkAndReconnect, getIndexMetadata };
9734
10317
  }
@@ -9774,13 +10357,13 @@ function createMCPServer() {
9774
10357
  { capabilities: serverConfig.capabilities }
9775
10358
  );
9776
10359
  }
9777
- async function setupAndConnectServer(server, toolContext, log, versionCheckInterval, options) {
10360
+ async function setupAndConnectServer(server, toolContext, log, versionCheckInterval, reindexStateManager, options) {
9778
10361
  const { rootDir, verbose, watch } = options;
9779
10362
  const { vectorDB, embeddings } = toolContext;
9780
10363
  registerMCPHandlers(server, toolContext, log);
9781
10364
  await handleAutoIndexing(vectorDB, rootDir, log);
9782
- const { gitPollInterval } = await setupGitDetection(rootDir, vectorDB, embeddings, verbose, log);
9783
- const fileWatcher = await setupFileWatching(watch, rootDir, vectorDB, embeddings, verbose, log);
10365
+ const fileWatcher = await setupFileWatching(watch, rootDir, vectorDB, embeddings, verbose, log, reindexStateManager);
10366
+ const { gitPollInterval } = await setupGitDetection(rootDir, vectorDB, embeddings, verbose, log, reindexStateManager, fileWatcher);
9784
10367
  const cleanup = setupCleanupHandlers(versionCheckInterval, gitPollInterval, fileWatcher, log);
9785
10368
  process.on("SIGINT", cleanup);
9786
10369
  process.on("SIGTERM", cleanup);
@@ -9803,9 +10386,18 @@ async function startMCPServer(options) {
9803
10386
  const { embeddings, vectorDB } = await initializeComponents(rootDir, earlyLog);
9804
10387
  const server = createMCPServer();
9805
10388
  const log = createMCPLog(server, verbose);
9806
- const { interval: versionCheckInterval, checkAndReconnect, getIndexMetadata } = setupVersionChecking(vectorDB, log);
9807
- const toolContext = { vectorDB, embeddings, rootDir, log, checkAndReconnect, getIndexMetadata };
9808
- await setupAndConnectServer(server, toolContext, log, versionCheckInterval, { rootDir, verbose, watch });
10389
+ const reindexStateManager = createReindexStateManager();
10390
+ const { interval: versionCheckInterval, checkAndReconnect, getIndexMetadata } = setupVersionChecking(vectorDB, log, reindexStateManager);
10391
+ const toolContext = {
10392
+ vectorDB,
10393
+ embeddings,
10394
+ rootDir,
10395
+ log,
10396
+ checkAndReconnect,
10397
+ getIndexMetadata,
10398
+ getReindexState: () => reindexStateManager.getState()
10399
+ };
10400
+ await setupAndConnectServer(server, toolContext, log, versionCheckInterval, reindexStateManager, { rootDir, verbose, watch });
9809
10401
  }
9810
10402
 
9811
10403
  // src/cli/serve.ts