grepmax 0.19.0 → 0.20.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.
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "grepmax",
4
+ "owner": {
5
+ "name": "Robert Owens",
6
+ "email": "robowens@me.com"
7
+ },
8
+ "plugins": [
9
+ {
10
+ "name": "grepmax",
11
+ "source": "./plugins/grepmax",
12
+ "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
13
+ "version": "0.20.0",
14
+ "author": {
15
+ "name": "Robert Owens",
16
+ "email": "robowens@me.com"
17
+ },
18
+ "skills": [
19
+ "./skills/grepmax"
20
+ ]
21
+ }
22
+ ]
23
+ }
package/README.md CHANGED
@@ -61,7 +61,7 @@ gmax "where do we handle auth?" --agent # Semantic search (compact output)
61
61
  gmax extract handleAuth # Full function body with line numbers
62
62
  gmax peek handleAuth # Signature + callers + callees
63
63
  gmax trace handleAuth -d 2 # Call graph (2-hop)
64
- gmax skeleton src/lib/search/ # File structure (bodies collapsed)
64
+ gmax skeleton src/lib/auth.ts # File structure (bodies collapsed)
65
65
  gmax symbols auth # List indexed symbols
66
66
  ```
67
67
 
@@ -316,7 +316,7 @@ exports.context = new commander_1.Command("context")
316
316
  allSymbols.add(s);
317
317
  }
318
318
  if (allSymbols.size > 0) {
319
- const pathScope = `path LIKE '${(0, filter_builder_1.escapeSqlString)(projectRoot)}/%'`;
319
+ const pathScope = (0, filter_builder_1.pathStartsWith)(`${projectRoot}/`);
320
320
  const relatedCounts = new Map();
321
321
  const searchedFiles = new Set(uniqueFiles);
322
322
  for (const sym of [...allSymbols].slice(0, 20)) {
@@ -773,7 +773,7 @@ exports.mcp = new commander_1.Command("mcp")
773
773
  "is_exported",
774
774
  "defined_symbols",
775
775
  ])
776
- .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
776
+ .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND ${(0, filter_builder_1.pathStartsWith)(prefix)}`)
777
777
  .limit(10)
778
778
  .toArray();
779
779
  if (rows.length === 0) {
@@ -867,7 +867,7 @@ exports.mcp = new commander_1.Command("mcp")
867
867
  const metaRows = yield table
868
868
  .query()
869
869
  .select(["is_exported", "start_line", "end_line"])
870
- .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
870
+ .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND ${(0, filter_builder_1.pathStartsWith)(prefix)}`)
871
871
  .limit(1)
872
872
  .toArray();
873
873
  const exported = metaRows.length > 0 && Boolean(metaRows[0].is_exported);
@@ -982,7 +982,7 @@ exports.mcp = new commander_1.Command("mcp")
982
982
  const defRows = yield table
983
983
  .query()
984
984
  .select(["path", "start_line", "is_exported"])
985
- .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
985
+ .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND ${(0, filter_builder_1.pathStartsWith)(prefix)}`)
986
986
  .limit(1)
987
987
  .toArray();
988
988
  if (defRows.length === 0) {
@@ -1037,7 +1037,7 @@ exports.mcp = new commander_1.Command("mcp")
1037
1037
  "referenced_symbols",
1038
1038
  "is_exported",
1039
1039
  ])
1040
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
1040
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
1041
1041
  .limit(500000)
1042
1042
  .toArray();
1043
1043
  if (rows.length === 0) {
@@ -1221,7 +1221,7 @@ exports.mcp = new commander_1.Command("mcp")
1221
1221
  const absPrefix = path.isAbsolute(pathPrefix)
1222
1222
  ? pathPrefix
1223
1223
  : path.resolve(projectRoot, pathPrefix);
1224
- query = query.where(`path LIKE '${(0, filter_builder_1.escapeSqlString)((0, filter_builder_1.normalizePath)(absPrefix))}%'`);
1224
+ query = query.where((0, filter_builder_1.pathStartsWith)((0, filter_builder_1.normalizePath)(absPrefix)));
1225
1225
  }
1226
1226
  const rows = yield query.toArray();
1227
1227
  const map = new Map();
@@ -1388,7 +1388,7 @@ exports.mcp = new commander_1.Command("mcp")
1388
1388
  "defined_symbols",
1389
1389
  "referenced_symbols",
1390
1390
  ])
1391
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
1391
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
1392
1392
  .limit(200000)
1393
1393
  .toArray();
1394
1394
  if (rows.length === 0) {
@@ -1829,7 +1829,7 @@ exports.mcp = new commander_1.Command("mcp")
1829
1829
  "role",
1830
1830
  "_distance",
1831
1831
  ])
1832
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(projectRoot)}/%'`)
1832
+ .where((0, filter_builder_1.pathStartsWith)(`${projectRoot}/`))
1833
1833
  .limit(limit + 5)
1834
1834
  .toArray();
1835
1835
  let filtered = results.filter((r) => !(r.path === source.path && r.start_line === source.start_line));
@@ -43,6 +43,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.plugin = void 0;
46
+ exports.installAll = installAll;
46
47
  const node_child_process_1 = require("node:child_process");
47
48
  const fs = __importStar(require("node:fs"));
48
49
  const os = __importStar(require("node:os"));
@@ -139,6 +140,39 @@ function getClients() {
139
140
  },
140
141
  ];
141
142
  }
143
+ /**
144
+ * Install gmax plugins for every detected client; returns the number installed.
145
+ * Deliberately does NOT call gracefulExit — callers decide how to finish. This
146
+ * lets `gmax setup` keep running (and print its outro) after installing, while
147
+ * the `add` subcommand still exits the process itself.
148
+ */
149
+ function installAll() {
150
+ return __awaiter(this, void 0, void 0, function* () {
151
+ const clients = getClients();
152
+ console.log("gmax plugin add — detecting clients...\n");
153
+ let installed = 0;
154
+ for (const client of clients) {
155
+ if (!client.detect()) {
156
+ console.log(` skip ${client.name} — not found`);
157
+ continue;
158
+ }
159
+ try {
160
+ yield client.install();
161
+ installed++;
162
+ }
163
+ catch (err) {
164
+ console.error(` FAIL ${client.name} — ${err instanceof Error ? err.message : String(err)}`);
165
+ }
166
+ }
167
+ if (installed === 0) {
168
+ console.log("\nNo supported clients found. Install one of: claude, opencode, codex, droid");
169
+ }
170
+ else {
171
+ console.log(`\n${installed} plugin(s) installed.`);
172
+ }
173
+ return installed;
174
+ });
175
+ }
142
176
  // --- Subcommands ---
143
177
  const addCmd = new commander_1.Command("add")
144
178
  .description("Install or update gmax plugins")
@@ -164,27 +198,7 @@ const addCmd = new commander_1.Command("add")
164
198
  return;
165
199
  }
166
200
  // Install all detected
167
- console.log("gmax plugin add — detecting clients...\n");
168
- let installed = 0;
169
- for (const client of clients) {
170
- if (!client.detect()) {
171
- console.log(` skip ${client.name} — not found`);
172
- continue;
173
- }
174
- try {
175
- yield client.install();
176
- installed++;
177
- }
178
- catch (err) {
179
- console.error(` FAIL ${client.name} — ${err instanceof Error ? err.message : String(err)}`);
180
- }
181
- }
182
- if (installed === 0) {
183
- console.log("\nNo supported clients found. Install one of: claude, opencode, codex, droid");
184
- }
185
- else {
186
- console.log(`\n${installed} plugin(s) installed.`);
187
- }
201
+ yield installAll();
188
202
  yield (0, exit_1.gracefulExit)();
189
203
  }));
190
204
  const removeCmd = new commander_1.Command("remove")
@@ -79,7 +79,7 @@ exports.project = new commander_1.Command("project")
79
79
  "defined_symbols",
80
80
  "referenced_symbols",
81
81
  ])
82
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
82
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
83
83
  .limit(200000)
84
84
  .toArray();
85
85
  if (rows.length === 0) {
@@ -133,10 +133,17 @@ Examples:
133
133
  (0, watcher_store_1.unregisterWatcher)(watcher.pid);
134
134
  }
135
135
  const paths = (0, project_root_1.ensureProjectPaths)(projectRoot);
136
+ // Slash-terminate so removing /repo/app can't also delete the sibling
137
+ // /repo/app2 — VectorDB matches `path LIKE prefix%` and MetaCache
138
+ // matches `key.startsWith(prefix)`, both of which bleed across siblings
139
+ // without the trailing slash. Mirrors the daemon remove path.
140
+ const rootPrefix = projectRoot.endsWith("/")
141
+ ? projectRoot
142
+ : `${projectRoot}/`;
136
143
  vectorDb = new vector_db_1.VectorDB(paths.lancedbDir);
137
- yield vectorDb.deletePathsWithPrefix(projectRoot);
144
+ yield vectorDb.deletePathsWithPrefix(rootPrefix);
138
145
  metaCache = new meta_cache_1.MetaCache(paths.lmdbPath);
139
- const keys = yield metaCache.getKeysWithPrefix(projectRoot);
146
+ const keys = yield metaCache.getKeysWithPrefix(rootPrefix);
140
147
  for (const key of keys) {
141
148
  metaCache.delete(key);
142
149
  }
@@ -154,8 +154,10 @@ exports.setup = new commander_1.Command("setup")
154
154
  initialValue: true,
155
155
  });
156
156
  if (!p.isCancel(installPlugins) && installPlugins) {
157
- const { plugin: pluginCmd } = yield Promise.resolve().then(() => __importStar(require("./plugin")));
158
- yield pluginCmd.parseAsync(["node", "gmax"]);
157
+ // Call installAll() directly, not `plugin` (bare status) or the `add`
158
+ // subcommand — both gracefulExit() before setup's outro can run.
159
+ const { installAll } = yield Promise.resolve().then(() => __importStar(require("./plugin")));
160
+ yield installAll();
159
161
  }
160
162
  p.outro(`Ready — ${selectedTier.label}, ${embedMode === "gpu" ? "GPU" : "CPU"} mode`);
161
163
  yield (0, exit_1.gracefulExit)();
@@ -121,7 +121,7 @@ Examples:
121
121
  const rows = yield table
122
122
  .query()
123
123
  .select(["id"])
124
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
124
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
125
125
  .toArray();
126
126
  chunkCounts.set(project.root, rows.length);
127
127
  }
@@ -86,7 +86,7 @@ function collectSymbols(options) {
86
86
  const absPrefix = path.isAbsolute(options.pathPrefix)
87
87
  ? options.pathPrefix
88
88
  : path.resolve(options.projectRoot, options.pathPrefix);
89
- query = query.where(`path LIKE '${(0, filter_builder_1.escapeSqlString)((0, filter_builder_1.normalizePath)(absPrefix))}%'`);
89
+ query = query.where((0, filter_builder_1.pathStartsWith)((0, filter_builder_1.normalizePath)(absPrefix)));
90
90
  }
91
91
  const rows = yield query.toArray();
92
92
  const map = new Map();
@@ -81,7 +81,7 @@ exports.watch = new commander_1.Command("watch")
81
81
  // Skip spawn if daemon already running at the same version.
82
82
  // If version mismatches (e.g. after npm install -g), shut down the old
83
83
  // daemon so we can start a fresh one with the new code.
84
- const { isDaemonRunning, isDaemonHeartbeatFresh, sendDaemonCommand } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/daemon-client")));
84
+ const { isDaemonRunning, isDaemonHeartbeatFresh, sendDaemonCommand, readDaemonPid, waitForProcessExit, } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/daemon-client")));
85
85
  if (yield isDaemonRunning()) {
86
86
  const cliVersion = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf-8")).version;
87
87
  const resp = yield sendDaemonCommand({ cmd: "ping" });
@@ -89,6 +89,12 @@ exports.watch = new commander_1.Command("watch")
89
89
  process.exit(0);
90
90
  }
91
91
  console.log(`Daemon version mismatch (${resp.version} → ${cliVersion}), restarting...`);
92
+ // Capture the old PID before shutdown removes the PID file, so we
93
+ // can wait for the process to fully EXIT rather than guessing with a
94
+ // fixed sleep. Spawning the successor while the old daemon is still
95
+ // draining lets the successor's killStaleProcesses() classify it as
96
+ // stale and SIGKILL it mid-cleanup.
97
+ const oldPid = readDaemonPid();
92
98
  yield sendDaemonCommand({
93
99
  cmd: "shutdown",
94
100
  reason: "version-mismatch",
@@ -97,8 +103,15 @@ exports.watch = new commander_1.Command("watch")
97
103
  from_version: cliVersion,
98
104
  from_argv: process.argv.slice(0, 4),
99
105
  });
100
- // Brief wait for old daemon to release socket/lock
101
- yield new Promise((r) => setTimeout(r, 2000));
106
+ if (oldPid) {
107
+ const exited = yield waitForProcessExit(oldPid, 20000);
108
+ if (!exited) {
109
+ console.log(`Old daemon (PID ${oldPid}) still draining after 20s — starting anyway`);
110
+ }
111
+ }
112
+ else {
113
+ yield new Promise((r) => setTimeout(r, 2000));
114
+ }
102
115
  }
103
116
  else if (isDaemonHeartbeatFresh()) {
104
117
  // Ping failed but daemon.lock mtime is fresh — another daemon is
@@ -131,6 +144,13 @@ exports.watch = new commander_1.Command("watch")
131
144
  process.exit(0);
132
145
  }
133
146
  console.error("[daemon] Failed to start:", err);
147
+ // Tear down any half-opened state — the listening socket, PID, and
148
+ // lock — so we don't leave a zombie that answers pings but has no
149
+ // resources. The socket also keeps the event loop alive otherwise.
150
+ try {
151
+ yield daemon.shutdown();
152
+ }
153
+ catch (_c) { }
134
154
  process.exitCode = 1;
135
155
  return;
136
156
  }
@@ -201,7 +221,7 @@ exports.watch = new commander_1.Command("watch")
201
221
  const indexed = yield table
202
222
  .query()
203
223
  .select(["id"])
204
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
224
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
205
225
  .limit(1)
206
226
  .toArray();
207
227
  if (indexed.length === 0) {
@@ -116,6 +116,10 @@ class Daemon {
116
116
  this.heartbeatTick = 0;
117
117
  this.shuttingDown = false;
118
118
  this.recycling = false;
119
+ // False until LanceDB + MetaCache are open. The socket starts listening early
120
+ // (so liveness probes succeed during slow init), so commands that need those
121
+ // resources must be gated on this to avoid hitting null stores mid-startup.
122
+ this.ready = false;
119
123
  this.processManager = new process_manager_1.ProcessManager({
120
124
  getShuttingDown: () => this.shuttingDown,
121
125
  });
@@ -253,6 +257,8 @@ class Daemon {
253
257
  this.vectorDb.startMaintenanceLoop();
254
258
  console.log("[daemon] Opening MetaCache:", config_1.PATHS.lmdbPath);
255
259
  this.metaCache = new meta_cache_1.MetaCache(config_1.PATHS.lmdbPath);
260
+ // Resources are open — only now may resource-dependent IPC commands run.
261
+ this.ready = true;
256
262
  }
257
263
  catch (err) {
258
264
  console.error("[daemon] Failed to open shared resources:", err);
@@ -499,6 +505,10 @@ class Daemon {
499
505
  uptime() {
500
506
  return Math.floor((Date.now() - this.startTime) / 1000);
501
507
  }
508
+ /** True once shared resources (LanceDB + MetaCache) are open. */
509
+ isReady() {
510
+ return this.ready;
511
+ }
502
512
  getDiskPressure() {
503
513
  var _a, _b;
504
514
  return (_b = (_a = this.vectorDb) === null || _a === void 0 ? void 0 : _a.diskPressure) !== null && _b !== void 0 ? _b : "unknown";
@@ -105,6 +105,12 @@ function handleCommand(daemon, cmd, conn) {
105
105
  var _a, _b, _c, _d, _e;
106
106
  try {
107
107
  (0, logger_1.debug)("daemon", `ipc cmd=${cmd.cmd}${cmd.root ? ` root=${cmd.root}` : ""}`);
108
+ // The socket listens before LanceDB/MetaCache are open so liveness probes
109
+ // succeed during slow startup. Gate resource-dependent commands until those
110
+ // stores exist; ping/shutdown must always work (probes + restart).
111
+ if (cmd.cmd !== "ping" && cmd.cmd !== "shutdown" && !daemon.isReady()) {
112
+ return { ok: false, error: "daemon initializing" };
113
+ }
108
114
  switch (cmd.cmd) {
109
115
  case "ping":
110
116
  return {
@@ -368,12 +368,15 @@ class WatcherManager {
368
368
  const bn = path.basename(absPath).toLowerCase();
369
369
  if (!INDEXABLE_EXTENSIONS.has(ext) && !INDEXABLE_EXTENSIONS.has(bn))
370
370
  continue;
371
- seenPaths.add(absPath);
372
371
  try {
373
372
  const stats = yield fs.promises.stat(absPath);
374
- // Skip files that are too large or empty — they'll never be indexed
373
+ // Skip files that are too large or empty — they'll never be indexed.
374
+ // Leave them OUT of seenPaths so a file that became empty/oversized is
375
+ // treated as deleted by the purge sweep below and its now-stale chunks
376
+ // are removed, rather than lingering as unsearchable-but-present rows.
375
377
  if (stats.size === 0 || stats.size > MAX_FILE_SIZE_BYTES)
376
378
  continue;
379
+ seenPaths.add(absPath);
377
380
  const cached = metaCache.get(absPath);
378
381
  if (!isFileCached(cached, stats)) {
379
382
  // Fast path: if only mtime changed but size is identical and we have a hash,
@@ -439,6 +442,19 @@ class WatcherManager {
439
442
  }
440
443
  unwatchProject(root) {
441
444
  return __awaiter(this, void 0, void 0, function* () {
445
+ // Stop poll-mode timers + their FSEvents recovery probe first, so a removed
446
+ // project can't keep scanning until full daemon shutdown. These live
447
+ // independently of the processor, so clear them even on the early return.
448
+ const pollInterval = this.pollIntervals.get(root);
449
+ if (pollInterval) {
450
+ clearInterval(pollInterval);
451
+ this.pollIntervals.delete(root);
452
+ }
453
+ const recoveryTimer = this.pollRecoveryTimers.get(root);
454
+ if (recoveryTimer) {
455
+ clearInterval(recoveryTimer);
456
+ this.pollRecoveryTimers.delete(root);
457
+ }
442
458
  const processor = this.deps.processors.get(root);
443
459
  if (!processor)
444
460
  return;
@@ -27,10 +27,10 @@ class GraphBuilder {
27
27
  scopeWhere(condition) {
28
28
  let result = condition;
29
29
  if (this.pathPrefix) {
30
- result = `${result} AND path LIKE '${(0, filter_builder_1.escapeSqlString)(this.pathPrefix)}%'`;
30
+ result = `${result} AND ${(0, filter_builder_1.pathStartsWith)(this.pathPrefix)}`;
31
31
  }
32
32
  for (const ex of this.excludePrefixes) {
33
- result = `${result} AND path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(ex)}%'`;
33
+ result = `${result} AND ${(0, filter_builder_1.pathNotStartsWith)(ex)}`;
34
34
  }
35
35
  return result;
36
36
  }
@@ -66,10 +66,10 @@ function expandFileSymbols(symbols, vectorDb, projectRoot, excludePrefixes) {
66
66
  return symbols;
67
67
  const table = yield vectorDb.ensureTable();
68
68
  const prefix = projectRoot.endsWith("/") ? projectRoot : `${projectRoot}/`;
69
- let where = `array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbols[0])}') AND path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`;
69
+ let where = `array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbols[0])}') AND ${(0, filter_builder_1.pathStartsWith)(prefix)}`;
70
70
  for (const ex of excludePrefixes !== null && excludePrefixes !== void 0 ? excludePrefixes : []) {
71
71
  const exNorm = ex.endsWith("/") ? ex : `${ex}/`;
72
- where += ` AND path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(exNorm)}%'`;
72
+ where += ` AND ${(0, filter_builder_1.pathNotStartsWith)(exNorm)}`;
73
73
  }
74
74
  // Find the file that defines this symbol
75
75
  const defRows = yield table
@@ -146,10 +146,10 @@ function findImportFallbackTests(expandedSymbols, originalSymbols, vectorDb, pro
146
146
  // test body's referenced_symbols ends up empty).
147
147
  const table = yield vectorDb.ensureTable();
148
148
  const prefix = projectRoot.endsWith("/") ? projectRoot : `${projectRoot}/`;
149
- let pathScope = `path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`;
149
+ let pathScope = (0, filter_builder_1.pathStartsWith)(prefix);
150
150
  for (const ex of excludePrefixes !== null && excludePrefixes !== void 0 ? excludePrefixes : []) {
151
151
  const exNorm = ex.endsWith("/") ? ex : `${ex}/`;
152
- pathScope += ` AND path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(exNorm)}%'`;
152
+ pathScope += ` AND ${(0, filter_builder_1.pathNotStartsWith)(exNorm)}`;
153
153
  }
154
154
  // Textual matching runs on the ORIGINAL targets only: matching co-defined
155
155
  // file symbols (helpers like `log`) textually drags in every test file
@@ -203,10 +203,10 @@ function walkCallers(symbol, graphBuilder, testHits, currentHop, maxDepth, visit
203
203
  function findDependents(symbols_1, vectorDb_1, projectRoot_1, excludePaths_1) {
204
204
  return __awaiter(this, arguments, void 0, function* (symbols, vectorDb, projectRoot, excludePaths, limit = 10, excludePrefixes) {
205
205
  const table = yield vectorDb.ensureTable();
206
- let pathScope = `path LIKE '${(0, filter_builder_1.escapeSqlString)(projectRoot)}/%'`;
206
+ let pathScope = (0, filter_builder_1.pathStartsWith)(`${projectRoot}/`);
207
207
  for (const ex of excludePrefixes !== null && excludePrefixes !== void 0 ? excludePrefixes : []) {
208
208
  const exNorm = ex.endsWith("/") ? ex : `${ex}/`;
209
- pathScope += ` AND path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(exNorm)}%'`;
209
+ pathScope += ` AND ${(0, filter_builder_1.pathNotStartsWith)(exNorm)}`;
210
210
  }
211
211
  const counts = new Map();
212
212
  for (const sym of symbols) {
@@ -177,8 +177,17 @@ class ProjectBatchProcessor {
177
177
  // change or add
178
178
  try {
179
179
  const stats = yield fs.promises.stat(absPath);
180
- if (!(0, file_utils_1.isIndexableFile)(absPath, stats.size))
180
+ if (!(0, file_utils_1.isIndexableFile)(absPath, stats.size)) {
181
+ // File became non-indexable (emptied or now too large). If we
182
+ // indexed it before, drop its chunks + meta so search stops
183
+ // returning stale content; otherwise there's nothing to clean up.
184
+ if (this.metaCache.get(absPath)) {
185
+ deletes.push(absPath);
186
+ metaDeletes.push(absPath);
187
+ reindexed++;
188
+ }
181
189
  continue;
190
+ }
182
191
  const cached = this.metaCache.get(absPath);
183
192
  if ((0, cache_check_1.isFileCached)(cached, stats)) {
184
193
  continue;
@@ -74,13 +74,24 @@ function flushBatch(db, meta, vectors, pendingMeta, pendingDeletes, dryRun) {
74
74
  return __awaiter(this, void 0, void 0, function* () {
75
75
  if (dryRun)
76
76
  return;
77
- // 1. Write to VectorDB first (source of truth for data)
78
- if (pendingDeletes.length > 0) {
79
- yield db.deletePaths(pendingDeletes);
80
- }
77
+ // 1. Insert the new vectors FIRST, then delete the old chunks for those paths
78
+ // (excluding the just-inserted ids). Deleting first would leave a file
79
+ // unsearchable if the insert then fails — the old, still-valid chunks would
80
+ // already be gone. Mirrors batch-processor's insert-first flush. Paths in
81
+ // pendingDeletes with no new vectors (emptied / non-indexable files) match
82
+ // no excluded id, so all their old chunks are removed.
83
+ const newIds = vectors.map((v) => v.id);
81
84
  if (vectors.length > 0) {
82
85
  yield db.insertBatch(vectors);
83
86
  }
87
+ if (pendingDeletes.length > 0) {
88
+ if (newIds.length > 0) {
89
+ yield db.deletePathsExcludingIds(pendingDeletes, newIds);
90
+ }
91
+ else {
92
+ yield db.deletePaths(pendingDeletes);
93
+ }
94
+ }
84
95
  // 2. Update MetaCache only after VectorDB write succeeds
85
96
  for (const [p, entry] of pendingMeta.entries()) {
86
97
  meta.put(p, entry);
@@ -67,8 +67,17 @@ function processBatchCore(batch, metaCache, pool, signal) {
67
67
  }
68
68
  try {
69
69
  const stats = yield fs.promises.stat(absPath);
70
- if (!(0, file_utils_1.isIndexableFile)(absPath, stats.size))
70
+ if (!(0, file_utils_1.isIndexableFile)(absPath, stats.size)) {
71
+ // File became non-indexable (emptied or now too large). If we indexed
72
+ // it before, drop its chunks + meta so search stops returning stale
73
+ // content; otherwise there's nothing to clean up.
74
+ if (metaCache.get(absPath)) {
75
+ deletes.push(absPath);
76
+ metaDeletes.push(absPath);
77
+ reindexed++;
78
+ }
71
79
  continue;
80
+ }
72
81
  const cached = metaCache.get(absPath);
73
82
  if ((0, cache_check_1.isFileCached)(cached, stats)) {
74
83
  continue;
@@ -10,7 +10,7 @@ function envInt(key, fallback) {
10
10
  return Number.isFinite(n) && n > 0 ? n : fallback;
11
11
  }
12
12
  function getLlmConfig() {
13
- var _a, _b, _c;
13
+ var _a, _b, _c, _d;
14
14
  return {
15
15
  model: (_a = process.env.GMAX_LLM_MODEL) !== null && _a !== void 0 ? _a : DEFAULT_MODEL,
16
16
  binary: (_b = process.env.GMAX_LLM_BINARY) !== null && _b !== void 0 ? _b : "llama-server",
@@ -21,5 +21,6 @@ function getLlmConfig() {
21
21
  maxTokens: envInt("GMAX_LLM_MAX_TOKENS", 8192),
22
22
  idleTimeoutMin: envInt("GMAX_LLM_IDLE_TIMEOUT", 30),
23
23
  startupWaitSec: envInt("GMAX_LLM_STARTUP_WAIT", 60),
24
+ reasoningFormat: (_d = process.env.GMAX_LLM_REASONING_FORMAT) !== null && _d !== void 0 ? _d : "deepseek",
24
25
  };
25
26
  }
@@ -45,6 +45,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
45
45
  return (mod && mod.__esModule) ? mod : { "default": mod };
46
46
  };
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.looksLikeRawToolCall = looksLikeRawToolCall;
49
+ exports.toolCallLeakHint = toolCallLeakHint;
50
+ exports.finalizeAnswer = finalizeAnswer;
48
51
  exports.investigate = investigate;
49
52
  const path = __importStar(require("node:path"));
50
53
  const openai_1 = __importDefault(require("openai"));
@@ -58,6 +61,37 @@ const tools_1 = require("./tools");
58
61
  function stripThinkTags(text) {
59
62
  return text.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/g, "").trim();
60
63
  }
64
+ /**
65
+ * Detect raw model tool-call markup leaking into `message.content`.
66
+ *
67
+ * Some local models (notably Qwen3.5-35B-A3B, which emits Qwen-XML tool calls)
68
+ * trip a known llama.cpp `peg-native` parser bug: the call is never extracted
69
+ * into structured `tool_calls` and instead leaks into `content` as raw markup
70
+ * like `<tool_call>…`, `<function=peek>…`, or `<parameter=…>`. Returning that
71
+ * verbatim as the "answer" is what this guards against.
72
+ */
73
+ function looksLikeRawToolCall(text) {
74
+ return /<tool_call\b|<\/?function[=\s>]|<parameter[=\s>]/i.test(text);
75
+ }
76
+ /** Guidance shown when a non-tool-calling model leaks raw tool-call markup. */
77
+ function toolCallLeakHint(modelName) {
78
+ return (`The investigate tool needs a model that emits structured tool calls, but ` +
79
+ `"${modelName}" returned raw tool-call markup instead. This is a known ` +
80
+ `llama.cpp limitation for Qwen-XML tool-calling models — switch GMAX_LLM_MODEL ` +
81
+ `to a Hermes-JSON tool-calling model (e.g. Qwen3-30B-A3B-Instruct-2507), or ` +
82
+ `use review_commit / the gmax CLI search tools for this question.`);
83
+ }
84
+ /**
85
+ * Turn a model message's content into the final answer: detect a tool-call leak
86
+ * (return a clear hint), otherwise strip reasoning tags.
87
+ */
88
+ function finalizeAnswer(content, modelName) {
89
+ if (content && looksLikeRawToolCall(content)) {
90
+ return toolCallLeakHint(modelName);
91
+ }
92
+ const stripped = content ? stripThinkTags(content) : "";
93
+ return stripped || "(no response)";
94
+ }
61
95
  function investigate(opts) {
62
96
  return __awaiter(this, void 0, void 0, function* () {
63
97
  var _a, _b, _c;
@@ -116,9 +150,7 @@ function investigate(opts) {
116
150
  if (verbose) {
117
151
  process.stderr.write(`[R${round}] Final answer (${roundMs}ms)\n`);
118
152
  }
119
- const answer = message.content
120
- ? stripThinkTags(message.content)
121
- : "(no response)";
153
+ const answer = finalizeAnswer(message.content, modelName);
122
154
  return {
123
155
  answer,
124
156
  rounds,
@@ -194,6 +226,10 @@ function investigate(opts) {
194
226
  process.stderr.write(`[R${maxRounds}] Round cap reached, forcing final answer\n`);
195
227
  }
196
228
  messages.push({ role: "user", content: prompts_1.FORCE_FINAL_MESSAGE });
229
+ // No tools on the synthesis call. Note: tool_choice:"none" does NOT stop this
230
+ // model from emitting a stray tool call here (verified 2026-06-28 — it still
231
+ // leaks raw markup into content), so the finalizeAnswer guard is what protects
232
+ // this path rather than a request-shape tweak.
197
233
  const response = yield client.chat.completions.create({
198
234
  model: modelName,
199
235
  messages,
@@ -202,9 +238,7 @@ function investigate(opts) {
202
238
  if (!((_c = response.choices) === null || _c === void 0 ? void 0 : _c.length)) {
203
239
  throw new Error("LLM returned empty response");
204
240
  }
205
- const answer = response.choices[0].message.content
206
- ? stripThinkTags(response.choices[0].message.content)
207
- : "(no response)";
241
+ const answer = finalizeAnswer(response.choices[0].message.content, modelName);
208
242
  return {
209
243
  answer,
210
244
  rounds: rounds + 1,
@@ -143,7 +143,7 @@ class LlmServer {
143
143
  throw new Error(`Model file not found: "${this.config.model}". Set GMAX_LLM_MODEL to a valid .gguf path`);
144
144
  }
145
145
  const logFd = (0, log_rotate_1.openRotatedLog)(config_1.PATHS.llmLogFile);
146
- const child = (0, node_child_process_1.spawn)(binary, [
146
+ const args = [
147
147
  "-m",
148
148
  this.config.model,
149
149
  "--host",
@@ -154,7 +154,14 @@ class LlmServer {
154
154
  String(this.config.ngl),
155
155
  "--ctx-size",
156
156
  String(this.config.ctxSize),
157
- ], { detached: true, stdio: ["ignore", logFd, logFd] });
157
+ ];
158
+ if (this.config.reasoningFormat) {
159
+ args.push("--reasoning-format", this.config.reasoningFormat);
160
+ }
161
+ const child = (0, node_child_process_1.spawn)(binary, args, {
162
+ detached: true,
163
+ stdio: ["ignore", logFd, logFd],
164
+ });
158
165
  child.unref();
159
166
  fs.closeSync(logFd);
160
167
  const pid = child.pid;
@@ -244,7 +244,7 @@ function executePeek(args, ctx) {
244
244
  const metaRows = yield table
245
245
  .query()
246
246
  .select(["is_exported", "start_line", "end_line"])
247
- .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
247
+ .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}') AND ${(0, filter_builder_1.pathStartsWith)(prefix)}`)
248
248
  .limit(1)
249
249
  .toArray();
250
250
  const exported = metaRows.length > 0 && Boolean(metaRows[0].is_exported);
@@ -147,7 +147,7 @@ function buildGraphFromDb(db, pathPrefix) {
147
147
  const rows = yield table
148
148
  .query()
149
149
  .select(["defined_symbols", "referenced_symbols"])
150
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
150
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
151
151
  .toArray();
152
152
  const nodes = new Set();
153
153
  const edges = new Map();
@@ -51,7 +51,7 @@ function buildWhereClause(pathPrefix, filters, searchIntent) {
51
51
  var _a;
52
52
  const parts = [];
53
53
  if (pathPrefix) {
54
- parts.push(`path LIKE '${(0, filter_builder_1.escapeSqlString)((0, filter_builder_1.normalizePath)(pathPrefix))}%'`);
54
+ parts.push((0, filter_builder_1.pathStartsWith)((0, filter_builder_1.normalizePath)(pathPrefix)));
55
55
  }
56
56
  const fileFilter = filters === null || filters === void 0 ? void 0 : filters.file;
57
57
  if (typeof fileFilter === "string" && fileFilter) {
@@ -62,7 +62,7 @@ function buildWhereClause(pathPrefix, filters, searchIntent) {
62
62
  const absExclude = pathPrefix
63
63
  ? (0, filter_builder_1.normalizePath)(pathPrefix + excludeFilter)
64
64
  : excludeFilter;
65
- parts.push(`path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(absExclude)}%'`);
65
+ parts.push((0, filter_builder_1.pathNotStartsWith)(absExclude));
66
66
  }
67
67
  // New array-shape: pre-resolved absolute exclude prefixes from
68
68
  // resolveScope (or daemon IPC). Each becomes its own NOT LIKE clause.
@@ -71,7 +71,7 @@ function buildWhereClause(pathPrefix, filters, searchIntent) {
71
71
  for (const p of excludePrefixes) {
72
72
  if (typeof p === "string" && p) {
73
73
  const norm = (0, filter_builder_1.normalizePath)(p);
74
- parts.push(`path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(norm)}%'`);
74
+ parts.push((0, filter_builder_1.pathNotStartsWith)(norm));
75
75
  }
76
76
  }
77
77
  }
@@ -84,7 +84,7 @@ function buildWhereClause(pathPrefix, filters, searchIntent) {
84
84
  for (const p of inPrefixes) {
85
85
  if (typeof p === "string" && p) {
86
86
  const norm = (0, filter_builder_1.normalizePath)(p);
87
- clauses.push(`path LIKE '${(0, filter_builder_1.escapeSqlString)(norm)}%'`);
87
+ clauses.push((0, filter_builder_1.pathStartsWith)(norm));
88
88
  }
89
89
  }
90
90
  if (clauses.length === 1)
@@ -106,7 +106,7 @@ function buildWhereClause(pathPrefix, filters, searchIntent) {
106
106
  const roots = projectRoots.split(",");
107
107
  const clauses = roots.map((r) => {
108
108
  const prefix = r.endsWith("/") ? r : `${r}/`;
109
- return `path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`;
109
+ return (0, filter_builder_1.pathStartsWith)(prefix);
110
110
  });
111
111
  parts.push(`(${clauses.join(" OR ")})`);
112
112
  }
@@ -114,7 +114,7 @@ function buildWhereClause(pathPrefix, filters, searchIntent) {
114
114
  if (typeof excludeRoots === "string" && excludeRoots) {
115
115
  for (const r of excludeRoots.split(",")) {
116
116
  const prefix = r.endsWith("/") ? r : `${r}/`;
117
- parts.push(`path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`);
117
+ parts.push((0, filter_builder_1.pathNotStartsWith)(prefix));
118
118
  }
119
119
  }
120
120
  const defFilter = filters === null || filters === void 0 ? void 0 : filters.def;
@@ -48,6 +48,7 @@ const fs = __importStar(require("node:fs"));
48
48
  const lancedb = __importStar(require("@lancedb/lancedb"));
49
49
  const apache_arrow_1 = require("apache-arrow");
50
50
  const config_1 = require("../../config");
51
+ const index_config_1 = require("../index/index-config");
51
52
  const cleanup_1 = require("../utils/cleanup");
52
53
  const filter_builder_1 = require("../utils/filter-builder");
53
54
  const logger_1 = require("../utils/logger");
@@ -86,7 +87,10 @@ class VectorDB {
86
87
  this.activeWrites = 0;
87
88
  this.writeDrainResolve = null;
88
89
  this.compactingPromise = null;
89
- this.vectorDim = vectorDim !== null && vectorDim !== void 0 ? vectorDim : config_1.CONFIG.VECTOR_DIM;
90
+ // Default to the configured tier's dim (not the hard-wired small-tier 384)
91
+ // so a `standard`-tier index actually stores 768d vectors. An explicit
92
+ // arg still wins (eval scripts, tests).
93
+ this.vectorDim = vectorDim !== null && vectorDim !== void 0 ? vectorDim : (0, index_config_1.readGlobalConfig)().vectorDim;
90
94
  this.unregisterCleanup = (0, cleanup_1.registerCleanup)(() => this.close());
91
95
  }
92
96
  /**
@@ -411,11 +415,13 @@ class VectorDB {
411
415
  // Callers (syncer flushBatch) splice records before passing — they're never reused.
412
416
  for (const rec of records) {
413
417
  const vec = toNumberArray(rec.vector);
414
- if (vec.length < this.vectorDim) {
415
- vec.push(...Array(this.vectorDim - vec.length).fill(0));
416
- }
417
- else if (vec.length > this.vectorDim) {
418
- vec.length = this.vectorDim;
418
+ // Never silently pad/truncate: a width mismatch means the embedding tier
419
+ // and this table disagree (wrong model wired, or a stale index after a
420
+ // tier change). Reshaping would store garbage that scores meaninglessly.
421
+ // Fail loudly and point at the fix instead.
422
+ if (vec.length !== this.vectorDim) {
423
+ throw new Error(`Vector dimension mismatch: got ${vec.length}d, expected ${this.vectorDim}d. ` +
424
+ "The embedding model tier likely changed without a rebuild — run `gmax index --reset`.");
419
425
  }
420
426
  rec.vector = vec;
421
427
  rec.colbert = toBuffer(rec.colbert);
@@ -676,7 +682,7 @@ class VectorDB {
676
682
  const rows = yield table
677
683
  .query()
678
684
  .select(["id"])
679
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
685
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
680
686
  .limit(1)
681
687
  .toArray();
682
688
  return rows.length > 0;
@@ -686,7 +692,7 @@ class VectorDB {
686
692
  return __awaiter(this, void 0, void 0, function* () {
687
693
  const table = yield this.ensureTable();
688
694
  const prefix = pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`;
689
- return table.countRows(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`);
695
+ return table.countRows((0, filter_builder_1.pathStartsWith)(prefix));
690
696
  });
691
697
  }
692
698
  countDistinctFilesForPath(pathPrefix) {
@@ -701,7 +707,7 @@ class VectorDB {
701
707
  const rows = yield table
702
708
  .query()
703
709
  .select(["path"])
704
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
710
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
705
711
  .toArray();
706
712
  const unique = new Set();
707
713
  for (const r of rows) {
@@ -804,7 +810,12 @@ class VectorDB {
804
810
  return __awaiter(this, void 0, void 0, function* () {
805
811
  this.ensureDiskOk();
806
812
  const table = yield this.ensureTable();
807
- yield this.withWriteGate(() => table.delete(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`));
813
+ // Slash-terminate so a project root can't bleed into a sibling
814
+ // (`/repo/app` must not delete `/repo/app2`), and use starts_with so `_`/`%`
815
+ // in the path are literal, not LIKE wildcards. Destructive path — keep this
816
+ // self-protective even if a caller forgets to normalize.
817
+ const dirPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
818
+ yield this.withWriteGate(() => table.delete((0, filter_builder_1.pathStartsWith)(dirPrefix)));
808
819
  });
809
820
  }
810
821
  drop() {
@@ -45,6 +45,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.sendDaemonCommand = sendDaemonCommand;
46
46
  exports.isDaemonRunning = isDaemonRunning;
47
47
  exports.isDaemonHeartbeatFresh = isDaemonHeartbeatFresh;
48
+ exports.readDaemonPid = readDaemonPid;
49
+ exports.waitForProcessExit = waitForProcessExit;
48
50
  exports.ensureDaemonRunning = ensureDaemonRunning;
49
51
  exports.sendStreamingCommand = sendStreamingCommand;
50
52
  const fs = __importStar(require("node:fs"));
@@ -152,6 +154,38 @@ function isDaemonHeartbeatFresh() {
152
154
  return false;
153
155
  }
154
156
  }
157
+ /** Read the daemon's PID from the PID file, or null if absent/invalid. */
158
+ function readDaemonPid() {
159
+ try {
160
+ const pid = parseInt(fs.readFileSync(config_1.PATHS.daemonPidFile, "utf-8").trim(), 10);
161
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
162
+ }
163
+ catch (_a) {
164
+ return null;
165
+ }
166
+ }
167
+ /**
168
+ * Poll until a process has fully exited, or the timeout elapses. Used before
169
+ * spawning a successor daemon: shutdown() drops the socket/lock immediately but
170
+ * keeps draining in-flight work, and a successor that starts in that window
171
+ * classifies the still-draining predecessor as stale and SIGKILLs it. Waiting
172
+ * for the actual process exit closes that race. Returns true if it exited.
173
+ */
174
+ function waitForProcessExit(pid, timeoutMs) {
175
+ return __awaiter(this, void 0, void 0, function* () {
176
+ const deadline = Date.now() + timeoutMs;
177
+ while (Date.now() < deadline) {
178
+ try {
179
+ process.kill(pid, 0);
180
+ }
181
+ catch (_a) {
182
+ return true; // ESRCH — process is gone
183
+ }
184
+ yield new Promise((r) => setTimeout(r, 100));
185
+ }
186
+ return false;
187
+ });
188
+ }
155
189
  /**
156
190
  * Ensure the daemon is running — start it if needed, poll up to 5s.
157
191
  * Returns true if daemon is ready, false if it couldn't be started.
@@ -1,12 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.escapeSqlString = escapeSqlString;
4
+ exports.pathStartsWith = pathStartsWith;
5
+ exports.pathNotStartsWith = pathNotStartsWith;
4
6
  exports.normalizePath = normalizePath;
5
7
  function escapeSqlString(str) {
6
8
  // LanceDB (via DataFusion) treats backslashes literally in standard strings.
7
9
  // We only need to escape single quotes by doubling them.
8
10
  return str.replace(/'/g, "''");
9
11
  }
12
+ /**
13
+ * SQL predicate matching rows whose `path` begins with `prefix`.
14
+ *
15
+ * Use this instead of `path LIKE '<prefix>%'` for any path-prefix scope.
16
+ * `escapeSqlString` only neutralizes quotes, not LIKE metacharacters, so a
17
+ * prefix containing `_` (matches any single char) or `%` (matches anything)
18
+ * would silently match — and delete — across sibling projects
19
+ * (`/repo/my_app/` would match `/repo/myXapp/`). `starts_with()` has no
20
+ * wildcard semantics, so the prefix is matched literally.
21
+ *
22
+ * Callers are responsible for trailing-slash boundary correctness: pass
23
+ * `/repo/app/` (not `/repo/app`) so the scope can't bleed into `/repo/app2/`.
24
+ */
25
+ function pathStartsWith(prefix) {
26
+ return `starts_with(path, '${escapeSqlString(prefix)}')`;
27
+ }
28
+ /** Negation of {@link pathStartsWith} — excludes paths under `prefix`. */
29
+ function pathNotStartsWith(prefix) {
30
+ return `NOT ${pathStartsWith(prefix)}`;
31
+ }
10
32
  /**
11
33
  * Normalizes a path to use forward slashes, ensuring consistency across platforms.
12
34
  * @param p The path to normalize
@@ -92,14 +92,12 @@ function buildScopeWhere(scope, condition) {
92
92
  const parts = [];
93
93
  if (condition)
94
94
  parts.push(condition);
95
- parts.push(`path LIKE '${(0, filter_builder_1.escapeSqlString)(scope.pathPrefix)}%'`);
95
+ parts.push((0, filter_builder_1.pathStartsWith)(scope.pathPrefix));
96
96
  for (const ex of scope.excludePrefixes) {
97
- parts.push(`path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(ex)}%'`);
97
+ parts.push((0, filter_builder_1.pathNotStartsWith)(ex));
98
98
  }
99
99
  if (scope.inPrefixes.length > 0) {
100
- const ors = scope.inPrefixes
101
- .map((p) => `path LIKE '${(0, filter_builder_1.escapeSqlString)(p)}%'`)
102
- .join(" OR ");
100
+ const ors = scope.inPrefixes.map((p) => (0, filter_builder_1.pathStartsWith)(p)).join(" OR ");
103
101
  parts.push(`(${ors})`);
104
102
  }
105
103
  return parts.join(" AND ");
@@ -63,7 +63,10 @@ class GraniteModel {
63
63
  this.vectorDimensions = vectorDim !== null && vectorDim !== void 0 ? vectorDim : config_1.CONFIG.VECTOR_DIM;
64
64
  }
65
65
  resolvePaths() {
66
- const basePath = path.join(CACHE_DIR, config_1.MODEL_IDS.embed);
66
+ // Honor the active tier's ONNX model (propagated from the pool); fall back
67
+ // to the small-tier default when unset (standalone/test invocations).
68
+ const modelId = process.env.GMAX_EMBED_ONNX_MODEL || config_1.MODEL_IDS.embed;
69
+ const basePath = path.join(CACHE_DIR, modelId);
67
70
  const onnxDir = path.join(basePath, "onnx");
68
71
  const candidates = ["model_q4.onnx", "model.onnx"];
69
72
  for (const candidate of candidates) {
@@ -43,6 +43,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.WorkerPool = void 0;
46
+ exports.embeddingEnv = embeddingEnv;
46
47
  exports.getWorkerPool = getWorkerPool;
47
48
  exports.destroyWorkerPool = destroyWorkerPool;
48
49
  exports.isWorkerPoolInitialized = isWorkerPoolInitialized;
@@ -54,6 +55,7 @@ const childProcess = __importStar(require("node:child_process"));
54
55
  const fs = __importStar(require("node:fs"));
55
56
  const path = __importStar(require("node:path"));
56
57
  const config_1 = require("../../config");
58
+ const index_config_1 = require("../index/index-config");
57
59
  const logger_1 = require("../utils/logger");
58
60
  function reviveBufferLike(input) {
59
61
  if (input &&
@@ -110,6 +112,22 @@ const FORCE_KILL_GRACE_MS = 200;
110
112
  // escalate to SIGKILL. ~5s is well above ONNX teardown time but short
111
113
  // enough that the reap loop self-heals within a minute.
112
114
  const REAP_FORCE_KILL_GRACE_MS = 5000;
115
+ /**
116
+ * Embedding env derived from the active model tier, injected into every spawned
117
+ * worker so the worker's VectorDB dim (GMAX_VECTOR_DIM) and CPU ONNX model
118
+ * (GMAX_EMBED_ONNX_MODEL) match the configured tier. Without this, workers fall
119
+ * back to the hard-wired small tier (384d) regardless of config. One spot covers
120
+ * every worker user (index, add, search, daemon, mcp). Merged so process.env can
121
+ * override — see the spread order at fork().
122
+ */
123
+ function embeddingEnv() {
124
+ const { modelTier } = (0, index_config_1.readGlobalConfig)();
125
+ const ids = (0, index_config_1.getModelIdsForTier)(modelTier);
126
+ return {
127
+ GMAX_VECTOR_DIM: String(ids.vectorDim),
128
+ GMAX_EMBED_ONNX_MODEL: ids.embed,
129
+ };
130
+ }
113
131
  class ProcessWorker {
114
132
  constructor(modulePath, execArgv, maxMemoryMb) {
115
133
  this.modulePath = modulePath;
@@ -127,9 +145,11 @@ class ProcessWorker {
127
145
  // fire for the same crash.
128
146
  this.cleanedUp = false;
129
147
  const memArgs = maxMemoryMb ? [`--max-old-space-size=${maxMemoryMb}`] : [];
148
+ // embeddingEnv() first so a manually-exported GMAX_VECTOR_DIM /
149
+ // GMAX_EMBED_ONNX_MODEL in process.env still wins (spread last).
130
150
  this.child = childProcess.fork(modulePath, {
131
151
  execArgv: [...memArgs, ...execArgv],
132
- env: Object.assign({}, process.env),
152
+ env: Object.assign(Object.assign({}, embeddingEnv()), process.env),
133
153
  });
134
154
  }
135
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {
@@ -65,6 +65,7 @@
65
65
  ],
66
66
  "files": [
67
67
  "dist",
68
+ ".claude-plugin",
68
69
  "plugins",
69
70
  "scripts/postinstall.js",
70
71
  "mlx-embed-server",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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",