sweet-search 2.6.8 → 2.6.10

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 (34) hide show
  1. package/README.md +15 -0
  2. package/core/cli.js +7 -0
  3. package/core/graph/graph-extractor.js +24 -14
  4. package/core/graph/hcgs-generator.js +4 -3
  5. package/core/incremental-indexing/application/production-li-delta.mjs +28 -3
  6. package/core/incremental-indexing/application/production-reconciler.mjs +40 -11
  7. package/core/incremental-indexing/application/reconciler.mjs +1 -1
  8. package/core/incremental-indexing/domain/encoder-input.mjs +9 -4
  9. package/core/indexing/artifact-builder.js +5 -3
  10. package/core/indexing/ast-chunker.js +65 -62
  11. package/core/indexing/index-maintainer.mjs +49 -6
  12. package/core/indexing/indexer-build.js +18 -4
  13. package/core/indexing/indexer-phases.js +45 -24
  14. package/core/indexing/indexer-utils.js +96 -6
  15. package/core/indexing/indexing-file-policy.js +30 -7
  16. package/core/indexing/model-pool.js +26 -0
  17. package/core/indexing/rss-budget.mjs +65 -0
  18. package/core/infrastructure/code-graph-repository.js +18 -18
  19. package/core/infrastructure/db-utils.js +29 -0
  20. package/core/infrastructure/simd-distance.js +35 -21
  21. package/core/infrastructure/tombstone-bitmap-reader.js +3 -1
  22. package/core/ranking/file-kind-ranking.js +14 -2
  23. package/core/ranking/late-interaction-index.js +86 -15
  24. package/core/ranking/late-interaction-model.js +25 -4
  25. package/core/ranking/mmr.js +15 -10
  26. package/core/search/search-anchor.js +21 -3
  27. package/core/search/search-boost.js +25 -13
  28. package/core/search/search-fusion.js +13 -5
  29. package/core/search/search-read-semantic.js +13 -12
  30. package/core/search/search-server.js +2 -1
  31. package/core/vector-store/binary-hnsw-index.js +338 -33
  32. package/core/vector-store/float-vector-store.js +9 -5
  33. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +11 -0
  34. package/package.json +8 -8
@@ -9,7 +9,7 @@ import { existsSync } from 'fs';
9
9
  import path from 'path';
10
10
 
11
11
  import { DB_PATHS, EMBEDDING_CONFIG, PROJECT_ROOT } from '../infrastructure/config/index.js';
12
- import { GraphExtractor, createGraphSchema, insertGraph } from '../graph/graph-extractor.js';
12
+ import { GraphExtractor, createGraphSchema, insertGraph, rebuildGraphFts } from '../graph/graph-extractor.js';
13
13
  import { resolveRelationshipTargets } from '../graph/relationship-resolver.js';
14
14
  import { populatePageRankColumn } from '../graph/structural-pagerank.js';
15
15
  import { getEmbeddings, getModelInfo } from '../embedding/embedding-service.js';
@@ -153,6 +153,7 @@ export async function buildCodeGraph(files, dryRun = false) {
153
153
  let errors = 0;
154
154
  let totalEntities = 0;
155
155
  let totalRelationships = 0;
156
+ let graphFlushed = false;
156
157
 
157
158
  for (let i = 0; i < files.length; i++) {
158
159
  try {
@@ -170,10 +171,14 @@ export async function buildCodeGraph(files, dryRun = false) {
170
171
  errors++;
171
172
  }
172
173
 
173
- // Flush batch every GRAPH_BATCH_SIZE files or at the end
174
+ // Flush batch every GRAPH_BATCH_SIZE files or at the end. FTS5 sync is
175
+ // deferred to ONE rebuild after the loop — 'rebuild' reconstructs the
176
+ // whole index from the entities table, so per-batch rebuilds were
177
+ // O(entities × batches) work discarded by the next batch.
174
178
  if ((i + 1) % GRAPH_BATCH_SIZE === 0 || i === files.length - 1) {
175
179
  if (entityBatch.length > 0 || relBatch.length > 0) {
176
- insertGraph(db, entityBatch, relBatch, hasFts5);
180
+ insertGraph(db, entityBatch, relBatch, hasFts5, { syncFts: false });
181
+ graphFlushed = true;
177
182
  totalEntities += entityBatch.length;
178
183
  totalRelationships += relBatch.length;
179
184
  entityBatch = [];
@@ -186,6 +191,13 @@ export async function buildCodeGraph(files, dryRun = false) {
186
191
  }
187
192
  }
188
193
 
194
+ // Single FTS5 rebuild at the exact point the last per-batch rebuild used to
195
+ // run (before relationship resolution, whose entity updates were never
196
+ // reflected in FTS).
197
+ if (graphFlushed && hasFts5) {
198
+ rebuildGraphFts(db);
199
+ }
200
+
189
201
  log(`\n✓ Extracted ${totalEntities} entities, ${totalRelationships} relationships`, 'green');
190
202
  if (errors > 0) {
191
203
  log(`⚠ ${errors} files had errors`, 'yellow');
@@ -360,7 +372,9 @@ function annotateChunksForVectorInsert(chunks) {
360
372
  const idx = indices[i];
361
373
  annotations[idx] = {
362
374
  ...ids[i],
363
- hashes: chunkInputHashes(chunks[idx]),
375
+ // Full indexing never persists dedup_fingerprint (only the reconcile
376
+ // delta writer does) — skip that hash.
377
+ hashes: chunkInputHashes(chunks[idx], { includeDedup: false }),
364
378
  };
365
379
  }
366
380
  }
@@ -10,7 +10,7 @@ import path from 'path';
10
10
  import { DB_PATHS, PROJECT_ROOT, EMBEDDING_CONFIG, HCGS_CONFIG } from '../infrastructure/config/index.js';
11
11
  import { getChangedFiles, updateState, getStats as getIncrementalStats, updatePhaseProgress, markPhaseComplete, clearPhaseProgress } from './incremental-tracker.js';
12
12
  import { backupSummaries, restoreSummaries, markForRegeneration } from '../graph/summary-manager.js';
13
- import { colors, log, logProgress, logError, discoverFiles, readFilesFromStdin, atomicSwapDatabase } from './indexer-utils.js';
13
+ import { colors, log, logProgress, logError, discoverFiles, readFilesFromStdin, atomicSwapDatabase, shouldStreamVectors } from './indexer-utils.js';
14
14
  import { buildCodeGraph, buildVectorIndex, chunkFiles } from './indexer-build.js';
15
15
  import { runDedupPhase, formatDedupSummary } from './dedup/dedup-phase.js';
16
16
  import { DEDUP_CONFIG } from '../infrastructure/config/index.js';
@@ -420,15 +420,24 @@ export async function buildVectorsAndArtifactsPhase(options = {}) {
420
420
  // model. For large full rebuilds we instead spill chunks to disk and embed/LI
421
421
  // in bounded windows (see streaming-vectors.js) so peak heap is O(window).
422
422
  //
423
- // Gated by file count so small repos + incremental runs keep the original
424
- // in-memory path byte-for-byte (benchmark indexes unaffected). Auto-selected,
425
- // no opt-in flag; SWEET_SEARCH_STREAM_VECTORS=0 forces the legacy path and
426
- // SWEET_SEARCH_STREAM_MIN_FILES tunes the threshold.
427
- const streamMinFiles = Number(process.env.SWEET_SEARCH_STREAM_MIN_FILES) || 5000;
428
- const useStreaming = !dryRun
429
- && fullReindex
430
- && filesToIndex.length >= streamMinFiles
431
- && process.env.SWEET_SEARCH_STREAM_VECTORS !== '0';
423
+ // Gated by file count OR total admitted source bytes (see shouldStreamVectors
424
+ // in indexer-utils.js) so small repos + incremental runs keep the original
425
+ // in-memory path byte-for-byte (benchmark indexes unaffected). The byte
426
+ // trigger catches few-files-huge-bytes repos (amalgamations, vendored blobs)
427
+ // that OOM the in-memory path while staying under the file gate.
428
+ // Auto-selected, no opt-in flag; SWEET_SEARCH_STREAM_VECTORS=0 forces the
429
+ // legacy path, SWEET_SEARCH_STREAM_MIN_FILES / SWEET_SEARCH_STREAM_MIN_BYTES
430
+ // tune the thresholds.
431
+ const streamingDecision = await shouldStreamVectors({ filesToIndex, dryRun, fullReindex });
432
+ const useStreaming = streamingDecision.useStreaming;
433
+ if (streamingDecision.reason === 'bytes') {
434
+ log(
435
+ ` Streaming vectors: ${filesToIndex.length} files total ` +
436
+ `${(streamingDecision.totalBytes / 1048576).toFixed(0)}+ MB >= ` +
437
+ `${(streamingDecision.thresholdBytes / 1048576).toFixed(0)} MB (bounded memory)`,
438
+ 'dim'
439
+ );
440
+ }
432
441
 
433
442
  // The in-memory path pre-chunks up front so both vector + LI encoders share
434
443
  // one chunk list. The streaming path does its own windowed chunking + dedup,
@@ -814,20 +823,32 @@ export async function updateIncrementalStatePhase(options = {}) {
814
823
  log('\nIncremental state updated', 'green');
815
824
  } else if (fullReindex) {
816
825
  const hashes = {};
817
- for (const file of allFiles) {
818
- try {
819
- const fullPath = path.join(PROJECT_ROOT, file);
820
- const [content, stat] = await Promise.all([
821
- fs.readFile(fullPath),
822
- fs.stat(fullPath, { bigint: true }).catch(() => null),
823
- ]);
824
- hashes[file] = {
825
- hash: contentHashSync(content),
826
- size: stat ? stat.size.toString() : null,
827
- mtime_ns: stat ? stat.mtimeNs.toString() : null,
828
- inode: stat ? stat.ino.toString() : null,
829
- };
830
- } catch (e) { /* skip */ }
826
+ // Batched read+stat (order-preserving assignment key insertion order,
827
+ // and therefore the serialized state file, matches the sequential loop).
828
+ // Mirrors the H6 batching in incremental-tracker.getChangedFiles.
829
+ const HASH_BATCH = 100;
830
+ for (let i = 0; i < allFiles.length; i += HASH_BATCH) {
831
+ const batch = allFiles.slice(i, i + HASH_BATCH);
832
+ const results = await Promise.all(batch.map(async (file) => {
833
+ try {
834
+ const fullPath = path.join(PROJECT_ROOT, file);
835
+ const [content, stat] = await Promise.all([
836
+ fs.readFile(fullPath),
837
+ fs.stat(fullPath, { bigint: true }).catch(() => null),
838
+ ]);
839
+ return {
840
+ hash: contentHashSync(content),
841
+ size: stat ? stat.size.toString() : null,
842
+ mtime_ns: stat ? stat.mtimeNs.toString() : null,
843
+ inode: stat ? stat.ino.toString() : null,
844
+ };
845
+ } catch (e) {
846
+ return null; // skip
847
+ }
848
+ }));
849
+ for (let j = 0; j < batch.length; j++) {
850
+ if (results[j]) hashes[batch[j]] = results[j];
851
+ }
831
852
  }
832
853
  await updateState(hashes, {
833
854
  totalChunks: vectorStats.chunks,
@@ -395,16 +395,22 @@ export async function discoverFiles(options = {}) {
395
395
 
396
396
  const files = [];
397
397
  let oversized = 0;
398
- for (const file of allFiles) {
399
- try {
400
- const stat = await fs.stat(path.join(projectRoot, file));
398
+ // Stat in batches (order-preserving) instead of one serialized await per
399
+ // file; results are consumed in the original allFiles order.
400
+ const STAT_BATCH = 200;
401
+ for (let i = 0; i < allFiles.length; i += STAT_BATCH) {
402
+ const batch = allFiles.slice(i, i + STAT_BATCH);
403
+ const stats = await Promise.all(
404
+ batch.map((file) => fs.stat(path.join(projectRoot, file)).catch(() => null)),
405
+ );
406
+ for (let j = 0; j < batch.length; j++) {
407
+ const stat = stats[j];
408
+ if (!stat) continue; // File disappeared between glob and stat
401
409
  if (stat.size > maxFileSize) {
402
410
  oversized++;
403
411
  } else {
404
- files.push(file);
412
+ files.push(batch[j]);
405
413
  }
406
- } catch {
407
- // File disappeared between glob and stat
408
414
  }
409
415
  }
410
416
 
@@ -426,3 +432,87 @@ export async function discoverFiles(options = {}) {
426
432
 
427
433
  return files;
428
434
  }
435
+
436
+ // =============================================================================
437
+ // STREAMING-VECTORS GATE
438
+ // =============================================================================
439
+
440
+ /**
441
+ * Sum on-disk sizes of `files` (relative to `projectRoot`), stopping as soon
442
+ * as the running total crosses `stopAt`. The streaming gate only needs to
443
+ * know whether the total crosses the threshold, not the exact figure, so on
444
+ * large-byte repos this exits after a fraction of the stats.
445
+ */
446
+ export async function sumFileSizesUpTo(files, stopAt, projectRoot = PROJECT_ROOT) {
447
+ let total = 0;
448
+ for (const file of files) {
449
+ try {
450
+ const stat = await fs.stat(path.isAbsolute(file) ? file : path.join(projectRoot, file));
451
+ total += stat.size;
452
+ if (total >= stopAt) return total;
453
+ } catch {
454
+ // File disappeared between discovery and this gate — skip it.
455
+ }
456
+ }
457
+ return total;
458
+ }
459
+
460
+ /**
461
+ * Decide whether a full rebuild should take the bounded-memory streaming
462
+ * vectors path (see streaming-vectors.js) instead of the in-memory path.
463
+ *
464
+ * Two independent triggers, either is sufficient:
465
+ * - file count ≥ SWEET_SEARCH_STREAM_MIN_FILES (default 5000)
466
+ * - total admitted source bytes ≥ SWEET_SEARCH_STREAM_MIN_BYTES
467
+ * (default 512 MB; set to 0 to disable the byte trigger)
468
+ *
469
+ * The byte trigger exists because peak heap on the in-memory path scales
470
+ * with the chunk corpus (≈ source bytes), not file count: a repo with
471
+ * few-but-huge files (amalgamations, vendored/generated blobs, extreme
472
+ * duplication) can OOM the default ~4 GB heap while staying far under the
473
+ * file gate.
474
+ *
475
+ * The 512 MB default is deliberately conservative so the trigger only fires
476
+ * where the in-memory path would fail outright, never where it would merely
477
+ * be tight. The one MEASURED failure at this scale is libsql (596 MB
478
+ * admitted source; the in-memory path needed a 9.6+ GB heap); the wider
479
+ * crash zone is ESTIMATED — not measured — to start around ~200 MB of
480
+ * admitted source on a default ~4 GB heap. A repo the byte trigger newly
481
+ * moves to streaming was therefore not getting a usable in-memory index at
482
+ * all, so streaming strictly improves on a crash; every repo below the
483
+ * threshold keeps the byte-for-byte-identical in-memory path and identical
484
+ * retrieval behaviour.
485
+ *
486
+ * Sizes are only stat'd when the (free) count trigger hasn't already fired,
487
+ * so the byte check costs at most one stat per file on sub-threshold repos.
488
+ * The re-stat duplicates work discoverFiles already did for its size cap —
489
+ * accepted deliberately: threading sizes through would change discoverFiles'
490
+ * public return shape, and ≤5000 extra stats on a full rebuild is noise next
491
+ * to chunking + embedding.
492
+ *
493
+ * @returns {Promise<{useStreaming: boolean, reason?: 'files'|'bytes', totalBytes?: number, thresholdBytes?: number}>}
494
+ */
495
+ export async function shouldStreamVectors({ filesToIndex, dryRun, fullReindex, projectRoot = PROJECT_ROOT, env = process.env }) {
496
+ if (dryRun || !fullReindex || env.SWEET_SEARCH_STREAM_VECTORS === '0') {
497
+ return { useStreaming: false };
498
+ }
499
+ const streamMinFiles = Number(env.SWEET_SEARCH_STREAM_MIN_FILES) || 5000;
500
+ if (filesToIndex.length >= streamMinFiles) {
501
+ return { useStreaming: true, reason: 'files' };
502
+ }
503
+ // Unset/empty/invalid → 512 MB default; an explicit 0 (or negative)
504
+ // disables the byte trigger alone, leaving the count trigger active.
505
+ const rawMinBytes = env.SWEET_SEARCH_STREAM_MIN_BYTES;
506
+ const parsedMinBytes = (rawMinBytes === undefined || rawMinBytes === '') ? NaN : Number(rawMinBytes);
507
+ const streamMinBytes = Number.isFinite(parsedMinBytes)
508
+ ? (parsedMinBytes > 0 ? parsedMinBytes : Infinity)
509
+ : 512 * 1024 * 1024;
510
+ if (!Number.isFinite(streamMinBytes)) {
511
+ return { useStreaming: false };
512
+ }
513
+ const totalBytes = await sumFileSizesUpTo(filesToIndex, streamMinBytes, projectRoot);
514
+ if (totalBytes >= streamMinBytes) {
515
+ return { useStreaming: true, reason: 'bytes', totalBytes, thresholdBytes: streamMinBytes };
516
+ }
517
+ return { useStreaming: false };
518
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readFileSync } from 'fs';
11
- import { minimatch } from 'minimatch';
11
+ import { Minimatch } from 'minimatch';
12
12
  import { loadProjectConfig } from '../infrastructure/config/index.js';
13
13
 
14
14
  const MM_OPTS = { dot: true, nocase: false };
@@ -54,6 +54,31 @@ function getExcludes(projectRoot) {
54
54
  function resetCache() {
55
55
  _excludesByRoot.clear();
56
56
  _cachedExtraPatterns = null;
57
+ _excludeMatchersByRoot.clear();
58
+ _cachedExtraMatchers = null;
59
+ }
60
+
61
+ // Precompiled Minimatch instances — `minimatch(p, g, opts)` recompiles the
62
+ // glob to a regex on every call; `new Minimatch(g, opts).match(p)` is the
63
+ // documented equivalent with the compile amortized.
64
+ const _excludeMatchersByRoot = new Map();
65
+
66
+ function getExcludeMatchers(projectRoot) {
67
+ const key = projectRoot || '__cwd__';
68
+ let cached = _excludeMatchersByRoot.get(key);
69
+ if (cached) return cached;
70
+ cached = getExcludes(projectRoot)
71
+ .filter((g) => typeof g === 'string')
72
+ .map((g) => new Minimatch(g, MM_OPTS));
73
+ _excludeMatchersByRoot.set(key, cached);
74
+ return cached;
75
+ }
76
+
77
+ let _cachedExtraMatchers = null;
78
+ function getExtraMatchers() {
79
+ if (_cachedExtraMatchers !== null) return _cachedExtraMatchers;
80
+ _cachedExtraMatchers = loadExtraPatternsFromFile().map((g) => new Minimatch(g, MM_OPTS));
81
+ return _cachedExtraMatchers;
57
82
  }
58
83
 
59
84
  const GENERATED_MARKERS = [
@@ -87,13 +112,11 @@ function loadExtraPatternsFromFile() {
87
112
  export function isExcludedByConfig(filePath, projectRoot) {
88
113
  if (!filePath) return false;
89
114
  const p = normalizePath(filePath);
90
- const excludes = getExcludes(projectRoot);
91
- for (const g of excludes) {
92
- if (typeof g === 'string' && minimatch(p, g, MM_OPTS)) return true;
115
+ for (const m of getExcludeMatchers(projectRoot)) {
116
+ if (m.match(p)) return true;
93
117
  }
94
- const extras = loadExtraPatternsFromFile();
95
- for (const g of extras) {
96
- if (minimatch(p, g, MM_OPTS)) return true;
118
+ for (const m of getExtraMatchers()) {
119
+ if (m.match(p)) return true;
97
120
  }
98
121
  return false;
99
122
  }
@@ -29,12 +29,15 @@ import {
29
29
  getLocalPipeline,
30
30
  callLocalModelCpu,
31
31
  unloadLocalModel,
32
+ configureLocalModelRuntime,
32
33
  } from '../embedding/embedding-local-model.js';
33
34
  import {
34
35
  getLateInteractionPipeline,
35
36
  encodeDocumentsCpu,
36
37
  unloadLateInteractionModel,
38
+ configureLateInteractionRuntime,
37
39
  } from '../ranking/late-interaction-model.js';
40
+ import { backgroundIntraOpThreads } from '../infrastructure/onnx-session-utils.js';
38
41
 
39
42
  /**
40
43
  * Small-changeset threshold. Incremental runs with fewer files than this use
@@ -69,6 +72,29 @@ export function selectAcceleratorDeviceKind(pref) {
69
72
  return null;
70
73
  }
71
74
 
75
+ /**
76
+ * G3: arm the BACKGROUND/maintainer ORT profile for BOTH resident CPU
77
+ * sessions — dense (embedding-local-model) and late-interaction. The profile
78
+ * (force_spinning_stop + arena-off + 2–4 intra-op threads) prevents the two
79
+ * resident-daemon pathologies: idle spinning workers (~a core of idle CPU)
80
+ * and monotonic RSS from ORT arena extension (#25325; measured 354 × 128MB
81
+ * ≈ 34GB after one edit-heavy day when the LI session was left on the
82
+ * foreground profile). MUST run before the first encode — both session
83
+ * singletons are built once; configuring after is a silent no-op.
84
+ *
85
+ * Lives here (model-pool owns indexing-side model lifecycle) so daemon
86
+ * entrypoints don't need their own cross-context imports of the two model
87
+ * modules.
88
+ *
89
+ * @returns {{armed: boolean}}
90
+ */
91
+ export function armBackgroundOrtProfiles() {
92
+ const intraOpThreads = backgroundIntraOpThreads();
93
+ configureLocalModelRuntime({ intraOpThreads, background: true });
94
+ configureLateInteractionRuntime({ intraOpThreads, background: true });
95
+ return { armed: true };
96
+ }
97
+
72
98
  /**
73
99
  * Whether this process can use an inference accelerator (Metal / CoreML
74
100
  * cascade / CUDA) for indexing. Requires both accelerator-capable hardware
@@ -112,6 +112,71 @@ export function isOverBudget(totalRssBytes, budgetBytesValue) {
112
112
  );
113
113
  }
114
114
 
115
+ // =============================================================================
116
+ // D.5: per-process maintainer RSS recycle ceiling
117
+ // =============================================================================
118
+ //
119
+ // The fleet budget above evicts the longest-IDLE daemon, so a single fat
120
+ // ACTIVE maintainer can legitimately ride up to the whole fleet budget
121
+ // (~76 GB on a 128 GB host) — measured in the wild when a session-config
122
+ // regression left the LI ORT session on the foreground arena profile
123
+ // (354 × 128MB arena extensions ≈ 34 GB RSS). This ceiling is the
124
+ // per-process backstop: when the maintainer's OWN rss crosses the line at a
125
+ // tick boundary, it requests the same graceful shutdown the idle-TTL uses
126
+ // (finish tick, publish, release lock, exit). The O_EXCL launch trigger
127
+ // respawns a fresh few-hundred-MB process on the next dirty event, so the
128
+ // index is unchanged — exit-to-reclaim is already the sanctioned way to
129
+ // return ORT memory (#25325).
130
+ //
131
+ // Guards against restart-thrash:
132
+ // - at least one COMPLETED tick (never abort startup work),
133
+ // - minimum uptime (default 10 min ⇒ worst case 6 recycles/hour even when
134
+ // the ceiling is misconfigured below the resident floor — visible via
135
+ // the per-recycle WARN, which names the env knob to raise).
136
+ //
137
+ // Measured anchors (2026-07-03, arena-off profile): steady floor ~2.7-2.9 GB
138
+ // on a mid-size repo, encode-active peaks ~4.7 GB — hence the 4 GiB default
139
+ // floor and 8 GiB cap.
140
+
141
+ const MAINTAINER_RSS_CEILING_FRACTION = 0.25;
142
+ const MAINTAINER_RSS_CEILING_MIN_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
143
+ const MAINTAINER_RSS_CEILING_MAX_BYTES = 8 * 1024 * 1024 * 1024; // 8 GiB
144
+ const MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT = 10 * 60 * 1000; // 10 min
145
+
146
+ /**
147
+ * Resolve the per-process maintainer RSS ceiling in bytes.
148
+ * `SWEET_SEARCH_MAINTAINER_RSS_MAX_MB` set: explicit MB value; 0/garbage
149
+ * disables (returns 0). Unset: clamp(25% of RAM, 4 GiB, 8 GiB).
150
+ */
151
+ export function maintainerRssCeilingBytes(env = process.env, totalMem = os.totalmem()) {
152
+ const raw = env.SWEET_SEARCH_MAINTAINER_RSS_MAX_MB;
153
+ if (raw != null && raw !== '') {
154
+ const mb = Number(raw);
155
+ if (!Number.isFinite(mb) || mb <= 0) return 0;
156
+ return Math.floor(mb * 1024 * 1024);
157
+ }
158
+ const scaled = Math.floor(totalMem * MAINTAINER_RSS_CEILING_FRACTION);
159
+ return Math.min(MAINTAINER_RSS_CEILING_MAX_BYTES, Math.max(MAINTAINER_RSS_CEILING_MIN_BYTES, scaled));
160
+ }
161
+
162
+ /** Minimum uptime before an RSS recycle may fire. */
163
+ export function maintainerRssMinUptimeMs(env = process.env) {
164
+ const raw = Number(env.SWEET_SEARCH_MAINTAINER_RSS_MIN_UPTIME_MS);
165
+ return Number.isFinite(raw) && raw >= 0 ? raw : MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT;
166
+ }
167
+
168
+ /**
169
+ * Pure recycle decision — evaluated at tick boundaries only.
170
+ * @returns {{recycle: boolean, reason: string}}
171
+ */
172
+ export function shouldRecycleForRss({ rssBytes, ceilingBytes, uptimeMs, minUptimeMs, ticksCompleted }) {
173
+ if (!Number.isFinite(ceilingBytes) || ceilingBytes <= 0) return { recycle: false, reason: 'disabled' };
174
+ if (!Number.isFinite(rssBytes) || rssBytes <= ceilingBytes) return { recycle: false, reason: 'under-ceiling' };
175
+ if (!(ticksCompleted >= 1)) return { recycle: false, reason: 'no-completed-tick' };
176
+ if (!(uptimeMs >= minUptimeMs)) return { recycle: false, reason: 'min-uptime' };
177
+ return { recycle: true, reason: 'over-ceiling' };
178
+ }
179
+
115
180
  /**
116
181
  * Read the resident-set size (bytes) of an arbitrary pid, best-effort and
117
182
  * cross-platform. Returns a non-negative integer, or 0 when unknown (dead pid,
@@ -10,7 +10,7 @@
10
10
 
11
11
  import Database from 'better-sqlite3';
12
12
  import { existsSync, statSync } from 'fs';
13
- import { applyReadPragmas } from './db-utils.js';
13
+ import { applyReadPragmas, prepareCached } from './db-utils.js';
14
14
  import { readAdjacentManifest, resolveManifestCodeGraphPath, sqlAliasPrefix } from './code-graph-visibility.js';
15
15
 
16
16
  export class CodeGraphRepository {
@@ -72,7 +72,7 @@ export class CodeGraphRepository {
72
72
  }
73
73
 
74
74
  _hasColumns(db, table, columns) {
75
- const rows = db.prepare(`PRAGMA table_info(${table})`).all();
75
+ const rows = prepareCached(db, `PRAGMA table_info(${table})`).all();
76
76
  const names = new Set(rows.map((row) => row.name));
77
77
  return columns.every((column) => names.has(column));
78
78
  }
@@ -138,7 +138,7 @@ export class CodeGraphRepository {
138
138
  const db = this._open();
139
139
  if (!db) return null;
140
140
  try {
141
- const row = db.prepare(`
141
+ const row = prepareCached(db, `
142
142
  SELECT id, name, type, start_line, end_line, parent_class
143
143
  FROM entities
144
144
  WHERE file_path = ?
@@ -207,7 +207,7 @@ export class CodeGraphRepository {
207
207
  // in the chunk range (catches entities like a multi-chunk struct whose
208
208
  // declaration starts in this chunk but body extends beyond — e.g. gin
209
209
  // Engine struct at gin.go:92-189 split across chunks 92-133 and 134-189).
210
- const containedRows = db.prepare(`
210
+ const containedRows = prepareCached(db, `
211
211
  SELECT name, type, start_line, end_line FROM entities
212
212
  WHERE file_path = ? AND start_line >= ? AND end_line <= ? AND ${this._entityVisibilitySql(db)}
213
213
  ORDER BY (end_line - start_line) DESC
@@ -219,7 +219,7 @@ export class CodeGraphRepository {
219
219
  return { name: row.name, type: row.type, startLine: row.start_line, endLine: row.end_line };
220
220
  }
221
221
  }
222
- const startsInRows = db.prepare(`
222
+ const startsInRows = prepareCached(db, `
223
223
  SELECT name, type, start_line, end_line FROM entities
224
224
  WHERE file_path = ? AND start_line >= ? AND start_line <= ? AND ${this._entityVisibilitySql(db)}
225
225
  ORDER BY (end_line - start_line) DESC
@@ -245,7 +245,7 @@ export class CodeGraphRepository {
245
245
  const db = this._open();
246
246
  if (!db) return null;
247
247
  try {
248
- const row = db.prepare(`
248
+ const row = prepareCached(db, `
249
249
  SELECT id, name, type, start_line, end_line, parent_class
250
250
  FROM entities
251
251
  WHERE file_path = ?
@@ -289,7 +289,7 @@ export class CodeGraphRepository {
289
289
  const db = this._open();
290
290
  if (!db) return [];
291
291
  try {
292
- const rows = db.prepare(`
292
+ const rows = prepareCached(db, `
293
293
  SELECT id, name, type, start_line, end_line, parent_class
294
294
  FROM entities
295
295
  WHERE file_path = ?
@@ -322,7 +322,7 @@ export class CodeGraphRepository {
322
322
  const db = this._open();
323
323
  if (!db) return null;
324
324
  try {
325
- const row = db.prepare(`
325
+ const row = prepareCached(db, `
326
326
  SELECT id, name, type, file_path, start_line, end_line, parent_class
327
327
  FROM entities
328
328
  WHERE id = ? AND ${this._entityVisibilitySql(db)}
@@ -379,7 +379,7 @@ export class CodeGraphRepository {
379
379
  const args = types
380
380
  ? [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), ...types, limit]
381
381
  : [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), limit];
382
- const rows = db.prepare(baseSql).all(...args);
382
+ const rows = prepareCached(db, baseSql).all(...args);
383
383
  return rows.map(r => ({
384
384
  type: r.rel_type,
385
385
  targetName: r.target_name,
@@ -445,7 +445,7 @@ export class CodeGraphRepository {
445
445
  const args = excludeFile
446
446
  ? [...uniq, ...types, ...this._entityVisibilityParams(db), excludeFile, limit]
447
447
  : [...uniq, ...types, ...this._entityVisibilityParams(db), limit];
448
- const rows = db.prepare(sql).all(...args);
448
+ const rows = prepareCached(db, sql).all(...args);
449
449
  // De-dup by name+type, keeping the first (smallest body).
450
450
  const seen = new Set();
451
451
  const out = [];
@@ -493,7 +493,7 @@ export class CodeGraphRepository {
493
493
  ORDER BY (end_line - start_line) ASC
494
494
  LIMIT ?
495
495
  `;
496
- const rows = db.prepare(sql).all(...uniq, ...types, ...this._entityVisibilityParams(db), limit);
496
+ const rows = prepareCached(db, sql).all(...uniq, ...types, ...this._entityVisibilityParams(db), limit);
497
497
  return rows.map(row => ({
498
498
  id: row.id,
499
499
  name: row.name,
@@ -549,7 +549,7 @@ export class CodeGraphRepository {
549
549
  ORDER BY (end_line - start_line) ASC
550
550
  LIMIT ?
551
551
  `;
552
- const rows = db.prepare(sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db), limit);
552
+ const rows = prepareCached(db, sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db), limit);
553
553
  return rows.map(row => ({
554
554
  id: row.id,
555
555
  name: row.name,
@@ -598,7 +598,7 @@ export class CodeGraphRepository {
598
598
  AND ${this._entityVisibilitySql(db)}
599
599
  GROUP BY lname
600
600
  `;
601
- const rows = db.prepare(sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db));
601
+ const rows = prepareCached(db, sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db));
602
602
  const map = new Map();
603
603
  for (const r of rows) map.set(r.lname, r.count);
604
604
  // Names with no row are absent — caller treats absent as 0.
@@ -640,7 +640,7 @@ export class CodeGraphRepository {
640
640
  const args = types
641
641
  ? [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), ...types, limit]
642
642
  : [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), limit];
643
- const rows = db.prepare(baseSql).all(...args);
643
+ const rows = prepareCached(db, baseSql).all(...args);
644
644
  return rows.map(r => ({
645
645
  type: r.rel_type,
646
646
  contextLine: r.context_line || null,
@@ -689,7 +689,7 @@ export class CodeGraphRepository {
689
689
  return Number.isFinite(n) && n > 0 ? n : 12;
690
690
  })();
691
691
  try {
692
- const rows = db.prepare(`
692
+ const rows = prepareCached(db, `
693
693
  SELECT r.target_name, COUNT(*) as cnt
694
694
  FROM relationships r
695
695
  LEFT JOIN entities e ON e.id = r.target_id AND ${this._entityVisibilitySql(db, 'e')}
@@ -798,7 +798,7 @@ export class CodeGraphRepository {
798
798
  if (!db) return null;
799
799
  try {
800
800
  if (this._hasColumns(db, 'entities', ['epoch_written', 'epoch_retired'])) {
801
- const visible = db.prepare(`
801
+ const visible = prepareCached(db, `
802
802
  SELECT 1 FROM entities
803
803
  WHERE file_path = ? AND ${this._entityVisibilitySql(db)}
804
804
  LIMIT 1
@@ -827,10 +827,10 @@ export class CodeGraphRepository {
827
827
  const staleArgs = this._manifestEpoch !== null
828
828
  ? [filePath, this._manifestEpoch, this._manifestEpoch]
829
829
  : [filePath];
830
- const staleRow = db.prepare(staleSql).get(...staleArgs);
830
+ const staleRow = prepareCached(db, staleSql).get(...staleArgs);
831
831
  return staleRow ? { staleSince: staleRow.stale_since || null } : null;
832
832
  }
833
- const row = db.prepare(`
833
+ const row = prepareCached(db, `
834
834
  SELECT stale_since, MIN(ROWID) as first_row
835
835
  FROM entities
836
836
  WHERE file_path = ?
@@ -17,6 +17,35 @@ import Database from 'better-sqlite3';
17
17
  */
18
18
  export const SAFE_IN_CLAUSE_BATCH = 999;
19
19
 
20
+ // Per-connection prepared-statement cache. Statements belong to their
21
+ // Database object, so the WeakMap self-invalidates when a connection object
22
+ // is dropped (closed connections are never passed back in by callers).
23
+ // Only use for .get/.all/.run statements — never .iterate (a cached statement
24
+ // mid-iteration cannot be re-entered).
25
+ const _stmtCaches = new WeakMap();
26
+
27
+ /**
28
+ * db.prepare with a per-connection cache — compiles each distinct SQL string
29
+ * once per Database object instead of once per call.
30
+ *
31
+ * @param {import('better-sqlite3').Database} db
32
+ * @param {string} sql
33
+ * @returns {import('better-sqlite3').Statement}
34
+ */
35
+ export function prepareCached(db, sql) {
36
+ let cache = _stmtCaches.get(db);
37
+ if (!cache) {
38
+ cache = new Map();
39
+ _stmtCaches.set(db, cache);
40
+ }
41
+ let stmt = cache.get(sql);
42
+ if (!stmt) {
43
+ stmt = db.prepare(sql);
44
+ cache.set(sql, stmt);
45
+ }
46
+ return stmt;
47
+ }
48
+
20
49
  /**
21
50
  * Apply read-path PRAGMA optimizations to a read-only database connection.
22
51
  *