grepmax 0.24.0 → 0.25.0

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.
Files changed (75) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +11 -6
  3. package/dist/commands/add.js +38 -31
  4. package/dist/commands/config.js +16 -15
  5. package/dist/commands/context.js +38 -13
  6. package/dist/commands/doctor.js +76 -83
  7. package/dist/commands/extract.js +12 -1
  8. package/dist/commands/index.js +23 -24
  9. package/dist/commands/list.js +23 -14
  10. package/dist/commands/mcp.js +278 -143
  11. package/dist/commands/peek.js +16 -5
  12. package/dist/commands/repair.js +54 -120
  13. package/dist/commands/search-output.js +30 -9
  14. package/dist/commands/search-run.js +75 -47
  15. package/dist/commands/search-skeletons.js +28 -18
  16. package/dist/commands/search.js +45 -49
  17. package/dist/commands/serve.js +415 -373
  18. package/dist/commands/setup.js +2 -2
  19. package/dist/commands/similar.js +5 -5
  20. package/dist/commands/skeleton.js +67 -41
  21. package/dist/commands/status.js +5 -2
  22. package/dist/commands/summarize.js +6 -0
  23. package/dist/commands/watch.js +102 -38
  24. package/dist/config.js +3 -0
  25. package/dist/lib/daemon/daemon.js +1101 -379
  26. package/dist/lib/daemon/ipc-handler.js +122 -13
  27. package/dist/lib/daemon/mlx-server-manager.js +268 -125
  28. package/dist/lib/daemon/process-manager.js +7 -4
  29. package/dist/lib/daemon/search-handler.js +23 -9
  30. package/dist/lib/daemon/watcher-manager.js +353 -110
  31. package/dist/lib/index/batch-processor.js +231 -67
  32. package/dist/lib/index/embedding-generation.js +109 -0
  33. package/dist/lib/index/embedding-status.js +23 -0
  34. package/dist/lib/index/file-policy.js +273 -0
  35. package/dist/lib/index/ignore-patterns.js +4 -0
  36. package/dist/lib/index/index-config.js +18 -4
  37. package/dist/lib/index/syncer.js +256 -79
  38. package/dist/lib/index/walker.js +66 -86
  39. package/dist/lib/index/watcher-batch.js +9 -0
  40. package/dist/lib/index/watcher.js +129 -3
  41. package/dist/lib/llm/server.js +6 -0
  42. package/dist/lib/search/searcher.js +30 -19
  43. package/dist/lib/store/store-lease.js +473 -0
  44. package/dist/lib/store/vector-db.js +302 -57
  45. package/dist/lib/utils/blocked-roots.js +63 -0
  46. package/dist/lib/utils/cross-project.js +7 -3
  47. package/dist/lib/utils/daemon-client.js +24 -1
  48. package/dist/lib/utils/daemon-launcher.js +38 -13
  49. package/dist/lib/utils/doctor-status.js +76 -0
  50. package/dist/lib/utils/file-utils.js +74 -4
  51. package/dist/lib/utils/keyed-mutex.js +101 -0
  52. package/dist/lib/utils/logger.js +57 -1
  53. package/dist/lib/utils/mlx-hf-cache.js +114 -0
  54. package/dist/lib/utils/operation-coordinator.js +146 -0
  55. package/dist/lib/utils/path-containment.js +106 -0
  56. package/dist/lib/utils/process.js +44 -0
  57. package/dist/lib/utils/project-registry.js +351 -3
  58. package/dist/lib/utils/scope-filter.js +3 -9
  59. package/dist/lib/utils/stale-hint.js +12 -19
  60. package/dist/lib/utils/watcher-launcher.js +5 -1
  61. package/dist/lib/utils/watcher-store.js +2 -2
  62. package/dist/lib/workers/colbert-math.js +15 -12
  63. package/dist/lib/workers/embeddings/colbert.js +3 -2
  64. package/dist/lib/workers/embeddings/granite.js +4 -3
  65. package/dist/lib/workers/embeddings/mlx-client.js +59 -16
  66. package/dist/lib/workers/orchestrator.js +39 -8
  67. package/dist/lib/workers/pool.js +146 -82
  68. package/dist/lib/workers/process-child.js +11 -2
  69. package/dist/lib/workers/serialized-handler.js +10 -0
  70. package/dist/lib/workers/worker.js +13 -4
  71. package/mlx-embed-server/server.py +21 -3
  72. package/mlx-embed-server/summarizer.py +8 -0
  73. package/package.json +4 -3
  74. package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
  75. package/plugins/grepmax/hooks/start.js +3 -170
@@ -43,36 +43,44 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.ProjectBatchProcessor = void 0;
46
- const fs = __importStar(require("node:fs"));
47
46
  const path = __importStar(require("node:path"));
48
- const config_1 = require("../../config");
49
47
  const vector_db_1 = require("../store/vector-db");
50
48
  const cache_check_1 = require("../utils/cache-check");
51
49
  const file_utils_1 = require("../utils/file-utils");
52
50
  const logger_1 = require("../utils/logger");
53
51
  const pool_1 = require("../workers/pool");
52
+ const file_policy_1 = require("./file-policy");
54
53
  const watcher_batch_1 = require("./watcher-batch");
55
- // Fast path-segment check to reject events that leak through FSEvents overflow.
56
- // Matches /node_modules/, /.git/, /dist/, /build/, /.next/, etc. anywhere in path.
57
- const IGNORED_PATH_SEGMENTS_RE = /\/(?:node_modules|\.git|\.next|\.nuxt|__pycache__|coverage|\.gmax|\.venv|venv|site-packages|dist|build|out|target|vendor|\.tox|\.gradle|\.m2)\//;
58
54
  const DEBOUNCE_MS = 2000;
59
55
  const MAX_RETRIES = 5;
60
56
  const MAX_BATCH_SIZE = 50;
61
57
  class ProjectBatchProcessor {
62
58
  constructor(opts) {
59
+ var _a, _b;
63
60
  this.pending = new Map();
64
61
  this.retryCount = new Map();
62
+ this.retryAt = new Map();
63
+ this.terminalFailures = new Set();
65
64
  this.debounceTimer = null;
65
+ this.debounceDueMs = 0;
66
66
  this.activeBatch = null;
67
67
  this.processing = false;
68
68
  this.closed = false;
69
69
  this.currentBatchAc = null;
70
70
  this.lastCorruptionLogMs = 0;
71
+ this.policyChangedDuringBatch = false;
71
72
  this.projectRoot = opts.projectRoot;
72
73
  this.vectorDb = opts.vectorDb;
73
74
  this.metaCache = opts.metaCache;
75
+ this.workerPool = (_a = opts.workerPool) !== null && _a !== void 0 ? _a : (0, pool_1.getWorkerPool)();
74
76
  this.onReindex = opts.onReindex;
75
77
  this.onActivity = opts.onActivity;
78
+ this.onPolicyChange = opts.onPolicyChange;
79
+ this.onTerminalFailure = opts.onTerminalFailure;
80
+ this.onPathSuccess = opts.onPathSuccess;
81
+ this.runOperation = opts.runOperation;
82
+ this.filePolicy =
83
+ (_b = opts.filePolicy) !== null && _b !== void 0 ? _b : new file_policy_1.ProjectFilePolicy(opts.projectRoot);
76
84
  this.wtag = `watch:${path.basename(opts.projectRoot)}`;
77
85
  const taskTimeoutMs = (() => {
78
86
  var _a;
@@ -82,24 +90,38 @@ class ProjectBatchProcessor {
82
90
  this.batchTimeoutMs = Math.max(Math.ceil(taskTimeoutMs * 1.5), 120000);
83
91
  }
84
92
  handleFileEvent(event, absPath) {
85
- var _a;
93
+ var _a, _b;
86
94
  if (this.closed)
87
95
  return;
88
- const ext = path.extname(absPath).toLowerCase();
89
- const bn = path.basename(absPath).toLowerCase();
90
- if (!config_1.INDEXABLE_EXTENSIONS.has(ext) && !config_1.INDEXABLE_EXTENSIONS.has(bn))
96
+ const normalize = this.filePolicy.normalizeEventPath;
97
+ const normalized = normalize
98
+ ? normalize.call(this.filePolicy, absPath)
99
+ : this.filePolicy.isLexicallyContained(absPath)
100
+ ? path.resolve(absPath)
101
+ : null;
102
+ if (!normalized)
103
+ return;
104
+ if (this.filePolicy.isPolicyFile(normalized)) {
105
+ this.filePolicy.invalidateIgnoreCache();
106
+ if (this.processing)
107
+ this.policyChangedDuringBatch = true;
108
+ (_a = this.onPolicyChange) === null || _a === void 0 ? void 0 : _a.call(this);
91
109
  return;
92
- // Safety net: reject paths with ignored directory segments.
93
- // FSEvents can leak events during overflow before the watcher drops them.
94
- if (IGNORED_PATH_SEGMENTS_RE.test(absPath))
110
+ }
111
+ if (!(0, file_utils_1.isIndexableFile)(normalized, 1) && !this.metaCache.get(normalized))
95
112
  return;
96
- this.pending.set(absPath, event);
97
- (_a = this.onActivity) === null || _a === void 0 ? void 0 : _a.call(this);
113
+ this.retryAt.delete(normalized);
114
+ this.pending.set(normalized, event);
115
+ (_b = this.onActivity) === null || _b === void 0 ? void 0 : _b.call(this);
98
116
  this.scheduleBatch();
99
117
  }
100
118
  /** Live (re)index progress: files queued + whether a batch is running. */
101
119
  get progress() {
102
- return { pendingFiles: this.pending.size, processing: this.processing };
120
+ return {
121
+ pendingFiles: this.pending.size,
122
+ processing: this.processing,
123
+ failedFiles: this.terminalFailures.size,
124
+ };
103
125
  }
104
126
  close() {
105
127
  return __awaiter(this, void 0, void 0, function* () {
@@ -109,24 +131,47 @@ class ProjectBatchProcessor {
109
131
  if (this.debounceTimer) {
110
132
  clearTimeout(this.debounceTimer);
111
133
  this.debounceTimer = null;
134
+ this.debounceDueMs = 0;
112
135
  }
113
136
  if (this.activeBatch) {
114
137
  yield this.activeBatch;
115
138
  }
116
139
  });
117
140
  }
118
- scheduleBatch() {
141
+ scheduleBatch(delayMs = DEBOUNCE_MS) {
142
+ const dueMs = Date.now() + Math.max(0, delayMs);
143
+ if (this.debounceTimer && this.debounceDueMs <= dueMs)
144
+ return;
119
145
  if (this.debounceTimer)
120
146
  clearTimeout(this.debounceTimer);
147
+ this.debounceDueMs = dueMs;
121
148
  this.debounceTimer = setTimeout(() => {
122
149
  this.debounceTimer = null;
150
+ this.debounceDueMs = 0;
123
151
  this.startBatch();
124
- }, DEBOUNCE_MS);
152
+ }, Math.max(0, delayMs));
153
+ }
154
+ schedulePendingBatch(defaultDelayMs = DEBOUNCE_MS) {
155
+ var _a;
156
+ const now = Date.now();
157
+ let earliestRetryMs = Number.POSITIVE_INFINITY;
158
+ for (const absPath of this.pending.keys()) {
159
+ const retryAt = (_a = this.retryAt.get(absPath)) !== null && _a !== void 0 ? _a : 0;
160
+ if (retryAt <= now) {
161
+ this.scheduleBatch(defaultDelayMs);
162
+ return;
163
+ }
164
+ earliestRetryMs = Math.min(earliestRetryMs, retryAt - now);
165
+ }
166
+ this.scheduleBatch(Number.isFinite(earliestRetryMs) ? earliestRetryMs : defaultDelayMs);
125
167
  }
126
168
  startBatch() {
127
169
  if (this.activeBatch)
128
170
  return;
129
- const run = this.processBatch().catch((err) => {
171
+ const execute = (signal) => this.processBatch(signal);
172
+ const run = (this.runOperation
173
+ ? this.runOperation(execute)
174
+ : execute(new AbortController().signal)).catch((err) => {
130
175
  console.error(`[${this.wtag}] Batch processing failed:`, err);
131
176
  });
132
177
  this.activeBatch = run;
@@ -135,9 +180,9 @@ class ProjectBatchProcessor {
135
180
  this.activeBatch = null;
136
181
  });
137
182
  }
138
- processBatch() {
183
+ processBatch(operationSignal) {
139
184
  return __awaiter(this, void 0, void 0, function* () {
140
- var _a;
185
+ var _a, _b, _c, _d, _e;
141
186
  if (this.closed || this.processing || this.pending.size === 0)
142
187
  return;
143
188
  // Circuit breaker: don't attempt writes when disk is critically low
@@ -145,29 +190,44 @@ class ProjectBatchProcessor {
145
190
  (0, logger_1.log)(this.wtag, "Disk critically low — deferring batch processing");
146
191
  if (this.debounceTimer)
147
192
  clearTimeout(this.debounceTimer);
193
+ this.debounceDueMs = Date.now() + 60000;
148
194
  this.debounceTimer = setTimeout(() => {
149
195
  this.debounceTimer = null;
196
+ this.debounceDueMs = 0;
150
197
  this.startBatch();
151
198
  }, 60000);
152
199
  return;
153
200
  }
154
- this.processing = true;
155
- const batchAc = new AbortController();
156
- this.currentBatchAc = batchAc;
157
- const batchTimeout = setTimeout(() => {
158
- (0, logger_1.log)(this.wtag, `Batch timed out after ${this.batchTimeoutMs}ms, aborting`);
159
- batchAc.abort();
160
- }, this.batchTimeoutMs);
161
201
  const batch = new Map();
202
+ const now = Date.now();
162
203
  let taken = 0;
163
204
  for (const [absPath, event] of this.pending) {
205
+ if (((_a = this.retryAt.get(absPath)) !== null && _a !== void 0 ? _a : 0) > now)
206
+ continue;
164
207
  batch.set(absPath, event);
165
208
  taken++;
166
209
  if (taken >= MAX_BATCH_SIZE)
167
210
  break;
168
211
  }
212
+ if (batch.size === 0) {
213
+ this.schedulePendingBatch(30000);
214
+ return;
215
+ }
216
+ this.processing = true;
217
+ const batchAc = new AbortController();
218
+ const abortBatch = () => batchAc.abort(operationSignal.reason);
219
+ if (operationSignal.aborted)
220
+ abortBatch();
221
+ else
222
+ operationSignal.addEventListener("abort", abortBatch, { once: true });
223
+ this.currentBatchAc = batchAc;
224
+ const batchTimeout = setTimeout(() => {
225
+ (0, logger_1.log)(this.wtag, `Batch timed out after ${this.batchTimeoutMs}ms, aborting`);
226
+ batchAc.abort();
227
+ }, this.batchTimeoutMs);
169
228
  for (const key of batch.keys()) {
170
229
  this.pending.delete(key);
230
+ this.retryAt.delete(key);
171
231
  }
172
232
  const filenames = [...batch.keys()].map((p) => path.basename(p));
173
233
  (0, logger_1.log)(this.wtag, `Processing ${batch.size} changed files: ${filenames.join(", ")}`);
@@ -177,12 +237,32 @@ class ProjectBatchProcessor {
177
237
  let backoffOverrideMs = 0;
178
238
  try {
179
239
  // No lock needed — daemon is the single writer to LanceDB/MetaCache
180
- const pool = (0, pool_1.getWorkerPool)();
240
+ const pool = this.workerPool;
181
241
  const deletes = [];
182
242
  const vectors = [];
183
243
  const metaUpdates = new Map();
184
244
  const metaDeletes = [];
185
245
  const completed = new Set();
246
+ const retryFailures = new Set();
247
+ const requeuePath = (absPath, event, failed) => {
248
+ var _a, _b;
249
+ if (this.pending.has(absPath))
250
+ return;
251
+ if (failed) {
252
+ const retry = (0, watcher_batch_1.computePathRetry)((_a = this.retryCount.get(absPath)) !== null && _a !== void 0 ? _a : 0, MAX_RETRIES, DEBOUNCE_MS);
253
+ if (!retry.retry) {
254
+ (0, logger_1.log)(this.wtag, `Dropped ${path.basename(absPath)} after ${MAX_RETRIES} retries`);
255
+ this.retryCount.delete(absPath);
256
+ this.retryAt.delete(absPath);
257
+ this.terminalFailures.add(absPath);
258
+ (_b = this.onTerminalFailure) === null || _b === void 0 ? void 0 : _b.call(this, absPath);
259
+ return;
260
+ }
261
+ this.retryCount.set(absPath, retry.failures);
262
+ this.retryAt.set(absPath, Date.now() + retry.backoffMs);
263
+ }
264
+ this.pending.set(absPath, event);
265
+ };
186
266
  for (const [absPath, event] of batch) {
187
267
  if (batchAc.signal.aborted)
188
268
  break;
@@ -191,21 +271,18 @@ class ProjectBatchProcessor {
191
271
  (processed % 10 === 0 || processed === batch.size)) {
192
272
  (0, logger_1.log)(this.wtag, `Progress: ${processed}/${batch.size} (${reindexed} reindexed)`);
193
273
  }
194
- if (event === "unlink") {
195
- deletes.push(absPath);
196
- metaDeletes.push(absPath);
197
- reindexed++;
198
- completed.add(absPath);
199
- continue;
200
- }
201
- // change or add
274
+ // Reclassify every event at apply time. A catchup-derived unlink may be
275
+ // stale if the file was recreated after its directory was scanned.
202
276
  try {
203
- const stats = yield fs.promises.stat(absPath);
204
- if (!(0, file_utils_1.isIndexableFile)(absPath, stats.size)) {
205
- // File became non-indexable (emptied or now too large). If we
206
- // indexed it before, drop its chunks + meta so search stops
207
- // returning stale content; otherwise there's nothing to clean up.
208
- if (this.metaCache.get(absPath)) {
277
+ const classification = yield this.filePolicy.classifyFile(absPath);
278
+ if (classification.status === "error") {
279
+ console.error(`[${this.wtag}] Policy could not classify ${absPath}:`, classification.error);
280
+ retryFailures.add(absPath);
281
+ continue;
282
+ }
283
+ if (classification.status === "excluded" ||
284
+ classification.status === "missing") {
285
+ if (event === "unlink" || this.metaCache.get(absPath)) {
209
286
  deletes.push(absPath);
210
287
  metaDeletes.push(absPath);
211
288
  reindexed++;
@@ -213,6 +290,7 @@ class ProjectBatchProcessor {
213
290
  completed.add(absPath);
214
291
  continue;
215
292
  }
293
+ const stats = classification.stat;
216
294
  const cached = this.metaCache.get(absPath);
217
295
  if ((0, cache_check_1.isFileCached)(cached, stats)) {
218
296
  completed.add(absPath);
@@ -221,10 +299,16 @@ class ProjectBatchProcessor {
221
299
  // Fast path: if only mtime changed but size matches and we have a hash,
222
300
  // verify in-process instead of dispatching to a worker (~220ms saved).
223
301
  if ((cached === null || cached === void 0 ? void 0 : cached.hash) && cached.size === stats.size) {
224
- const buf = yield fs.promises.readFile(absPath);
225
- const hash = (0, file_utils_1.computeContentHash)(buf, absPath);
302
+ const snapshot = yield (0, file_utils_1.readFileSnapshot)(absPath, {
303
+ projectRoot: this.projectRoot,
304
+ });
305
+ if (snapshot.size !== stats.size ||
306
+ snapshot.mtimeMs !== stats.mtimeMs) {
307
+ throw new Error("File changed after policy classification");
308
+ }
309
+ const hash = (0, file_utils_1.computeContentHash)(snapshot.buffer, absPath);
226
310
  if (hash === cached.hash) {
227
- metaUpdates.set(absPath, Object.assign(Object.assign({}, cached), { mtimeMs: stats.mtimeMs }));
311
+ metaUpdates.set(absPath, Object.assign(Object.assign({}, cached), { mtimeMs: snapshot.mtimeMs }));
228
312
  completed.add(absPath);
229
313
  continue;
230
314
  }
@@ -232,7 +316,27 @@ class ProjectBatchProcessor {
232
316
  const result = yield pool.processFile({
233
317
  path: absPath,
234
318
  absolutePath: absPath,
319
+ projectRoot: this.projectRoot,
235
320
  }, batchAc.signal);
321
+ // Policy and filesystem state can change while embedding is in
322
+ // flight. Never commit a result that no longer describes the path.
323
+ const current = yield this.filePolicy.classifyFile(absPath);
324
+ if (current.status === "error") {
325
+ retryFailures.add(absPath);
326
+ continue;
327
+ }
328
+ if (current.status === "excluded" || current.status === "missing") {
329
+ deletes.push(absPath);
330
+ metaDeletes.push(absPath);
331
+ reindexed++;
332
+ completed.add(absPath);
333
+ continue;
334
+ }
335
+ if (current.stat.mtimeMs !== result.mtimeMs ||
336
+ current.stat.size !== result.size) {
337
+ retryFailures.add(absPath);
338
+ continue;
339
+ }
236
340
  const metaEntry = {
237
341
  hash: result.hash,
238
342
  mtimeMs: result.mtimeMs,
@@ -274,15 +378,28 @@ class ProjectBatchProcessor {
274
378
  console.error(`[${this.wtag}] Worker pool unhealthy, aborting batch`);
275
379
  break;
276
380
  }
277
- completed.add(absPath);
381
+ retryFailures.add(absPath);
278
382
  }
279
383
  }
280
384
  }
385
+ const pureDeleteCandidates = new Set(metaDeletes);
386
+ const authoritativeMetaDeletes = new Set();
387
+ for (const absPath of pureDeleteCandidates) {
388
+ const current = yield this.filePolicy.classifyFile(absPath);
389
+ if (current.status === "missing" || current.status === "excluded") {
390
+ authoritativeMetaDeletes.add(absPath);
391
+ continue;
392
+ }
393
+ completed.delete(absPath);
394
+ batch.set(absPath, "change");
395
+ if (current.status === "error")
396
+ retryFailures.add(absPath);
397
+ }
281
398
  // Requeue files that didn't reach a terminal outcome. This includes the
282
399
  // file whose worker was in flight when the batch was aborted.
283
400
  for (const [absPath, event] of batch) {
284
- if (!completed.has(absPath) && !this.pending.has(absPath)) {
285
- this.pending.set(absPath, event);
401
+ if (!completed.has(absPath)) {
402
+ requeuePath(absPath, event, retryFailures.has(absPath));
286
403
  }
287
404
  }
288
405
  // Flush to VectorDB: insert first, then delete old (preserving new)
@@ -290,29 +407,44 @@ class ProjectBatchProcessor {
290
407
  if (vectors.length > 0) {
291
408
  yield this.vectorDb.insertBatch(vectors);
292
409
  }
293
- if (deletes.length > 0) {
410
+ for (const absPath of [...authoritativeMetaDeletes]) {
411
+ const current = yield this.filePolicy.classifyFile(absPath);
412
+ if (current.status === "missing" || current.status === "excluded") {
413
+ continue;
414
+ }
415
+ authoritativeMetaDeletes.delete(absPath);
416
+ completed.delete(absPath);
417
+ batch.set(absPath, "change");
418
+ requeuePath(absPath, "change", current.status === "error");
419
+ }
420
+ const deletesToApply = deletes.filter((absPath) => !pureDeleteCandidates.has(absPath) ||
421
+ authoritativeMetaDeletes.has(absPath));
422
+ if (deletesToApply.length > 0) {
294
423
  if (newIds.length > 0) {
295
- yield this.vectorDb.deletePathsExcludingIds(deletes, newIds);
424
+ yield this.vectorDb.deletePathsExcludingIds(deletesToApply, newIds);
296
425
  }
297
426
  else {
298
- yield this.vectorDb.deletePaths(deletes);
427
+ yield this.vectorDb.deletePaths(deletesToApply);
299
428
  }
300
429
  }
301
430
  // Update MetaCache
302
431
  for (const [p, entry] of metaUpdates) {
303
432
  this.metaCache.put(p, entry);
304
433
  }
305
- for (const p of metaDeletes) {
434
+ for (const p of authoritativeMetaDeletes) {
306
435
  this.metaCache.delete(p);
307
436
  }
308
437
  const duration = Date.now() - start;
309
438
  if (reindexed > 0) {
310
- (_a = this.onReindex) === null || _a === void 0 ? void 0 : _a.call(this, reindexed, duration);
439
+ (_b = this.onReindex) === null || _b === void 0 ? void 0 : _b.call(this, reindexed, duration);
311
440
  }
312
441
  const remaining = this.pending.size;
313
442
  (0, logger_1.log)(this.wtag, `Batch complete: ${batch.size} files, ${reindexed} reindexed (${(duration / 1000).toFixed(1)}s)${remaining > 0 ? ` — ${remaining} remaining` : ""}`);
314
- for (const absPath of batch.keys()) {
443
+ for (const absPath of completed) {
315
444
  this.retryCount.delete(absPath);
445
+ this.retryAt.delete(absPath);
446
+ this.terminalFailures.delete(absPath);
447
+ (_c = this.onPathSuccess) === null || _c === void 0 ? void 0 : _c.call(this, absPath);
316
448
  }
317
449
  // Trigger compaction if fragments are accumulating
318
450
  if (reindexed > 0) {
@@ -328,6 +460,8 @@ class ProjectBatchProcessor {
328
460
  // Disk pressure: requeue without counting as retries (not the file's fault)
329
461
  if (err instanceof vector_db_1.DiskPressureError) {
330
462
  for (const [absPath, event] of batch) {
463
+ if (this.terminalFailures.has(absPath))
464
+ continue;
331
465
  if (!this.pending.has(absPath)) {
332
466
  this.pending.set(absPath, event);
333
467
  }
@@ -338,51 +472,81 @@ class ProjectBatchProcessor {
338
472
  }
339
473
  else if ((0, vector_db_1.isLanceCorruptionError)(err)) {
340
474
  // Manifest references a missing fragment — retrying every 2s burns CPU
341
- // and floods logs without making progress. Log once per hour, drop the
342
- // batch (per-file retries would just re-fail), and back off 30 min so a
343
- // human can run `gmax index --reset` for the affected project.
475
+ // and floods logs without making progress. Log once per hour, preserve
476
+ // non-terminal events, and back off 30 min for manual recovery.
344
477
  const now = Date.now();
345
478
  if (now - this.lastCorruptionLogMs > 60 * 60 * 1000) {
346
479
  this.lastCorruptionLogMs = now;
347
480
  const msg = err instanceof Error ? err.message : String(err);
348
481
  console.error(`[${this.wtag}] DATA CORRUPTION: LanceDB manifest references a missing fragment. ` +
349
482
  `Backing off this project's batch processor for 30 min. ` +
350
- `To repair, run: gmax index --reset (in ${this.projectRoot}). Original: ${msg}`);
483
+ `Preserve the store for diagnosis, then run 'gmax repair --rebuild' only when whole-corpus replacement is intended. Original: ${msg}`);
484
+ }
485
+ for (const [absPath, event] of batch) {
486
+ if (this.terminalFailures.has(absPath))
487
+ continue;
488
+ if (!this.pending.has(absPath))
489
+ this.pending.set(absPath, event);
351
490
  }
352
- for (const [absPath] of batch)
353
- this.retryCount.delete(absPath);
354
491
  backoffOverrideMs = 30 * 60 * 1000;
355
492
  }
356
493
  else {
357
494
  console.error(`[${this.wtag}] Batch processing failed:`, err);
358
- const { requeued, dropped, backoffMs } = (0, watcher_batch_1.computeRetryAction)(batch, this.retryCount, MAX_RETRIES, false, 0, DEBOUNCE_MS);
359
- for (const [absPath, event] of requeued) {
360
- if (!this.pending.has(absPath)) {
361
- this.pending.set(absPath, event);
495
+ let dropped = 0;
496
+ const droppedPaths = [];
497
+ for (const [absPath, event] of batch) {
498
+ if (this.terminalFailures.has(absPath))
499
+ continue;
500
+ if (this.pending.has(absPath))
501
+ continue;
502
+ const retry = (0, watcher_batch_1.computePathRetry)((_d = this.retryCount.get(absPath)) !== null && _d !== void 0 ? _d : 0, MAX_RETRIES, DEBOUNCE_MS);
503
+ if (!retry.retry) {
504
+ this.retryCount.delete(absPath);
505
+ this.retryAt.delete(absPath);
506
+ this.terminalFailures.add(absPath);
507
+ (_e = this.onTerminalFailure) === null || _e === void 0 ? void 0 : _e.call(this, absPath);
508
+ dropped++;
509
+ droppedPaths.push(absPath);
510
+ continue;
362
511
  }
512
+ this.retryCount.set(absPath, retry.failures);
513
+ this.retryAt.set(absPath, Date.now() + retry.backoffMs);
514
+ this.pending.set(absPath, event);
363
515
  }
364
516
  if (dropped > 0) {
365
- const droppedPaths = [...batch.keys()].filter((p) => !requeued.has(p));
366
517
  (0, logger_1.log)(this.wtag, `Dropped ${dropped} file(s) after ${MAX_RETRIES} retries: ${droppedPaths.map((p) => path.basename(p)).join(", ")}`);
367
518
  }
368
- backoffOverrideMs = this.pending.size > 0 ? backoffMs : 0;
519
+ backoffOverrideMs = 0;
369
520
  }
370
521
  }
371
522
  finally {
372
523
  clearTimeout(batchTimeout);
524
+ operationSignal.removeEventListener("abort", abortBatch);
373
525
  this.currentBatchAc = null;
374
526
  this.processing = false;
527
+ if (this.policyChangedDuringBatch) {
528
+ this.policyChangedDuringBatch = false;
529
+ for (const absPath of batch.keys()) {
530
+ if (!this.pending.has(absPath)) {
531
+ this.retryCount.delete(absPath);
532
+ this.retryAt.delete(absPath);
533
+ this.pending.set(absPath, "change");
534
+ }
535
+ }
536
+ }
375
537
  if (!this.closed && this.pending.size > 0) {
376
538
  if (backoffOverrideMs > 0) {
377
539
  if (this.debounceTimer)
378
540
  clearTimeout(this.debounceTimer);
541
+ this.debounceDueMs = Date.now() + backoffOverrideMs;
379
542
  this.debounceTimer = setTimeout(() => {
380
543
  this.debounceTimer = null;
544
+ this.debounceDueMs = 0;
381
545
  this.startBatch();
382
546
  }, backoffOverrideMs);
383
547
  }
384
548
  else {
385
- this.scheduleBatch();
549
+ this.schedulePendingBatch();
386
550
  }
387
551
  }
388
552
  }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeEmbeddingFingerprint = computeEmbeddingFingerprint;
4
+ exports.resolveEmbeddingGeneration = resolveEmbeddingGeneration;
5
+ exports.parseEmbeddingGeneration = parseEmbeddingGeneration;
6
+ exports.isOnnxFallbackCompatible = isOnnxFallbackCompatible;
7
+ exports.compareEmbeddingGeneration = compareEmbeddingGeneration;
8
+ const node_crypto_1 = require("node:crypto");
9
+ const config_1 = require("../../config");
10
+ function requireModelId(label, value) {
11
+ const normalized = value.trim();
12
+ if (!normalized)
13
+ throw new Error(`${label} must be a non-empty model ID`);
14
+ if (normalized !== value) {
15
+ throw new Error(`${label} must not contain surrounding whitespace`);
16
+ }
17
+ return value;
18
+ }
19
+ function computeEmbeddingFingerprint(identity) {
20
+ if (!Number.isInteger(identity.vectorDim) || identity.vectorDim <= 0) {
21
+ throw new Error("Embedding vector dimension must be a positive integer");
22
+ }
23
+ const canonical = [
24
+ "gmax-embedding-generation-v1",
25
+ requireModelId("Model tier", identity.tier),
26
+ identity.vectorDim,
27
+ requireModelId("ONNX model", identity.onnxModel),
28
+ requireModelId("MLX model", identity.mlxModel),
29
+ requireModelId("ColBERT model", identity.colbertModel),
30
+ ];
31
+ return (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(canonical)).digest("hex");
32
+ }
33
+ function resolveEmbeddingGeneration(input) {
34
+ var _a;
35
+ const tier = config_1.MODEL_TIERS[input.modelTier];
36
+ if (!tier)
37
+ throw new Error(`Unknown model tier: ${input.modelTier}`);
38
+ if (input.vectorDim !== undefined && input.vectorDim !== tier.vectorDim) {
39
+ throw new Error(`Vector dimension ${input.vectorDim} contradicts model tier ${input.modelTier} (${tier.vectorDim})`);
40
+ }
41
+ const identity = {
42
+ tier: tier.id,
43
+ vectorDim: tier.vectorDim,
44
+ onnxModel: tier.onnxModel,
45
+ mlxModel: requireModelId("MLX model", (_a = input.mlxModel) !== null && _a !== void 0 ? _a : tier.mlxModel),
46
+ colbertModel: config_1.MODEL_IDS.colbert,
47
+ };
48
+ return Object.freeze(Object.assign(Object.assign({}, identity), { fingerprint: computeEmbeddingFingerprint(identity) }));
49
+ }
50
+ function parseEmbeddingGeneration(value) {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
52
+ throw new Error("Invalid embedding generation");
53
+ }
54
+ const candidate = value;
55
+ if (typeof candidate.tier !== "string" ||
56
+ typeof candidate.vectorDim !== "number" ||
57
+ typeof candidate.onnxModel !== "string" ||
58
+ typeof candidate.mlxModel !== "string" ||
59
+ typeof candidate.colbertModel !== "string" ||
60
+ typeof candidate.fingerprint !== "string") {
61
+ throw new Error("Invalid embedding generation");
62
+ }
63
+ const identity = {
64
+ tier: candidate.tier,
65
+ vectorDim: candidate.vectorDim,
66
+ onnxModel: candidate.onnxModel,
67
+ mlxModel: candidate.mlxModel,
68
+ colbertModel: candidate.colbertModel,
69
+ };
70
+ if (candidate.fingerprint !== computeEmbeddingFingerprint(identity)) {
71
+ throw new Error("Embedding generation identity is inconsistent");
72
+ }
73
+ return Object.freeze(Object.assign(Object.assign({}, identity), { fingerprint: candidate.fingerprint }));
74
+ }
75
+ function isOnnxFallbackCompatible(generation) {
76
+ const tier = config_1.MODEL_TIERS[generation.tier];
77
+ return ((tier === null || tier === void 0 ? void 0 : tier.id) === generation.tier &&
78
+ tier.vectorDim === generation.vectorDim &&
79
+ tier.onnxModel === generation.onnxModel &&
80
+ tier.mlxModel === generation.mlxModel);
81
+ }
82
+ function compareEmbeddingGeneration(persisted, configured) {
83
+ var _a;
84
+ const hasExactIdentity = persisted.embeddingFingerprint !== undefined &&
85
+ persisted.embedModel !== undefined &&
86
+ persisted.mlxModel !== undefined &&
87
+ persisted.colbertModel !== undefined;
88
+ const built = hasExactIdentity
89
+ ? parseEmbeddingGeneration({
90
+ tier: persisted.modelTier,
91
+ vectorDim: persisted.vectorDim,
92
+ onnxModel: persisted.embedModel,
93
+ mlxModel: persisted.mlxModel,
94
+ colbertModel: persisted.colbertModel,
95
+ fingerprint: persisted.embeddingFingerprint,
96
+ })
97
+ : resolveEmbeddingGeneration(Object.assign({ modelTier: persisted.modelTier, vectorDim: persisted.vectorDim }, (persisted.mlxModel === undefined
98
+ ? {}
99
+ : { mlxModel: persisted.mlxModel })));
100
+ return {
101
+ built,
102
+ state: ((_a = persisted.embeddingFingerprint) !== null && _a !== void 0 ? _a : built.fingerprint) !==
103
+ configured.fingerprint
104
+ ? "stale"
105
+ : persisted.embeddingFingerprint
106
+ ? "current"
107
+ : "legacy",
108
+ };
109
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.projectEmbeddingStatus = projectEmbeddingStatus;
4
+ exports.embeddingFingerprintLabel = embeddingFingerprintLabel;
5
+ exports.assertEmbeddingSearchCompatible = assertEmbeddingSearchCompatible;
6
+ const embedding_generation_1 = require("./embedding-generation");
7
+ function projectEmbeddingStatus(project, config) {
8
+ const configured = (0, embedding_generation_1.resolveEmbeddingGeneration)(config);
9
+ if (!project || project.status !== "indexed") {
10
+ return { configured, built: null, state: "unbuilt" };
11
+ }
12
+ const comparison = (0, embedding_generation_1.compareEmbeddingGeneration)(project, configured);
13
+ return Object.assign({ configured }, comparison);
14
+ }
15
+ function embeddingFingerprintLabel(fingerprint) {
16
+ return fingerprint.slice(0, 12);
17
+ }
18
+ function assertEmbeddingSearchCompatible(projects, config) {
19
+ const stale = projects.filter((project) => projectEmbeddingStatus(project, config).state === "stale");
20
+ if (stale.length === 0)
21
+ return;
22
+ throw new Error(`embedding generation mismatch for ${stale.map((project) => project.name).join(", ")}; run gmax repair --rebuild to rebuild the whole corpus`);
23
+ }