grepmax 0.26.28 → 0.26.32

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.
@@ -150,20 +150,28 @@ function handleCommand(daemon, cmd, conn) {
150
150
  capabilities: {
151
151
  exclusiveGenerationRebuild: exports.EXCLUSIVE_GENERATION_REBUILD_PROTOCOL,
152
152
  readVerbs: read_verbs_1.READ_VERBS_PROTOCOL,
153
+ // `watch`/`unwatch` accept holder/pid/ttlMs. A daemon without this
154
+ // treats `unwatch` as unwatch-for-everyone, so clients releasing a
155
+ // single lease must check it first.
156
+ watchLeases: 1,
153
157
  },
154
158
  };
155
159
  case "watch": {
156
160
  const root = String(cmd.root || "");
157
161
  if (!root)
158
162
  return { ok: false, error: "missing root" };
159
- yield daemon.watchProject(root);
163
+ yield daemon.requestWatch(root, {
164
+ holder: typeof cmd.holder === "string" ? cmd.holder : undefined,
165
+ pid: typeof cmd.pid === "number" ? cmd.pid : undefined,
166
+ ttlMs: typeof cmd.ttlMs === "number" ? cmd.ttlMs : undefined,
167
+ });
160
168
  return { ok: true, pid: process.pid };
161
169
  }
162
170
  case "unwatch": {
163
171
  const root = String(cmd.root || "");
164
172
  if (!root)
165
173
  return { ok: false, error: "missing root" };
166
- yield daemon.unwatchProject(root);
174
+ yield daemon.releaseWatch(root, typeof cmd.holder === "string" ? cmd.holder : undefined);
167
175
  return { ok: true };
168
176
  }
169
177
  case "status":
@@ -172,6 +180,7 @@ function handleCommand(daemon, cmd, conn) {
172
180
  pid: process.pid,
173
181
  uptime: daemon.uptime(),
174
182
  projects: daemon.listProjects(),
183
+ leases: daemon.listWatchLeases(),
175
184
  diskPressure: daemon.getDiskPressure(),
176
185
  mlx: daemon.getMlxStatus(),
177
186
  };
@@ -304,6 +313,7 @@ function handleCommand(daemon, cmd, conn) {
304
313
  const root = String(cmd.root || "");
305
314
  if (!root)
306
315
  return { ok: false, error: "missing root" };
316
+ daemon.leaseForCli(root);
307
317
  startStreaming(conn, () => daemon.addProject(root, conn));
308
318
  return null;
309
319
  }
@@ -311,6 +321,7 @@ function handleCommand(daemon, cmd, conn) {
311
321
  const root = String(cmd.root || "");
312
322
  if (!root)
313
323
  return { ok: false, error: "missing root" };
324
+ daemon.leaseForCli(root);
314
325
  startStreaming(conn, () => daemon.ensureProject(root, conn));
315
326
  return null;
316
327
  }
@@ -318,6 +329,7 @@ function handleCommand(daemon, cmd, conn) {
318
329
  const root = String(cmd.root || "");
319
330
  if (!root)
320
331
  return { ok: false, error: "missing root" };
332
+ daemon.leaseForCli(root);
321
333
  startStreaming(conn, () => daemon.indexProject(root, conn, {
322
334
  reset: !!cmd.reset,
323
335
  dryRun: !!cmd.dryRun,
@@ -385,8 +397,11 @@ function handleCommand(daemon, cmd, conn) {
385
397
  const ac = new AbortController();
386
398
  const onClose = () => ac.abort();
387
399
  conn.on("close", onClose);
400
+ const name = String(cmd.cmd);
388
401
  try {
389
- return yield daemon.runSharedOperation(String(cmd.cmd), ac.signal, (signal) => verb(cmd, { daemon, conn, signal }));
402
+ // Heavy verbs queue on a global gate before admission; see
403
+ // HEAVY_READ_VERBS in read-verbs.ts.
404
+ return yield (0, read_verbs_1.runReadVerb)(name, ac.signal, () => daemon.runSharedOperation(name, ac.signal, (signal) => verb(cmd, { daemon, conn, signal })));
390
405
  }
391
406
  finally {
392
407
  conn.off("close", onClose);
@@ -20,12 +20,16 @@
20
20
  * so `ipc-handler` tests can drive them with a fake `Daemon`.
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.READ_VERBS_PROTOCOL = void 0;
23
+ exports.heavyReadGate = exports.DEFAULT_HEAVY_READ_CONCURRENCY = exports.HEAVY_READ_VERBS = exports.READ_VERBS_PROTOCOL = void 0;
24
24
  exports.registerReadVerbs = registerReadVerbs;
25
25
  exports.getReadVerb = getReadVerb;
26
26
  exports.readVerbNames = readVerbNames;
27
+ exports.resolveHeavyReadConcurrency = resolveHeavyReadConcurrency;
28
+ exports.isHeavyReadVerb = isHeavyReadVerb;
29
+ exports.runReadVerb = runReadVerb;
27
30
  exports.clearReadVerbs = clearReadVerbs;
28
31
  exports.registerGraphVerbs = registerGraphVerbs;
32
+ const async_semaphore_1 = require("../utils/async-semaphore");
29
33
  const graph_handler_1 = require("./graph-handler");
30
34
  /** Bumped when the verb wire shapes change incompatibly. */
31
35
  exports.READ_VERBS_PROTOCOL = 1;
@@ -50,6 +54,53 @@ function getReadVerb(name) {
50
54
  function readVerbNames() {
51
55
  return [...registry.keys()].sort();
52
56
  }
57
+ // ---------------------------------------------------------------------------
58
+ // Heavy-verb admission.
59
+ //
60
+ // `runSharedOperation` only tracks a verb; it never limits how many run. These
61
+ // verbs each scan a large slice of the table (tens to hundreds of thousands of
62
+ // rows, or one vector search per sampled anchor), and their Arrow buffers sit
63
+ // in native memory until the answer is built. With one MCP session per Claude
64
+ // Code window, N sessions asking at once meant N such scans resident together.
65
+ // They now share a small global semaphore; everything else stays unbounded,
66
+ // because point lookups (`graph.resolve`, `rows.locate`, ...) are cheap and
67
+ // must not queue behind an audit.
68
+ //
69
+ // A queued request waits *before* `runSharedOperation` admits it (see
70
+ // `runReadVerb`), so an exclusive rebuild does not have to sit behind scans
71
+ // that have not started yet, and a client that disconnects while queued
72
+ // leaves without ever touching the store.
73
+ // ---------------------------------------------------------------------------
74
+ exports.HEAVY_READ_VERBS = new Set([
75
+ "graph.audit", // up to AUDIT_ROW_LIMIT rows with symbol arrays
76
+ "graph.dead", // whole caller set
77
+ "graph.risk", // up to MAX_RISK_SYMBOLS x three content scans
78
+ "graph.subgraph", // per-file symbol scans over up to MAX_SUBGRAPH_FILES
79
+ "graph.trace", // multi-hop caller/callee expansion
80
+ "rows.project", // up to 200k rows with two symbol-array columns
81
+ "vector.similar", // vector search plus content
82
+ "vector.surprises", // up to 100k-row scan plus one search per anchor
83
+ ]);
84
+ exports.DEFAULT_HEAVY_READ_CONCURRENCY = 2;
85
+ /** `GMAX_HEAVY_READ_CONCURRENCY`, or the default when unset or not a positive integer. */
86
+ function resolveHeavyReadConcurrency(raw = process.env.GMAX_HEAVY_READ_CONCURRENCY) {
87
+ const n = Number(raw);
88
+ return raw !== undefined && Number.isInteger(n) && n >= 1
89
+ ? n
90
+ : exports.DEFAULT_HEAVY_READ_CONCURRENCY;
91
+ }
92
+ exports.heavyReadGate = new async_semaphore_1.AsyncSemaphore(resolveHeavyReadConcurrency());
93
+ function isHeavyReadVerb(name) {
94
+ return typeof name === "string" && exports.HEAVY_READ_VERBS.has(name);
95
+ }
96
+ /**
97
+ * Admission wrapper `ipc-handler` puts around a verb's shared operation: heavy
98
+ * verbs wait for a gate slot first (abortable through `signal`), light verbs
99
+ * run straight through.
100
+ */
101
+ function runReadVerb(name, signal, run, gate = exports.heavyReadGate) {
102
+ return isHeavyReadVerb(name) ? gate.run(signal, run) : run();
103
+ }
53
104
  /** Test hook: drop every registration. */
54
105
  function clearReadVerbs() {
55
106
  registry.clear();
@@ -76,10 +76,13 @@ function handleDaemonSearch(deps, payload, signal) {
76
76
  if (!deps.processors.has(root)) {
77
77
  // A full index (--reset) or the initial index removes/defers the
78
78
  // processor while (re)building. The partial index is still queryable, so
79
- // answer the search and flag it partial (below) rather than erroring —
80
- // only truly-unwatched, not-indexing projects get "not watched".
81
- const indexingNow = deps.indexProgress.has(root) || ((_c = (0, project_registry_1.getProject)(root)) === null || _c === void 0 ? void 0 : _c.status) === "pending";
82
- if (!indexingNow) {
79
+ // answer the search and flag it partial (below) rather than erroring.
80
+ // An indexed project nobody holds a watch lease on is served from its
81
+ // last-synced vectors — watching is per session now, not a precondition
82
+ // for reading. Only unknown, never-indexed roots get "not watched".
83
+ const status = (_c = (0, project_registry_1.getProject)(root)) === null || _c === void 0 ? void 0 : _c.status;
84
+ const indexingNow = deps.indexProgress.has(root) || status === "pending";
85
+ if (!indexingNow && status !== "indexed") {
83
86
  return {
84
87
  ok: false,
85
88
  error: "project not watched",
@@ -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 {