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.
- package/.claude-plugin/marketplace.json +23 -0
- package/README.md +1 -1
- package/dist/commands/codex.js +5 -2
- package/dist/commands/context.js +1 -1
- package/dist/commands/doctor.js +37 -7
- package/dist/commands/droid.js +61 -9
- package/dist/commands/mcp.js +7 -7
- package/dist/commands/plugin.js +35 -21
- package/dist/commands/project.js +1 -1
- package/dist/commands/remove.js +9 -2
- package/dist/commands/repair.js +186 -0
- package/dist/commands/search-run.js +12 -1
- package/dist/commands/setup.js +4 -2
- package/dist/commands/status.js +1 -1
- package/dist/commands/symbols.js +1 -1
- package/dist/commands/watch.js +25 -4
- package/dist/config.js +45 -1
- package/dist/index.js +2 -0
- package/dist/lib/daemon/daemon.js +177 -48
- package/dist/lib/daemon/ipc-handler.js +12 -0
- package/dist/lib/daemon/process-manager.js +21 -3
- package/dist/lib/daemon/watcher-manager.js +18 -2
- package/dist/lib/graph/graph-builder.js +2 -2
- package/dist/lib/graph/impact.js +6 -6
- package/dist/lib/index/batch-processor.js +10 -1
- package/dist/lib/index/syncer.js +15 -4
- package/dist/lib/index/watcher-batch.js +10 -1
- package/dist/lib/llm/config.js +2 -1
- package/dist/lib/llm/investigate.js +40 -6
- package/dist/lib/llm/server.js +9 -2
- package/dist/lib/llm/tools.js +1 -1
- package/dist/lib/search/pagerank.js +1 -1
- package/dist/lib/search/searcher.js +6 -6
- package/dist/lib/store/vector-db.js +49 -10
- package/dist/lib/utils/daemon-client.js +87 -0
- package/dist/lib/utils/filter-builder.js +22 -0
- package/dist/lib/utils/scope-filter.js +3 -5
- package/dist/lib/workers/embeddings/granite.js +4 -1
- package/dist/lib/workers/pool.js +21 -1
- package/package.json +2 -1
- package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
- package/plugins/grepmax/agents/semantic-explore.md +1 -1
- package/plugins/grepmax/skills/grepmax/SKILL.md +1 -1
package/dist/lib/index/syncer.js
CHANGED
|
@@ -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.
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
package/dist/lib/llm/config.js
CHANGED
|
@@ -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,
|
package/dist/lib/llm/server.js
CHANGED
|
@@ -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
|
|
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
|
-
]
|
|
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;
|
package/dist/lib/llm/tools.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
95
|
+
parts.push((0, filter_builder_1.pathStartsWith)(scope.pathPrefix));
|
|
96
96
|
for (const ex of scope.excludePrefixes) {
|
|
97
|
-
parts.push(
|
|
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
|
-
|
|
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) {
|
package/dist/lib/workers/pool.js
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -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
|
|
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
|
```
|