grepmax 0.26.67 → 0.26.69

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.
@@ -10,7 +10,7 @@
10
10
  "name": "grepmax",
11
11
  "source": "./plugins/grepmax",
12
12
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
13
- "version": "0.26.67",
13
+ "version": "0.26.69",
14
14
  "author": {
15
15
  "name": "Robert Owens",
16
16
  "email": "robowens@me.com"
package/README.md CHANGED
@@ -240,7 +240,7 @@ gmax status # See all projects + watcher status
240
240
 
241
241
  Adding or indexing a project can start the daemon automatically. MCP acquires primary-project watch leases lazily when an index-reading tool is used; discovery and catalog listing start no background work. It shuts down after 4 hours of inactivity, and hands off to a fresh daemon after 24 hours or once its memory footprint passes 2.5 GB. File-change batches are processed on up to four worker processes by default, added only when work backs up, with one kept free for searches; full-table compaction and new FTS/ANN index builds are disabled by containment. Existing indexes remain readable. Host safety stops persist across restart and block heavy operations.
242
242
 
243
- `gmax watch status` reports failed files, native watcher recovery or polling, dropped-event counts, and the last complete filesystem reconciliation. FSEvents dropped-event warnings keep the native stream attached and preserve the accompanying events. A reconciliation covers the gap; repeated warnings coalesce into one later scan, with a 30-second scan interval. Recovery stays visible until a complete scan covers the latest gap. Other backend errors still trigger subscription recovery; repeated terminal failures fall back to five-minute polling, where recent edits can lag. Search and MCP responses retain health warnings even after the file queue drains. A complete reconciliation means the filesystem scan completed; queued embedding work can still be pending.
243
+ `gmax watch status` reports failed files, native watcher recovery or polling, dropped-event counts, and the last complete filesystem reconciliation. FSEvents dropped-event warnings keep the native stream attached and preserve the accompanying events. A reconciliation covers the gap; repeated warnings coalesce into one later scan, with a 30-second scan interval. Recovery stays visible until a complete scan covers the latest gap. Other backend errors still trigger subscription recovery; repeated terminal failures fall back to five-minute polling, where recent edits can lag. Search and MCP responses retain health warnings even after the file queue drains. On macOS, gmax also forwards up to eight existing policy-excluded directories to the native event stream, prioritizing dependency, build and development output. Discovery is bounded; remaining paths retain glob filtering and file-policy checks. Policy edits refresh exclusions, and a policy change during subscription setup is checked before keeping a directory exclusion. Live edit batches can run during reconciliation instead of waiting for the full filesystem walk. Whole-project reindex and removal still quiesce the watcher and drain its work before changing the project. A complete reconciliation means the filesystem scan completed; queued embedding work can still be pending.
244
244
 
245
245
  Live filesystem edits take priority over background catchup and cleanup. Background batches dispatch one file at a time, with at most four catchup files or 50 cleanup paths. They yield between files when a live edit arrives or the two-second dispatch window is reached; an active file or store commit finishes before yielding. Catchup and cleanup alternate, and one background file is admitted after three consecutive live batches so cleanup continues during sustained editing. Existing retry backoff and worker limits remain in effect.
246
246
 
@@ -192,7 +192,14 @@ class Daemon {
192
192
  evictSearcher: (root) => {
193
193
  this.searchers.delete(root);
194
194
  },
195
- runProjectOperation: (root, name, signal, fn) => this.withProjectLock(root, signal, () => this.runSharedOperation(name, signal, fn)),
195
+ runProjectOperation: (root, name, signal, fn) => {
196
+ // A filesystem walk can take minutes. Keep it lifecycle-tracked and
197
+ // admitted, but let live batches hold the project's write lock while
198
+ // the scan yields. Project removal/reindex quiesces the watcher first.
199
+ if (name === "watch-catchup")
200
+ return this.runSharedOperation(name, signal, fn);
201
+ return this.withProjectLock(root, signal, () => this.runSharedOperation(name, signal, fn));
202
+ },
196
203
  });
197
204
  this.projectMutex = new keyed_mutex_1.KeyedMutex();
198
205
  // Tests construct daemons against the real HOME; keep their leases in memory.
@@ -1237,7 +1244,15 @@ class Daemon {
1237
1244
  this.vectorDb.pauseMaintenanceLoop();
1238
1245
  const stopHeartbeat = (0, ipc_handler_1.startHeartbeat)(conn);
1239
1246
  let lastProgressTime = 0;
1247
+ const wasWatching = this.processors.has(root);
1248
+ let watcherQuiesced = false;
1240
1249
  try {
1250
+ // Re-adding a watched project is also a full sync. A catchup scan no
1251
+ // longer holds the write lock, so explicitly drain it before syncing.
1252
+ if (wasWatching) {
1253
+ yield this.unwatchProjectWithinOperation(root);
1254
+ watcherQuiesced = true;
1255
+ }
1241
1256
  const result = yield (0, syncer_1.initialSync)({
1242
1257
  projectRoot: root,
1243
1258
  vectorDb: this.vectorDb,
@@ -1287,6 +1302,7 @@ class Daemon {
1287
1302
  });
1288
1303
  }
1289
1304
  yield this.watchProjectWithinOperation(root);
1305
+ watcherQuiesced = false;
1290
1306
  (0, ipc_handler_1.writeDone)(conn, {
1291
1307
  ok: true,
1292
1308
  processed: result.processed,
@@ -1316,6 +1332,15 @@ class Daemon {
1316
1332
  }
1317
1333
  finally {
1318
1334
  stopHeartbeat();
1335
+ if (watcherQuiesced && !this.shuttingDown) {
1336
+ try {
1337
+ this.assertHeavyOperationAdmission("watch");
1338
+ yield this.watchProjectWithinOperation(root);
1339
+ }
1340
+ catch (error) {
1341
+ console.error("[daemon] Could not restore watcher after add:", error);
1342
+ }
1343
+ }
1319
1344
  (_a = this.vectorDb) === null || _a === void 0 ? void 0 : _a.resumeMaintenanceLoop();
1320
1345
  }
1321
1346
  });
@@ -51,7 +51,6 @@ var __asyncValues = (this && this.__asyncValues) || function (o) {
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
52
  exports.WatcherManager = void 0;
53
53
  const path = __importStar(require("node:path"));
54
- const watcher = __importStar(require("@parcel/watcher"));
55
54
  const batch_processor_1 = require("../index/batch-processor");
56
55
  const cache_coherence_1 = require("../index/cache-coherence");
57
56
  const file_policy_1 = require("../index/file-policy");
@@ -327,7 +326,7 @@ class WatcherManager {
327
326
  catch (_c) { }
328
327
  this.deps.subscriptions.delete(root);
329
328
  }
330
- const sub = yield watcher.subscribe(root, (err, events) => {
329
+ const { subscription: sub } = yield (0, watcher_ignore_1.subscribeWithNativeExclusions)(root, (err, events) => {
331
330
  var _a, _b;
332
331
  var _c, _d;
333
332
  if (this.deps.processors.get(root) !== processor ||
@@ -360,12 +359,7 @@ class WatcherManager {
360
359
  processor.handleFileEvent(event.type === "delete" ? "unlink" : "change", event.path);
361
360
  }
362
361
  this.deps.touchActivity();
363
- }, {
364
- ignore: [
365
- ...watcher_1.WATCHER_IGNORE_GLOBS,
366
- ...(yield (0, watcher_ignore_1.readProjectWatcherIgnores)(root)),
367
- ],
368
- });
362
+ }, watcher_1.WATCHER_IGNORE_GLOBS);
369
363
  if (this.deps.processors.get(root) !== processor ||
370
364
  this.watcherGenerations.get(root) !== generation) {
371
365
  yield sub.unsubscribe();
@@ -653,6 +647,7 @@ class WatcherManager {
653
647
  // Fast path: if only mtime changed but size is identical and we have a hash,
654
648
  // just verify the hash in-process instead of sending to a worker.
655
649
  if ((cached === null || cached === void 0 ? void 0 : cached.hash) && cached.size === stats.size) {
650
+ const observed = Object.assign({}, cached);
656
651
  const snapshot = yield (0, file_utils_1.readFileSnapshot)(absPath, {
657
652
  projectRoot: root,
658
653
  });
@@ -664,11 +659,21 @@ class WatcherManager {
664
659
  continue;
665
660
  }
666
661
  const hash = (0, file_utils_1.computeContentHash)(snapshot.buffer, absPath);
667
- if (hash === cached.hash) {
662
+ if (hash === observed.hash) {
668
663
  // Content unchanged — update mtime in cache and skip worker
669
664
  if (signal.aborted)
670
665
  return false;
671
- metaCache.put(absPath, Object.assign(Object.assign({}, cached), { mtimeMs: stats.mtimeMs }));
666
+ // A live batch can finish while this read yields. Never replace
667
+ // newer metadata or resurrect a cache entry it removed.
668
+ const current = metaCache.get(absPath);
669
+ if (current &&
670
+ current.hash === observed.hash &&
671
+ current.mtimeMs === observed.mtimeMs &&
672
+ current.size === observed.size &&
673
+ current.hashVersion === observed.hashVersion &&
674
+ current.hasVectors === observed.hasVectors) {
675
+ metaCache.put(absPath, Object.assign(Object.assign({}, observed), { mtimeMs: stats.mtimeMs }));
676
+ }
672
677
  skipped++;
673
678
  continue;
674
679
  }
@@ -41,15 +41,27 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
41
41
  step((generator = generator.apply(thisArg, _arguments || [])).next());
42
42
  });
43
43
  };
44
+ var __asyncValues = (this && this.__asyncValues) || function (o) {
45
+ if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
46
+ var m = o[Symbol.asyncIterator], i;
47
+ return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
48
+ function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
49
+ function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
50
+ };
44
51
  var __importDefault = (this && this.__importDefault) || function (mod) {
45
52
  return (mod && mod.__esModule) ? mod : { "default": mod };
46
53
  };
47
54
  Object.defineProperty(exports, "__esModule", { value: true });
48
55
  exports.nativeProjectIgnoreGlobs = nativeProjectIgnoreGlobs;
49
56
  exports.readProjectWatcherIgnores = readProjectWatcherIgnores;
57
+ exports.readNativeWatcherIgnores = readNativeWatcherIgnores;
58
+ exports.watcherIgnoreIdentity = watcherIgnoreIdentity;
59
+ exports.subscribeWithNativeExclusions = subscribeWithNativeExclusions;
50
60
  const fs = __importStar(require("node:fs/promises"));
51
61
  const path = __importStar(require("node:path"));
62
+ const watcher = __importStar(require("@parcel/watcher"));
52
63
  const ignore_1 = __importDefault(require("ignore"));
64
+ const file_policy_1 = require("./file-policy");
53
65
  /** Only the common positive gitignore/glob subset is safe to push into Parcel.
54
66
  * Negations, escapes and unsupported syntax stay with ProjectFilePolicy, so a
55
67
  * native ignore can never hide a file that an explicit re-inclusion needs. */
@@ -102,3 +114,174 @@ function readProjectWatcherIgnores(root) {
102
114
  }
103
115
  });
104
116
  }
117
+ const NATIVE_EXCLUSION_LIMIT = 8;
118
+ const DISCOVERY_DIRECTORY_LIMIT = 512;
119
+ const DISCOVERY_ENTRY_LIMIT = 16384;
120
+ const DISCOVERY_DEPTH_LIMIT = 4;
121
+ const DISCOVERY_TIME_MS = 1000;
122
+ /** FSEvents only receives Parcel's literal paths, not its glob filters. Select
123
+ * bounded, existing directories that file policy already excludes. Never hide
124
+ * a source ancestor merely because some descendants are ignored. */
125
+ function readNativeWatcherIgnores(root, baseGlobs) {
126
+ return __awaiter(this, void 0, void 0, function* () {
127
+ var _a, e_1, _b, _c;
128
+ const policy = new file_policy_1.ProjectFilePolicy(root);
129
+ const candidates = [];
130
+ const discoveryPriority = (directory, depth) => {
131
+ const parts = path.relative(root, directory).split(path.sep);
132
+ // Package containers often hold nested build roots. Inspect those before
133
+ // descending authored source trees, which can exhaust the entry budget.
134
+ const container = parts.some((part) => ["packages", "apps", "services", "ios", "android", "macos"].includes(part));
135
+ const source = parts.some((part) => ["src", "source", "Sources", "tests", "test", "Tests"].includes(part));
136
+ return (container ? 1000 : 0) - (source ? 2000 : 0) - depth;
137
+ };
138
+ const pending = [{ directory: root, depth: 0 }];
139
+ const deadline = performance.now() + DISCOVERY_TIME_MS;
140
+ let entries = 0;
141
+ let visited = 0;
142
+ for (let cursor = 0; cursor < pending.length; cursor++) {
143
+ if (visited++ >= DISCOVERY_DIRECTORY_LIMIT || performance.now() > deadline)
144
+ break;
145
+ let next = cursor;
146
+ for (let i = cursor + 1; i < pending.length; i++) {
147
+ if (discoveryPriority(pending[i].directory, pending[i].depth) >
148
+ discoveryPriority(pending[next].directory, pending[next].depth))
149
+ next = i;
150
+ }
151
+ [pending[cursor], pending[next]] = [pending[next], pending[cursor]];
152
+ const { directory, depth } = pending[cursor];
153
+ try {
154
+ const dir = yield fs.opendir(directory);
155
+ try {
156
+ for (var _d = true, dir_1 = (e_1 = void 0, __asyncValues(dir)), dir_1_1; dir_1_1 = yield dir_1.next(), _a = dir_1_1.done, !_a; _d = true) {
157
+ _c = dir_1_1.value;
158
+ _d = false;
159
+ const entry = _c;
160
+ if (++entries > DISCOVERY_ENTRY_LIMIT || performance.now() > deadline)
161
+ break;
162
+ if (!entry.isDirectory() || entry.isSymbolicLink())
163
+ continue;
164
+ const absolute = path.join(directory, entry.name);
165
+ const classified = yield policy.classifyDirectory(absolute);
166
+ if (classified.status === "excluded") {
167
+ if (!["default ignore policy", "project ignore policy"].includes(classified.reason))
168
+ continue;
169
+ const relative = path
170
+ .relative(root, absolute)
171
+ .split(path.sep)
172
+ .join("/");
173
+ // Public Parcel options distinguish literals from globs. Skip names
174
+ // it could interpret as a pattern; existing glob filtering remains.
175
+ if (!/^[A-Za-z0-9_. /-]+$/.test(relative))
176
+ continue;
177
+ const priority = entry.name === ".git" || entry.name === "node_modules"
178
+ ? depth === 0
179
+ ? 1000
180
+ : 500
181
+ : entry.name === ".dev"
182
+ ? depth === 0
183
+ ? 950
184
+ : 550
185
+ : entry.name === ".build" || entry.name === "DerivedData"
186
+ ? 800
187
+ : entry.name === ".next"
188
+ ? 700
189
+ : depth === 0
190
+ ? 600
191
+ : 200;
192
+ candidates.push({ relative, score: priority - depth * 10 });
193
+ }
194
+ else if (classified.status === "traverse" &&
195
+ depth < DISCOVERY_DEPTH_LIMIT &&
196
+ pending.length < DISCOVERY_DIRECTORY_LIMIT) {
197
+ pending.push({ directory: absolute, depth: depth + 1 });
198
+ }
199
+ }
200
+ }
201
+ catch (e_1_1) { e_1 = { error: e_1_1 }; }
202
+ finally {
203
+ try {
204
+ if (!_d && !_a && (_b = dir_1.return)) yield _b.call(dir_1);
205
+ }
206
+ finally { if (e_1) throw e_1.error; }
207
+ }
208
+ }
209
+ catch (_e) {
210
+ // Failed/partial discovery only loses an optimization. Existing glob
211
+ // delivery and authoritative file-policy classification remain in force.
212
+ }
213
+ if (entries >= DISCOVERY_ENTRY_LIMIT)
214
+ break;
215
+ }
216
+ candidates.sort((a, b) => b.score - a.score || a.relative.localeCompare(b.relative));
217
+ const literalPaths = candidates
218
+ .slice(0, NATIVE_EXCLUSION_LIMIT)
219
+ .map((c) => c.relative);
220
+ return [...literalPaths, ...(yield nativeWatcherFilters(root, baseGlobs))];
221
+ });
222
+ }
223
+ function nativeWatcherFilters(root, baseGlobs) {
224
+ return __awaiter(this, void 0, void 0, function* () {
225
+ const projectGlobs = yield readProjectWatcherIgnores(root);
226
+ return [...baseGlobs, ...projectGlobs].map((pattern) => {
227
+ if (pattern.includes("*"))
228
+ return pattern;
229
+ // Keep file exclusions and excess directory literals out of ignorePaths:
230
+ // passing more than eight silently defeats macOS stream exclusion.
231
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
232
+ return new RegExp(`^${escaped}(?:/|$)`);
233
+ });
234
+ });
235
+ }
236
+ function verifyNativeWatcherIgnores(root, baseGlobs, ignores) {
237
+ return __awaiter(this, void 0, void 0, function* () {
238
+ const policy = new file_policy_1.ProjectFilePolicy(root);
239
+ const literals = [];
240
+ // Recheck the selected paths, rather than repeating timed discovery. A
241
+ // different partial scan must not cause subscription churn or disable the
242
+ // exclusions we already proved safe. New candidates can wait for refresh.
243
+ for (const pattern of ignores) {
244
+ if (typeof pattern !== "string" || pattern.includes("*"))
245
+ continue;
246
+ const classified = yield policy.classifyDirectory(path.join(root, pattern));
247
+ if (classified.status === "excluded" &&
248
+ ["default ignore policy", "project ignore policy"].includes(classified.reason))
249
+ literals.push(pattern);
250
+ }
251
+ return [...literals, ...(yield nativeWatcherFilters(root, baseGlobs))];
252
+ });
253
+ }
254
+ function watcherIgnoreIdentity(ignores) {
255
+ return JSON.stringify(ignores.map((pattern) => String(pattern)));
256
+ }
257
+ /** A policy edit during subscribe has no event listener yet. Check again after
258
+ * attaching so an old directory exclusion cannot permanently hide newly
259
+ * re-included source. Sustained policy churn falls back to immutable globs. */
260
+ function subscribeWithNativeExclusions(root, callback, baseGlobs, initialIgnores) {
261
+ return __awaiter(this, void 0, void 0, function* () {
262
+ let ignores = initialIgnores !== null && initialIgnores !== void 0 ? initialIgnores : (yield readNativeWatcherIgnores(root, baseGlobs));
263
+ for (let attempt = 0; attempt < 2; attempt++) {
264
+ const subscription = yield watcher.subscribe(root, callback, {
265
+ ignore: ignores,
266
+ });
267
+ let verified;
268
+ try {
269
+ verified = yield verifyNativeWatcherIgnores(root, baseGlobs, ignores);
270
+ }
271
+ catch (error) {
272
+ yield subscription.unsubscribe();
273
+ throw error;
274
+ }
275
+ const policy = watcherIgnoreIdentity(ignores);
276
+ if (policy === watcherIgnoreIdentity(verified))
277
+ return { subscription, policy };
278
+ yield subscription.unsubscribe();
279
+ ignores = verified;
280
+ }
281
+ const fallback = [...baseGlobs];
282
+ return {
283
+ subscription: yield watcher.subscribe(root, callback, { ignore: fallback }),
284
+ policy: watcherIgnoreIdentity(fallback),
285
+ };
286
+ });
287
+ }
@@ -52,7 +52,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
52
52
  exports.WATCHER_IGNORE_GLOBS = void 0;
53
53
  exports.startWatcher = startWatcher;
54
54
  const path = __importStar(require("node:path"));
55
- const watcher = __importStar(require("@parcel/watcher"));
56
55
  const batch_processor_1 = require("./batch-processor");
57
56
  const cache_coherence_1 = require("./cache-coherence");
58
57
  const file_policy_1 = require("./file-policy");
@@ -135,11 +134,8 @@ function startWatcher(opts) {
135
134
  const update = subscriptionUpdate
136
135
  .catch(() => { })
137
136
  .then(() => __awaiter(this, void 0, void 0, function* () {
138
- const ignores = [
139
- ...exports.WATCHER_IGNORE_GLOBS,
140
- ...(yield (0, watcher_ignore_1.readProjectWatcherIgnores)(projectRoot)),
141
- ];
142
- const policy = JSON.stringify(ignores);
137
+ const ignores = yield (0, watcher_ignore_1.readNativeWatcherIgnores)(projectRoot, exports.WATCHER_IGNORE_GLOBS);
138
+ const policy = (0, watcher_ignore_1.watcherIgnoreIdentity)(ignores);
143
139
  if (closing ||
144
140
  (subscription && !subscriptionFailed && subscriptionPolicy === policy))
145
141
  return;
@@ -149,7 +145,7 @@ function startWatcher(opts) {
149
145
  subscription = null;
150
146
  if (closing)
151
147
  return;
152
- const next = yield watcher.subscribe(projectRoot, (err, events) => {
148
+ const { subscription: next, policy: attachedPolicy } = yield (0, watcher_ignore_1.subscribeWithNativeExclusions)(projectRoot, (err, events) => {
153
149
  var _a;
154
150
  if (closing || generation !== subscriptionGeneration)
155
151
  return;
@@ -168,13 +164,13 @@ function startWatcher(opts) {
168
164
  }
169
165
  for (const event of events)
170
166
  processor.handleFileEvent(event.type === "delete" ? "unlink" : "change", event.path);
171
- }, { ignore: ignores });
167
+ }, exports.WATCHER_IGNORE_GLOBS, ignores);
172
168
  if (closing) {
173
169
  yield next.unsubscribe();
174
170
  return;
175
171
  }
176
172
  subscription = next;
177
- subscriptionPolicy = policy;
173
+ subscriptionPolicy = attachedPolicy;
178
174
  }));
179
175
  subscriptionUpdate = update;
180
176
  return update;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.67",
3
+ "version": "0.26.69",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.67",
3
+ "version": "0.26.69",
4
4
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
5
5
  "author": {
6
6
  "name": "Robert Owens",