grepmax 0.19.0 → 0.21.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 (43) hide show
  1. package/.claude-plugin/marketplace.json +23 -0
  2. package/README.md +1 -1
  3. package/dist/commands/codex.js +5 -2
  4. package/dist/commands/context.js +1 -1
  5. package/dist/commands/doctor.js +37 -7
  6. package/dist/commands/droid.js +61 -9
  7. package/dist/commands/mcp.js +7 -7
  8. package/dist/commands/plugin.js +35 -21
  9. package/dist/commands/project.js +1 -1
  10. package/dist/commands/remove.js +9 -2
  11. package/dist/commands/repair.js +186 -0
  12. package/dist/commands/search-run.js +12 -1
  13. package/dist/commands/setup.js +4 -2
  14. package/dist/commands/status.js +1 -1
  15. package/dist/commands/symbols.js +1 -1
  16. package/dist/commands/watch.js +25 -4
  17. package/dist/config.js +45 -1
  18. package/dist/index.js +2 -0
  19. package/dist/lib/daemon/daemon.js +177 -48
  20. package/dist/lib/daemon/ipc-handler.js +12 -0
  21. package/dist/lib/daemon/process-manager.js +21 -3
  22. package/dist/lib/daemon/watcher-manager.js +18 -2
  23. package/dist/lib/graph/graph-builder.js +2 -2
  24. package/dist/lib/graph/impact.js +6 -6
  25. package/dist/lib/index/batch-processor.js +10 -1
  26. package/dist/lib/index/syncer.js +15 -4
  27. package/dist/lib/index/watcher-batch.js +10 -1
  28. package/dist/lib/llm/config.js +2 -1
  29. package/dist/lib/llm/investigate.js +40 -6
  30. package/dist/lib/llm/server.js +9 -2
  31. package/dist/lib/llm/tools.js +1 -1
  32. package/dist/lib/search/pagerank.js +1 -1
  33. package/dist/lib/search/searcher.js +6 -6
  34. package/dist/lib/store/vector-db.js +49 -10
  35. package/dist/lib/utils/daemon-client.js +87 -0
  36. package/dist/lib/utils/filter-builder.js +22 -0
  37. package/dist/lib/utils/scope-filter.js +3 -5
  38. package/dist/lib/workers/embeddings/granite.js +4 -1
  39. package/dist/lib/workers/pool.js +21 -1
  40. package/package.json +2 -1
  41. package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
  42. package/plugins/grepmax/agents/semantic-explore.md +1 -1
  43. package/plugins/grepmax/skills/grepmax/SKILL.md +1 -1
@@ -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
  /**
@@ -275,6 +279,32 @@ class VectorDB {
275
279
  summary: "",
276
280
  };
277
281
  }
282
+ /**
283
+ * Read the physical width of the on-disk `vector` column, or null if the
284
+ * table doesn't exist yet. Non-throwing and validation-free on purpose: doctor
285
+ * uses it to detect a table stranded at an old width after a tier change, and
286
+ * must see the truth even when the table is incompatible with the current
287
+ * config (a throwing ensureTable would mask exactly the mismatch we're hunting).
288
+ */
289
+ getSchemaVectorDim() {
290
+ return __awaiter(this, void 0, void 0, function* () {
291
+ const db = yield this.getDb();
292
+ let table;
293
+ try {
294
+ table = yield db.openTable(TABLE_NAME);
295
+ }
296
+ catch (_a) {
297
+ return null; // no table on disk yet
298
+ }
299
+ const schema = yield table.schema();
300
+ const field = schema.fields.find((f) => f.name === "vector");
301
+ if (!field)
302
+ return null;
303
+ // The `vector` column is a FixedSizeList; its listSize is the vector width.
304
+ const listSize = field.type.listSize;
305
+ return typeof listSize === "number" ? listSize : null;
306
+ });
307
+ }
278
308
  validateSchema(table) {
279
309
  return __awaiter(this, void 0, void 0, function* () {
280
310
  const schema = yield table.schema();
@@ -411,11 +441,15 @@ class VectorDB {
411
441
  // Callers (syncer flushBatch) splice records before passing — they're never reused.
412
442
  for (const rec of records) {
413
443
  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;
444
+ // Never silently pad/truncate: a width mismatch means the embedding tier
445
+ // and this table disagree (wrong model wired, or a stale index after a
446
+ // tier change). Reshaping would store garbage that scores meaninglessly.
447
+ // Fail loudly and point at the fix instead.
448
+ if (vec.length !== this.vectorDim) {
449
+ throw new Error(`Vector dimension mismatch: got ${vec.length}d, expected ${this.vectorDim}d. ` +
450
+ "The embedding model tier likely changed without a rebuild — the shared " +
451
+ `table is fixed-width, so run \`${config_1.REBUILD_COMMAND}\` (a per-project ` +
452
+ "`gmax index --reset` cannot change the table width).");
419
453
  }
420
454
  rec.vector = vec;
421
455
  rec.colbert = toBuffer(rec.colbert);
@@ -676,7 +710,7 @@ class VectorDB {
676
710
  const rows = yield table
677
711
  .query()
678
712
  .select(["id"])
679
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
713
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
680
714
  .limit(1)
681
715
  .toArray();
682
716
  return rows.length > 0;
@@ -686,7 +720,7 @@ class VectorDB {
686
720
  return __awaiter(this, void 0, void 0, function* () {
687
721
  const table = yield this.ensureTable();
688
722
  const prefix = pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`;
689
- return table.countRows(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`);
723
+ return table.countRows((0, filter_builder_1.pathStartsWith)(prefix));
690
724
  });
691
725
  }
692
726
  countDistinctFilesForPath(pathPrefix) {
@@ -701,7 +735,7 @@ class VectorDB {
701
735
  const rows = yield table
702
736
  .query()
703
737
  .select(["path"])
704
- .where(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`)
738
+ .where((0, filter_builder_1.pathStartsWith)(prefix))
705
739
  .toArray();
706
740
  const unique = new Set();
707
741
  for (const r of rows) {
@@ -804,7 +838,12 @@ class VectorDB {
804
838
  return __awaiter(this, void 0, void 0, function* () {
805
839
  this.ensureDiskOk();
806
840
  const table = yield this.ensureTable();
807
- yield this.withWriteGate(() => table.delete(`path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`));
841
+ // Slash-terminate so a project root can't bleed into a sibling
842
+ // (`/repo/app` must not delete `/repo/app2`), and use starts_with so `_`/`%`
843
+ // in the path are literal, not LIKE wildcards. Destructive path — keep this
844
+ // self-protective even if a caller forgets to normalize.
845
+ const dirPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
846
+ yield this.withWriteGate(() => table.delete((0, filter_builder_1.pathStartsWith)(dirPrefix)));
808
847
  });
809
848
  }
810
849
  drop() {
@@ -45,6 +45,11 @@ 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;
50
+ exports.writeDrainingMarker = writeDrainingMarker;
51
+ exports.clearDrainingMarker = clearDrainingMarker;
52
+ exports.isDaemonDraining = isDaemonDraining;
48
53
  exports.ensureDaemonRunning = ensureDaemonRunning;
49
54
  exports.sendStreamingCommand = sendStreamingCommand;
50
55
  const fs = __importStar(require("node:fs"));
@@ -152,6 +157,88 @@ function isDaemonHeartbeatFresh() {
152
157
  return false;
153
158
  }
154
159
  }
160
+ /** Read the daemon's PID from the PID file, or null if absent/invalid. */
161
+ function readDaemonPid() {
162
+ try {
163
+ const pid = parseInt(fs.readFileSync(config_1.PATHS.daemonPidFile, "utf-8").trim(), 10);
164
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
165
+ }
166
+ catch (_a) {
167
+ return null;
168
+ }
169
+ }
170
+ /**
171
+ * Poll until a process has fully exited, or the timeout elapses. Used before
172
+ * spawning a successor daemon: shutdown() drops the socket/lock immediately but
173
+ * keeps draining in-flight work, and a successor that starts in that window
174
+ * classifies the still-draining predecessor as stale and SIGKILLs it. Waiting
175
+ * for the actual process exit closes that race. Returns true if it exited.
176
+ */
177
+ function waitForProcessExit(pid, timeoutMs) {
178
+ return __awaiter(this, void 0, void 0, function* () {
179
+ const deadline = Date.now() + timeoutMs;
180
+ while (Date.now() < deadline) {
181
+ try {
182
+ process.kill(pid, 0);
183
+ }
184
+ catch (_a) {
185
+ return true; // ESRCH — process is gone
186
+ }
187
+ yield new Promise((r) => setTimeout(r, 100));
188
+ }
189
+ return false;
190
+ });
191
+ }
192
+ // A daemon's graceful shutdown can run well past the 20s restart wait — worker
193
+ // SIGTERM→SIGKILL escalation (5s), maintenance drain (10s), LanceDB close (5s),
194
+ // LLM/MLX teardown. Treat a draining marker as authoritative for this long; past
195
+ // it, assume the draining daemon wedged and let killStaleProcesses reclaim it.
196
+ const DRAIN_GRACE_MS = 90000;
197
+ /**
198
+ * Record that this daemon (pid) has begun graceful shutdown, so a successor's
199
+ * killStaleProcesses() won't SIGKILL it mid-cleanup after it drops its
200
+ * socket/PID/lock liveness markers. Best-effort; cleared by clearDrainingMarker
201
+ * on a clean exit and otherwise self-expires after DRAIN_GRACE_MS.
202
+ */
203
+ function writeDrainingMarker(pid) {
204
+ try {
205
+ fs.mkdirSync(config_1.PATHS.globalRoot, { recursive: true });
206
+ fs.writeFileSync(config_1.PATHS.daemonDrainingFile, JSON.stringify({ pid, ts: Date.now() }));
207
+ }
208
+ catch (_a) { }
209
+ }
210
+ /** Remove the draining marker (clean end of shutdown). */
211
+ function clearDrainingMarker() {
212
+ try {
213
+ fs.unlinkSync(config_1.PATHS.daemonDrainingFile);
214
+ }
215
+ catch (_a) { }
216
+ }
217
+ /**
218
+ * True if `pid` is a daemon currently inside graceful shutdown: a fresh draining
219
+ * marker naming that exact PID, and the process is still alive. A stale marker
220
+ * (older than DRAIN_GRACE_MS), a mismatched PID, or a dead process all read as
221
+ * "not draining" so a wedged or already-exited predecessor is still reclaimable.
222
+ */
223
+ function isDaemonDraining(pid) {
224
+ try {
225
+ const { pid: markerPid, ts } = JSON.parse(fs.readFileSync(config_1.PATHS.daemonDrainingFile, "utf-8"));
226
+ if (markerPid !== pid)
227
+ return false;
228
+ if (typeof ts !== "number" || Date.now() - ts > DRAIN_GRACE_MS)
229
+ return false;
230
+ try {
231
+ process.kill(pid, 0);
232
+ return true;
233
+ }
234
+ catch (_a) {
235
+ return false; // process already gone — done draining
236
+ }
237
+ }
238
+ catch (_b) {
239
+ return false; // no marker / unreadable
240
+ }
241
+ }
155
242
  /**
156
243
  * Ensure the daemon is running — start it if needed, poll up to 5s.
157
244
  * 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.21.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.21.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",
@@ -41,7 +41,7 @@ gmax trace handleAuth -d 2
41
41
 
42
42
  **File structure** — collapsed signatures (~4x fewer tokens than reading):
43
43
  ```
44
- gmax skeleton src/lib/auth/
44
+ gmax skeleton src/lib/auth.ts
45
45
  ```
46
46
 
47
47
  **Project overview** — languages, structure, key entry points:
@@ -116,7 +116,7 @@ Agent output ends with `t: <test-file>:line\t<test-symbol>\t<hop-label>` rows wh
116
116
  ### Skeleton — `gmax skeleton <target>`
117
117
  ```
118
118
  gmax skeleton src/lib/auth.ts # single file
119
- gmax skeleton src/lib/search/ # entire directory
119
+ gmax skeleton handleAuth # by symbol name (finds its file)
120
120
  gmax skeleton src/a.ts,src/b.ts # batch
121
121
  gmax skeleton src/lib/auth.ts --json # structured JSON output
122
122
  ```