grepmax 0.26.27 → 0.26.30

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 (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +40 -0
  3. package/dist/commands/audit.js +28 -260
  4. package/dist/commands/context.js +42 -26
  5. package/dist/commands/dead.js +37 -35
  6. package/dist/commands/doctor.js +280 -10
  7. package/dist/commands/extract.js +79 -49
  8. package/dist/commands/impact.js +74 -20
  9. package/dist/commands/log.js +25 -25
  10. package/dist/commands/mcp.js +639 -475
  11. package/dist/commands/peek.js +49 -47
  12. package/dist/commands/project.js +29 -107
  13. package/dist/commands/related.js +43 -52
  14. package/dist/commands/search-run.js +23 -8
  15. package/dist/commands/search.js +7 -2
  16. package/dist/commands/similar.js +50 -76
  17. package/dist/commands/skeleton.js +137 -83
  18. package/dist/commands/status.js +90 -25
  19. package/dist/commands/surprises.js +73 -16
  20. package/dist/commands/symbols.js +58 -77
  21. package/dist/commands/test-find.js +51 -17
  22. package/dist/commands/trace.js +26 -11
  23. package/dist/commands/watch.js +1 -1
  24. package/dist/config.js +8 -2
  25. package/dist/lib/analysis/surprising-connections.js +135 -15
  26. package/dist/lib/daemon/daemon.js +157 -13
  27. package/dist/lib/daemon/graph-handler.js +859 -0
  28. package/dist/lib/daemon/ipc-handler.js +36 -4
  29. package/dist/lib/daemon/read-verbs.js +156 -0
  30. package/dist/lib/daemon/rows-handler.js +665 -0
  31. package/dist/lib/daemon/search-handler.js +7 -4
  32. package/dist/lib/daemon/vector-handler.js +199 -0
  33. package/dist/lib/daemon/watch-leases.js +179 -0
  34. package/dist/lib/index/batch-processor.js +7 -1
  35. package/dist/lib/index/watcher.js +4 -0
  36. package/dist/lib/search/pagerank.js +80 -19
  37. package/dist/lib/store/vector-db.js +35 -1
  38. package/dist/lib/utils/async-semaphore.js +141 -0
  39. package/dist/lib/utils/daemon-client.js +11 -0
  40. package/dist/lib/utils/process-footprint.js +51 -0
  41. package/dist/lib/utils/store-access.js +381 -0
  42. package/dist/lib/utils/tests-footer.js +19 -2
  43. package/dist/lib/utils/watcher-launcher.js +46 -19
  44. package/dist/lib/workers/pool.js +72 -2
  45. package/package.json +1 -1
  46. package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
  47. package/plugins/grepmax/hooks/cwd-changed.js +19 -36
  48. package/plugins/grepmax/hooks/start.js +15 -19
  49. package/plugins/grepmax/hooks/stop.js +13 -4
  50. package/plugins/grepmax/hooks/watch-lease.js +163 -0
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ /**
3
+ * Daemon-side handlers for the `vector.*` read verbs — the two commands whose
4
+ * store work is a vector scan rather than a row select.
5
+ *
6
+ * Both ship the *analysed* answer, never the vectors:
7
+ *
8
+ * - `vector.similar` runs the source-chunk lookup, `configureAnnVectorQuery`,
9
+ * `table.vectorSearch`, and the self/threshold filter, and returns the ranked
10
+ * chunks. The 384-float source vector never crosses the socket.
11
+ * - `vector.surprises` runs `analyzeSurprisingConnections` and returns the
12
+ * summary plus the findings the caller will actually print. The raw `pairs`
13
+ * array is dropped: nothing renders it, and it is the largest field by far.
14
+ *
15
+ * As in rows-handler.ts, each `run*` function is the single implementation both
16
+ * the verb and the command's in-process fallback call, so the two paths cannot
17
+ * produce different output.
18
+ */
19
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
20
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
21
+ return new (P || (P = Promise))(function (resolve, reject) {
22
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
23
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
24
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
25
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
26
+ });
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.runSimilar = runSimilar;
30
+ exports.handleVectorSimilar = handleVectorSimilar;
31
+ exports.runSurprises = runSurprises;
32
+ exports.handleVectorSurprises = handleVectorSurprises;
33
+ exports.registerVectorVerbs = registerVectorVerbs;
34
+ const surprising_connections_1 = require("../analysis/surprising-connections");
35
+ const ann_config_1 = require("../store/ann-config");
36
+ const arrow_1 = require("../utils/arrow");
37
+ const filter_builder_1 = require("../utils/filter-builder");
38
+ const path_containment_1 = require("../utils/path-containment");
39
+ const scope_filter_1 = require("../utils/scope-filter");
40
+ const read_verbs_1 = require("./read-verbs");
41
+ const rows_handler_1 = require("./rows-handler");
42
+ function runSimilar(deps, req) {
43
+ return __awaiter(this, void 0, void 0, function* () {
44
+ var _a, _b;
45
+ if (!deps.vectorDb)
46
+ throw new rows_handler_1.ReadVerbError("daemon not ready");
47
+ (_a = deps.touchActivity) === null || _a === void 0 ? void 0 : _a.call(deps);
48
+ const table = yield deps.vectorDb.ensureTable();
49
+ const sourceSelect = ["vector", "path", "defined_symbols", "start_line"];
50
+ const sourceRows = yield table
51
+ .query()
52
+ .select(sourceSelect)
53
+ .where(req.absPath !== undefined
54
+ ? `path = '${(0, filter_builder_1.escapeSqlString)(req.absPath)}'`
55
+ : `array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)((_b = req.symbol) !== null && _b !== void 0 ? _b : "")}') AND ${(0, filter_builder_1.pathStartsWith)(`${req.projectRoot}/`)}`)
56
+ .limit(1)
57
+ .toArray();
58
+ if (sourceRows.length === 0)
59
+ return { status: "not-found" };
60
+ const source = sourceRows[0];
61
+ const sourceVector = source.vector;
62
+ const sourcePath = String(source.path || "");
63
+ if (!sourceVector || sourceVector.length === 0)
64
+ return { status: "no-vector" };
65
+ const pathScope = (0, scope_filter_1.buildScopeWhere)(req.scope);
66
+ const results = yield (0, ann_config_1.configureAnnVectorQuery)(table.vectorSearch(sourceVector))
67
+ .select([
68
+ "path",
69
+ "start_line",
70
+ "end_line",
71
+ "defined_symbols",
72
+ "role",
73
+ "content",
74
+ "_distance",
75
+ ])
76
+ .where(pathScope)
77
+ .limit(req.limit + 5) // fetch extra to account for self-filtering
78
+ .toArray();
79
+ // Filter out self and apply threshold
80
+ const filtered = results.filter((raw) => {
81
+ const r = raw;
82
+ if (r.path === sourcePath && r.start_line === source.start_line)
83
+ return false;
84
+ if (req.threshold > 0) {
85
+ // LanceDB returns L2 distance; convert to similarity
86
+ const sim = 1 / (1 + (Number(r._distance) || 0));
87
+ if (sim < req.threshold)
88
+ return false;
89
+ }
90
+ return true;
91
+ });
92
+ // `content` is selected (keeping the query identical) but never rendered, so
93
+ // it is dropped here rather than shipped.
94
+ return {
95
+ status: "ok",
96
+ results: filtered.map((raw) => {
97
+ var _a, _b, _c, _d, _e;
98
+ const r = raw;
99
+ return {
100
+ path: String((_a = r.path) !== null && _a !== void 0 ? _a : ""),
101
+ start_line: Number((_b = r.start_line) !== null && _b !== void 0 ? _b : 0),
102
+ end_line: Number((_c = r.end_line) !== null && _c !== void 0 ? _c : 0),
103
+ defined_symbols: (0, arrow_1.toArr)(r.defined_symbols),
104
+ role: String((_d = r.role) !== null && _d !== void 0 ? _d : ""),
105
+ _distance: Number((_e = r._distance) !== null && _e !== void 0 ? _e : 0),
106
+ };
107
+ }),
108
+ };
109
+ });
110
+ }
111
+ function handleVectorSimilar(deps, payload) {
112
+ return __awaiter(this, void 0, void 0, function* () {
113
+ return (0, rows_handler_1.asReadVerbResponse)(() => __awaiter(this, void 0, void 0, function* () {
114
+ const projectRoot = (0, rows_handler_1.assertReadableProject)(payload.projectRoot);
115
+ const scope = (0, rows_handler_1.resolveWireScope)(projectRoot, payload.scope);
116
+ const absPath = typeof payload.absPath === "string" && payload.absPath
117
+ ? (0, path_containment_1.resolveContainedPath)(projectRoot, payload.absPath)
118
+ : undefined;
119
+ const symbol = typeof payload.symbol === "string" && payload.symbol
120
+ ? payload.symbol
121
+ : undefined;
122
+ if (!absPath && !symbol)
123
+ throw new rows_handler_1.ReadVerbError("missing target");
124
+ const result = yield runSimilar(deps, {
125
+ projectRoot,
126
+ absPath,
127
+ symbol,
128
+ scope,
129
+ limit: (0, rows_handler_1.clampInt)(payload.limit, 5, 1, 25),
130
+ threshold: typeof payload.threshold === "number" &&
131
+ Number.isFinite(payload.threshold)
132
+ ? payload.threshold
133
+ : 0,
134
+ });
135
+ return Object.assign({ ok: true }, result);
136
+ }));
137
+ });
138
+ }
139
+ function runSurprises(deps, req) {
140
+ return __awaiter(this, void 0, void 0, function* () {
141
+ var _a;
142
+ if (!deps.vectorDb)
143
+ throw new rows_handler_1.ReadVerbError("daemon not ready");
144
+ (_a = deps.touchActivity) === null || _a === void 0 ? void 0 : _a.call(deps);
145
+ const table = yield deps.vectorDb.ensureTable();
146
+ const result = yield (0, surprising_connections_1.analyzeSurprisingConnections)(table, req.projectRoot, req.options);
147
+ // Both formatters use only `summary` and `findings.slice(0, top)`, plus
148
+ // `findings.length === 0`. Trimming to `top` here preserves both and keeps
149
+ // `pairs` (the bulk of the object) off the wire entirely.
150
+ return {
151
+ summary: result.summary,
152
+ findings: result.findings.slice(0, req.top),
153
+ };
154
+ });
155
+ }
156
+ function handleVectorSurprises(deps, payload) {
157
+ return __awaiter(this, void 0, void 0, function* () {
158
+ return (0, rows_handler_1.asReadVerbResponse)(() => __awaiter(this, void 0, void 0, function* () {
159
+ var _a;
160
+ const projectRoot = (0, rows_handler_1.assertReadableProject)(payload.projectRoot);
161
+ const raw = ((_a = payload.options) !== null && _a !== void 0 ? _a : {});
162
+ const toPrefixList = (value) => {
163
+ if (!Array.isArray(value) || value.length === 0)
164
+ return undefined;
165
+ // Validated here rather than trusted: these reach resolveScope, which
166
+ // builds the WHERE clause the scan runs under.
167
+ return value.map((v) => (0, path_containment_1.resolveContainedPath)(projectRoot, String(v)));
168
+ };
169
+ const result = yield runSurprises(deps, {
170
+ projectRoot,
171
+ options: {
172
+ sample: (0, rows_handler_1.clampInt)(raw.sample, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.sample, 1, 10000),
173
+ neighbors: (0, rows_handler_1.clampInt)(raw.neighbors, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.neighbors, 1, 200),
174
+ dirDepth: (0, rows_handler_1.clampInt)(raw.dirDepth, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.dirDepth, 1, 20),
175
+ minSimilarity: typeof raw.minSimilarity === "number" &&
176
+ Number.isFinite(raw.minSimilarity) &&
177
+ raw.minSimilarity >= 0
178
+ ? raw.minSimilarity
179
+ : surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.minSimilarity,
180
+ maxRows: (0, rows_handler_1.clampInt)(raw.maxRows, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.maxRows, 1, surprising_connections_1.MAX_SURPRISE_ROWS),
181
+ includeTests: Boolean(raw.includeTests),
182
+ includeEval: Boolean(raw.includeEval),
183
+ in: toPrefixList(raw.in),
184
+ exclude: toPrefixList(raw.exclude),
185
+ },
186
+ top: (0, rows_handler_1.clampInt)(payload.top, 20, 1, 100),
187
+ });
188
+ return Object.assign({ ok: true }, result);
189
+ }));
190
+ });
191
+ }
192
+ // --- Registration -----------------------------------------------------------
193
+ /** Called from `Daemon.start()`; see registerRowsVerbs for why not at import. */
194
+ function registerVectorVerbs() {
195
+ (0, read_verbs_1.registerReadVerbs)({
196
+ "vector.similar": (payload, ctx) => handleVectorSimilar(ctx.daemon.storeReadDeps(), payload),
197
+ "vector.surprises": (payload, ctx) => handleVectorSurprises(ctx.daemon.storeReadDeps(), payload),
198
+ });
199
+ }
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.WatchLeases = exports.MAX_LEASE_TTL_MS = exports.DEFAULT_LEASE_TTL_MS = void 0;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ exports.DEFAULT_LEASE_TTL_MS = 30 * 60 * 1000;
40
+ /** Upper bound on a requested TTL — a lease must not outlive its holder by days. */
41
+ exports.MAX_LEASE_TTL_MS = 12 * 60 * 60 * 1000;
42
+ function defaultIsPidAlive(pid) {
43
+ try {
44
+ process.kill(pid, 0);
45
+ return true;
46
+ }
47
+ catch (err) {
48
+ // EPERM: the process exists but belongs to someone else.
49
+ return err.code === "EPERM";
50
+ }
51
+ }
52
+ class WatchLeases {
53
+ constructor(file, opts = {}) {
54
+ var _a, _b;
55
+ this.file = file;
56
+ this.leases = new Map();
57
+ this.now = (_a = opts.now) !== null && _a !== void 0 ? _a : Date.now;
58
+ this.isPidAlive = (_b = opts.isPidAlive) !== null && _b !== void 0 ? _b : defaultIsPidAlive;
59
+ }
60
+ static key(root, holder) {
61
+ return `${root}\0${holder}`;
62
+ }
63
+ /** Take or renew a lease. Returns true if the root was not wanted before. */
64
+ acquire(root, req) {
65
+ const wasWanted = this.isWanted(root);
66
+ const ttl = Math.min(Math.max(req.ttlMs, 1000), exports.MAX_LEASE_TTL_MS);
67
+ this.leases.set(WatchLeases.key(root, req.holder), {
68
+ root,
69
+ holder: req.holder,
70
+ pid: req.pid,
71
+ expiresAt: this.now() + ttl,
72
+ });
73
+ this.persist();
74
+ return !wasWanted;
75
+ }
76
+ /** Drop one holder's lease, or every lease on the root when holder is omitted. */
77
+ release(root, holder) {
78
+ let changed = false;
79
+ for (const [key, lease] of this.leases) {
80
+ if (lease.root !== root)
81
+ continue;
82
+ if (holder !== undefined && lease.holder !== holder)
83
+ continue;
84
+ this.leases.delete(key);
85
+ changed = true;
86
+ }
87
+ if (changed)
88
+ this.persist();
89
+ }
90
+ isLive(lease) {
91
+ if (lease.expiresAt <= this.now())
92
+ return false;
93
+ if (lease.pid !== undefined && !this.isPidAlive(lease.pid))
94
+ return false;
95
+ return true;
96
+ }
97
+ isWanted(root) {
98
+ for (const lease of this.leases.values()) {
99
+ if (lease.root === root && this.isLive(lease))
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ /** Drop dead leases. Returns roots that were wanted before and are not now. */
105
+ sweep() {
106
+ // Every stored lease was live when last swept or acquired, so the stored
107
+ // roots — not the currently-live ones — are what "wanted before" means.
108
+ const before = new Set([...this.leases.values()].map((l) => l.root));
109
+ let changed = false;
110
+ for (const [key, lease] of this.leases) {
111
+ if (!this.isLive(lease)) {
112
+ this.leases.delete(key);
113
+ changed = true;
114
+ }
115
+ }
116
+ if (!changed)
117
+ return [];
118
+ this.persist();
119
+ const after = this.wantedRoots();
120
+ return [...before].filter((root) => !after.has(root));
121
+ }
122
+ wantedRoots() {
123
+ const roots = new Set();
124
+ for (const lease of this.leases.values()) {
125
+ if (this.isLive(lease))
126
+ roots.add(lease.root);
127
+ }
128
+ return roots;
129
+ }
130
+ /** Live leases, for `gmax status`. */
131
+ list() {
132
+ return [...this.leases.values()].filter((lease) => this.isLive(lease));
133
+ }
134
+ load() {
135
+ if (!this.file)
136
+ return;
137
+ let raw;
138
+ try {
139
+ raw = JSON.parse(fs.readFileSync(this.file, "utf-8"));
140
+ }
141
+ catch (_a) {
142
+ return;
143
+ }
144
+ if (!Array.isArray(raw))
145
+ return;
146
+ for (const entry of raw) {
147
+ if (!entry ||
148
+ typeof entry.root !== "string" ||
149
+ typeof entry.holder !== "string" ||
150
+ typeof entry.expiresAt !== "number") {
151
+ continue;
152
+ }
153
+ const lease = {
154
+ root: entry.root,
155
+ holder: entry.holder,
156
+ pid: typeof entry.pid === "number" ? entry.pid : undefined,
157
+ expiresAt: Math.min(entry.expiresAt, this.now() + exports.MAX_LEASE_TTL_MS),
158
+ };
159
+ if (this.isLive(lease)) {
160
+ this.leases.set(WatchLeases.key(lease.root, lease.holder), lease);
161
+ }
162
+ }
163
+ }
164
+ persist() {
165
+ if (!this.file)
166
+ return;
167
+ try {
168
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
169
+ const tmp = `${this.file}.${process.pid}.tmp`;
170
+ fs.writeFileSync(tmp, JSON.stringify([...this.leases.values()]));
171
+ fs.renameSync(tmp, this.file);
172
+ }
173
+ catch (_a) {
174
+ // Persistence only shortens the gap after a restart; the next renewal
175
+ // re-establishes the lease either way.
176
+ }
177
+ }
178
+ }
179
+ exports.WatchLeases = WatchLeases;
@@ -59,6 +59,11 @@ const MAX_BATCH_SIZE = 50;
59
59
  // Pool priority cannot preempt an active processFile, so keep one worker free
60
60
  // for latency-sensitive encodeQuery/rerank work during indexing bursts.
61
61
  const DEFAULT_BATCH_CONCURRENCY = Math.max(1, config_1.CONFIG.WORKER_THREADS - 1);
62
+ // Files per concurrent task slot. A watcher batch of a handful of small files
63
+ // finishes in a second or two on one worker, so fanning it out only makes the
64
+ // pool cold-spawn workers (each loads the models, ~1 GB) that are reaped
65
+ // unused 60s later. Scale fan-out with the batch instead.
66
+ const FILES_PER_BATCH_SLOT = 4;
62
67
  class ProjectBatchProcessor {
63
68
  constructor(opts) {
64
69
  var _a, _b, _c;
@@ -436,6 +441,7 @@ class ProjectBatchProcessor {
436
441
  }
437
442
  }
438
443
  });
444
+ const batchConcurrency = Math.min(this.concurrency, Math.max(1, Math.ceil(batch.size / FILES_PER_BATCH_SLOT)));
439
445
  const activeTasks = [];
440
446
  const schedule = (task) => __awaiter(this, void 0, void 0, function* () {
441
447
  const taskPromise = task();
@@ -446,7 +452,7 @@ class ProjectBatchProcessor {
446
452
  activeTasks.splice(index, 1);
447
453
  };
448
454
  void taskPromise.then(removeActiveTask, removeActiveTask);
449
- if (activeTasks.length >= this.concurrency) {
455
+ if (activeTasks.length >= batchConcurrency) {
450
456
  yield Promise.race(activeTasks);
451
457
  }
452
458
  });
@@ -96,6 +96,10 @@ exports.WATCHER_IGNORE_GLOBS = [
96
96
  // "**/<name>" ignores the directory entry itself, "**/<name>/**" its contents.
97
97
  ...IGNORED_DIR_NAMES.flatMap((name) => [`**/${name}`, `**/${name}/**`]),
98
98
  "**/*.tmp.*", // editor atomic save artifacts
99
+ // Atomic-save temp files named `.!<pid>!<name>` (e.g. `.!8903!dotmd.md`).
100
+ // picomatch treats `!` as a literal unless it leads the pattern or opens
101
+ // an `!(...)` extglob, so this matches only that shape, at any depth.
102
+ "**/.!*!*",
99
103
  "**/*.sb-*", // Xcode swap files
100
104
  // Codegen output never reaches the index (file policy drops it), so don't
101
105
  // let it reach the watcher either — it is the dominant source of FSEvents
@@ -42,12 +42,14 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
42
42
  });
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.PAGERANK_MEMORY_CACHE_MAX = exports.PAGERANK_ROW_LIMIT = void 0;
45
46
  exports.computePageRank = computePageRank;
46
47
  exports.buildGraphFromDb = buildGraphFromDb;
47
48
  exports.writeDiskCache = writeDiskCache;
48
49
  exports.loadOrComputePageRank = loadOrComputePageRank;
49
50
  exports.pageRankBoostForSymbols = pageRankBoostForSymbols;
50
51
  exports._clearMemoryCacheForTests = _clearMemoryCacheForTests;
52
+ exports._memoryCacheKeysForTests = _memoryCacheKeysForTests;
51
53
  exports._cachePathForTests = _cachePathForTests;
52
54
  const crypto = __importStar(require("node:crypto"));
53
55
  const fs = __importStar(require("node:fs"));
@@ -58,7 +60,51 @@ const DEFAULT_DAMPING = 0.85;
58
60
  const DEFAULT_MAX_ITER = 50;
59
61
  const DEFAULT_TOL = 1e-6;
60
62
  const DEFAULT_TTL_MS = 60 * 60 * 1000;
63
+ /**
64
+ * Row cap for the graph scan. Matches `graph.audit`'s AUDIT_ROW_LIMIT: the
65
+ * largest indexed project holds ~270k chunks, so this never truncates a real
66
+ * project, but a runaway prefix (a home directory, a monorepo root) can no
67
+ * longer pull millions of symbol-array rows into daemon memory at once. A
68
+ * truncated scan ranks the symbols it saw; the ranking is a search boost, not
69
+ * an answer, so a partial graph degrades gracefully.
70
+ */
71
+ exports.PAGERANK_ROW_LIMIT = 500000;
72
+ /**
73
+ * Score maps stay in the daemon for its lifetime, one per scoped prefix a
74
+ * search has used (`--in` makes those open-ended). Keep the most recently
75
+ * used few; each is a Map over every defined symbol in its scope.
76
+ */
77
+ exports.PAGERANK_MEMORY_CACHE_MAX = 8;
78
+ // Map iteration order is insertion order, so re-inserting on a hit makes the
79
+ // first key the least recently used.
61
80
  const memoryCache = new Map();
81
+ // Concurrent searches on a cold prefix share one scan instead of each running
82
+ // their own.
83
+ const inFlight = new Map();
84
+ function memoryGet(pathPrefix) {
85
+ const entry = memoryCache.get(pathPrefix);
86
+ if (!entry)
87
+ return undefined;
88
+ memoryCache.delete(pathPrefix);
89
+ if (Date.now() - entry.computedAt >= getTtlMs())
90
+ return undefined;
91
+ memoryCache.set(pathPrefix, entry);
92
+ return entry;
93
+ }
94
+ function memorySet(pathPrefix, entry) {
95
+ memoryCache.delete(pathPrefix);
96
+ memoryCache.set(pathPrefix, entry);
97
+ const ttl = getTtlMs();
98
+ const now = Date.now();
99
+ for (const [key, value] of memoryCache) {
100
+ if (now - value.computedAt >= ttl)
101
+ memoryCache.delete(key);
102
+ }
103
+ while (memoryCache.size > exports.PAGERANK_MEMORY_CACHE_MAX) {
104
+ const oldest = memoryCache.keys().next().value;
105
+ memoryCache.delete(oldest);
106
+ }
107
+ }
62
108
  function computePageRank(graph, damping = DEFAULT_DAMPING, maxIter = DEFAULT_MAX_ITER, tol = DEFAULT_TOL) {
63
109
  const N = graph.nodes.length;
64
110
  const result = new Map();
@@ -148,6 +194,7 @@ function buildGraphFromDb(db, pathPrefix) {
148
194
  .query()
149
195
  .select(["defined_symbols", "referenced_symbols"])
150
196
  .where((0, filter_builder_1.pathStartsWith)(prefix))
197
+ .limit(exports.PAGERANK_ROW_LIMIT)
151
198
  .toArray();
152
199
  const nodes = new Set();
153
200
  const edges = new Map();
@@ -224,28 +271,38 @@ function writeDiskCache(pathPrefix, scores) {
224
271
  }
225
272
  function loadOrComputePageRank(db, pathPrefix) {
226
273
  return __awaiter(this, void 0, void 0, function* () {
227
- const mem = memoryCache.get(pathPrefix);
228
- if (mem && Date.now() - mem.computedAt < getTtlMs()) {
274
+ const mem = memoryGet(pathPrefix);
275
+ if (mem)
229
276
  return { scores: mem.scores, max: mem.max };
230
- }
231
- const disk = readDiskCache(pathPrefix);
232
- if (disk) {
233
- memoryCache.set(pathPrefix, disk);
234
- return { scores: disk.scores, max: disk.max };
235
- }
236
- const graph = yield buildGraphFromDb(db, pathPrefix);
237
- const scores = computePageRank(graph);
238
- let max = 0;
239
- for (const v of scores.values())
240
- if (v > max)
241
- max = v;
242
- const entry = { scores, max, computedAt: Date.now() };
243
- memoryCache.set(pathPrefix, entry);
277
+ const pending = inFlight.get(pathPrefix);
278
+ if (pending)
279
+ return pending;
280
+ const task = (() => __awaiter(this, void 0, void 0, function* () {
281
+ const disk = readDiskCache(pathPrefix);
282
+ if (disk) {
283
+ memorySet(pathPrefix, disk);
284
+ return { scores: disk.scores, max: disk.max };
285
+ }
286
+ const graph = yield buildGraphFromDb(db, pathPrefix);
287
+ const scores = computePageRank(graph);
288
+ let max = 0;
289
+ for (const v of scores.values())
290
+ if (v > max)
291
+ max = v;
292
+ memorySet(pathPrefix, { scores, max, computedAt: Date.now() });
293
+ try {
294
+ writeDiskCache(pathPrefix, scores);
295
+ }
296
+ catch (_a) { }
297
+ return { scores, max };
298
+ }))();
299
+ inFlight.set(pathPrefix, task);
244
300
  try {
245
- writeDiskCache(pathPrefix, scores);
301
+ return yield task;
302
+ }
303
+ finally {
304
+ inFlight.delete(pathPrefix);
246
305
  }
247
- catch (_a) { }
248
- return { scores, max };
249
306
  });
250
307
  }
251
308
  function pageRankBoostForSymbols(symbols, scores, max) {
@@ -261,6 +318,10 @@ function pageRankBoostForSymbols(symbols, scores, max) {
261
318
  }
262
319
  function _clearMemoryCacheForTests() {
263
320
  memoryCache.clear();
321
+ inFlight.clear();
322
+ }
323
+ function _memoryCacheKeysForTests() {
324
+ return [...memoryCache.keys()];
264
325
  }
265
326
  function _cachePathForTests(pathPrefix) {
266
327
  return cachePathFor(pathPrefix);
@@ -187,11 +187,33 @@ const COMPACTION_MIN_INTERVAL_MS = 30 * 60 * 1000;
187
187
  * so a store that cannot be improved stops paying for the attempt.
188
188
  */
189
189
  const COMPACTION_MAX_INTERVAL_MS = 6 * 60 * 60 * 1000;
190
+ /**
191
+ * Bounds for LanceDB's per-connection caches.
192
+ *
193
+ * `lancedb.connect()` without a Session gets a 6 GB index cache and a 1 GB
194
+ * metadata cache, and a connection lives as long as the daemon. On a 48 GB host
195
+ * that was already swapping, the daemon reached a 4 GB footprint (9.6 GB peak),
196
+ * almost all native malloc and invisible to the RSS-based recycle because most of
197
+ * it had been compressed or swapped. The whole on-disk index directory for a
198
+ * 430k-row store is ~650 MB, so 1 GB still holds every index; the metadata cache
199
+ * only needs the latest manifests, not every retained version.
200
+ */
201
+ const LANCE_INDEX_CACHE_MB = envMb("GMAX_LANCE_INDEX_CACHE_MB", 1024);
202
+ const LANCE_METADATA_CACHE_MB = envMb("GMAX_LANCE_METADATA_CACHE_MB", 256);
203
+ function envMb(name, fallback) {
204
+ const parsed = Number(process.env[name]);
205
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
206
+ }
207
+ function createLanceSession() {
208
+ const mb = (n) => BigInt(n * 1024 * 1024);
209
+ return new lancedb.Session(mb(LANCE_INDEX_CACHE_MB), mb(LANCE_METADATA_CACHE_MB));
210
+ }
190
211
  class VectorDB {
191
212
  constructor(lancedbDir, vectorDim, suppliedLease) {
192
213
  this.lancedbDir = lancedbDir;
193
214
  this.suppliedLease = suppliedLease;
194
215
  this.db = null;
216
+ this.session = null;
195
217
  this.closed = false;
196
218
  this.maintenanceRunning = false;
197
219
  this.maintenancePromise = null;
@@ -291,6 +313,16 @@ class VectorDB {
291
313
  }
292
314
  /** True iff a maintenance tick is currently running. Used by the daemon to
293
315
  * defer idle shutdown so we don't tear down LanceDB mid-optimize. */
316
+ /** Bytes held by LanceDB's index + metadata caches (0 before first connect). */
317
+ cacheSizeBytes() {
318
+ var _a, _b;
319
+ try {
320
+ return Number((_b = (_a = this.session) === null || _a === void 0 ? void 0 : _a.sizeBytes()) !== null && _b !== void 0 ? _b : 0);
321
+ }
322
+ catch (_c) {
323
+ return 0;
324
+ }
325
+ }
294
326
  isMaintenanceActive() {
295
327
  return this.maintenancePromise !== null;
296
328
  }
@@ -324,7 +356,8 @@ class VectorDB {
324
356
  }
325
357
  if (!this.db) {
326
358
  fs.mkdirSync(this.lancedbDir, { recursive: true });
327
- this.db = yield lancedb.connect(this.lancedbDir);
359
+ this.session = createLanceSession();
360
+ this.db = yield lancedb.connect(this.lancedbDir, {}, this.session);
328
361
  }
329
362
  return this.db;
330
363
  });
@@ -1578,6 +1611,7 @@ class VectorDB {
1578
1611
  }
1579
1612
  }
1580
1613
  this.db = null;
1614
+ this.session = null;
1581
1615
  if (this.leasePromise) {
1582
1616
  let lease = null;
1583
1617
  try {