sweet-search 2.5.1 → 2.5.2

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/core/cli.js CHANGED
@@ -28,6 +28,20 @@ if (args[0] === 'init') {
28
28
  // Hybrid span-selection reader; runs in JS (depends on LI index + ranking).
29
29
  const { handleReadSemanticCli } = await import('./search/search-read-semantic.js');
30
30
  await handleReadSemanticCli(args.slice(1));
31
+ } else if (args[0] === 'index') {
32
+ // Indexing pipeline. Forwarded to index-codebase-v21.js::main(), which
33
+ // reads its own flags via process.argv. Setting argv here is required
34
+ // because the indexer's parseArgs reads process.argv.slice(2) by default.
35
+ // Without this subcommand, npm-installed users had no way to invoke
36
+ // indexing — `node ./node_modules/sweet-search/core/indexing/index-codebase-v21.js`
37
+ // was a silent no-op (direct-run guard mismatched under symlinked installs)
38
+ // and the bin had no `index` entry at all. Forwards every argument after
39
+ // `index` so existing flag combos (--full / --graph-only / --vectors-only /
40
+ // --files-from-stdin / --late-interaction-model=… / etc.) all work.
41
+ const indexerArgs = args.slice(1);
42
+ process.argv = [process.argv[0], 'index-codebase-v21.js', ...indexerArgs];
43
+ const { main: runIndexer } = await import('./indexing/index-codebase-v21.js');
44
+ await runIndexer();
31
45
  } else if (args[0] === '--serve' || args[0] === '--stop') {
32
46
  // Warm search server lifecycle is implemented in JS.
33
47
  const { runCli } = await import('./search/index.js');
@@ -39,6 +53,7 @@ Usage:
39
53
  sweet-search <query> Search the indexed codebase
40
54
  sweet-search read <file...> Filesystem-grounded read (1-20 files)
41
55
  sweet-search read-semantic <f> <q> Return only file spans relevant to a query
56
+ sweet-search index [options] Build / update the codebase index
42
57
  sweet-search init [options] Set up runtime assets and models
43
58
  sweet-search uninstall [opts] Remove local state created by init
44
59
  sweet-search prewarm-vocab [file] Pre-warm vocabulary cache with terms
@@ -50,6 +65,15 @@ Options:
50
65
  --json Output results as JSON
51
66
  --cold Force cold start (skip warm server)
52
67
 
68
+ Indexing flags (sweet-search index ...):
69
+ --full Full reindex from scratch
70
+ --graph-only Build code graph only
71
+ --vectors-only Build vectors + HNSW only (skips code graph)
72
+ --files-from-stdin Read newline-delimited paths from stdin
73
+ --late-interaction-model=ID Override the LI variant for this run
74
+ --no-late-interaction Skip LI index build
75
+ --quiet | --verbose Logging verbosity
76
+
53
77
  Run 'sweet-search init --help' or 'sweet-search uninstall --help' for subcommand options.`);
54
78
  } else {
55
79
  const { resolveNativeBinary } = await import('./infrastructure/index.js');
@@ -45,6 +45,7 @@ export class LRUCache {
45
45
  }
46
46
 
47
47
  has(key) { return this.cache.has(key); }
48
+ delete(key) { this.hitCount.delete(key); return this.cache.delete(key); }
48
49
  getHitCount(key) { return this.hitCount.get(key) || 0; }
49
50
  size() { return this.cache.size; }
50
51
  clear() { this.cache.clear(); this.hitCount.clear(); }
@@ -191,6 +192,62 @@ export class QueryStats {
191
192
  // model is not silently served when a different model is active.
192
193
  const VOCAB_SCHEMA_VERSION = 3;
193
194
 
195
+ /**
196
+ * Coerce an input value into a Float32Array suitable for downstream embedding
197
+ * math (truncateForHNSW, late-interaction MaxSim, cosine similarity).
198
+ *
199
+ * Why this exists: persisted vocabularies are JSON-serialised. JSON.stringify
200
+ * on a Float32Array produces an indexed object `{"0": v0, "1": v1, ...}`,
201
+ * not an array. After `JSON.parse`, the value has `.length === undefined`,
202
+ * `.slice === undefined`, and crashes any downstream consumer that calls
203
+ * vector methods. This helper repairs the value at the cache boundary so
204
+ * the rest of the embedding pipeline can rely on a uniform vector contract.
205
+ *
206
+ * Accepted inputs:
207
+ * - Float32Array → returned as-is
208
+ * - Array<number> → wrapped in Float32Array
209
+ * - Float64Array / Int*Array etc. → copied into Float32Array
210
+ * - Plain object with stringly-keyed numeric indices ("0","1",...,"N-1")
211
+ * → reconstructed as Float32Array of length N
212
+ *
213
+ * Returns null when the input cannot be sensibly interpreted as a vector
214
+ * (callers should drop the cache entry and re-derive).
215
+ *
216
+ * @param {*} value
217
+ * @returns {Float32Array|null}
218
+ */
219
+ export function coerceToFloat32Vector(value) {
220
+ if (value == null) return null;
221
+ if (value instanceof Float32Array) return value;
222
+ if (Array.isArray(value)) return Float32Array.from(value);
223
+ // Other typed arrays: copy values into a Float32Array.
224
+ if (ArrayBuffer.isView(value) && typeof value.length === 'number') {
225
+ return Float32Array.from(value);
226
+ }
227
+ // Plain object form from JSON-deserialised Float32Array.
228
+ if (typeof value === 'object') {
229
+ const keys = Object.keys(value);
230
+ if (keys.length === 0) return null;
231
+ // All keys must be string-encoded non-negative integers and contiguous
232
+ // from 0 to length-1. (We do not try to "fill gaps" — that would silently
233
+ // mask a real bug.)
234
+ const indices = new Array(keys.length);
235
+ for (let i = 0; i < keys.length; i++) {
236
+ const k = keys[i];
237
+ // Reject anything that isn't an integer-shaped key.
238
+ if (!/^\d+$/.test(k)) return null;
239
+ const n = +k;
240
+ if (!Number.isInteger(n) || n < 0 || n >= keys.length) return null;
241
+ indices[n] = value[k];
242
+ }
243
+ for (let i = 0; i < indices.length; i++) {
244
+ if (typeof indices[i] !== 'number' || !Number.isFinite(indices[i])) return null;
245
+ }
246
+ return Float32Array.from(indices);
247
+ }
248
+ return null;
249
+ }
250
+
194
251
  /** Build the embedding-fingerprint we expect a vocabulary file to match. */
195
252
  function currentVocabFingerprint() {
196
253
  return {
@@ -269,10 +326,27 @@ export class Vocabulary {
269
326
  this.terms.clear();
270
327
  } else {
271
328
  this.metadata = { ...this.metadata, ...(data.metadata || {}) };
272
- for (const [term, embedding] of Object.entries(data.terms || {})) {
273
- this.terms.set(term, embedding);
329
+ let normalized = 0;
330
+ let dropped = 0;
331
+ for (const [term, raw] of Object.entries(data.terms || {})) {
332
+ // Coerce to Float32Array. Persisted vocabs JSON-serialise typed
333
+ // arrays as indexed objects (`{"0": v0, ...}`), which otherwise
334
+ // crash downstream `embedding.slice(...)` calls (see
335
+ // `truncateForHNSW`). Reject any entry we cannot interpret as a
336
+ // vector — better to re-embed than to surface a corrupt vector.
337
+ const vec = coerceToFloat32Vector(raw);
338
+ if (vec) {
339
+ this.terms.set(term, vec);
340
+ normalized++;
341
+ } else {
342
+ dropped++;
343
+ }
344
+ }
345
+ if (dropped > 0) {
346
+ console.log(`Vocabulary: Loaded ${normalized} pre-computed embeddings (dropped ${dropped} unrecognised)`);
347
+ } else {
348
+ console.log(`Vocabulary: Loaded ${normalized} pre-computed embeddings`);
274
349
  }
275
- console.log(`Vocabulary: Loaded ${this.terms.size} pre-computed embeddings`);
276
350
  }
277
351
  }
278
352
  } catch (err) {
@@ -292,7 +366,18 @@ export class Vocabulary {
292
366
  this.metadata.model = EMBEDDING_CONFIG.model;
293
367
  this.metadata.dimension = EMBEDDING_CONFIG.dimension;
294
368
  if (!this.metadata.created) this.metadata.created = this.metadata.lastUpdated;
295
- const data = { metadata: this.metadata, terms: Object.fromEntries(this.terms) };
369
+ // Normalise to plain arrays so JSON.stringify produces a compact,
370
+ // round-trippable form. Float32Array would otherwise serialise as
371
+ // an indexed object ({"0": v0, "1": v1, ...}) which load() can read
372
+ // (via coerceToFloat32Vector) but which is wasteful and was the
373
+ // shape that originally caused the `embedding.slice` bug.
374
+ const termsOut = {};
375
+ for (const [term, vec] of this.terms.entries()) {
376
+ termsOut[term] = vec instanceof Float32Array || ArrayBuffer.isView(vec)
377
+ ? Array.from(vec)
378
+ : vec;
379
+ }
380
+ const data = { metadata: this.metadata, terms: termsOut };
296
381
  await writeJsonAtomic(this.vocabPath, JSON.stringify(data, null, 2));
297
382
  });
298
383
  }
@@ -303,6 +388,7 @@ export class Vocabulary {
303
388
  }
304
389
  set(term, embedding) { this.terms.set(this.normalize(term), embedding); }
305
390
  has(term) { return this.terms.has(this.normalize(term)); }
391
+ delete(term) { return this.terms.delete(this.normalize(term)); }
306
392
  normalize(term) { return term.toLowerCase().trim(); }
307
393
  size() { return this.terms.size; }
308
394
 
@@ -45,6 +45,7 @@ import {
45
45
  queryDeduplicator,
46
46
  queryStats,
47
47
  cacheStats,
48
+ coerceToFloat32Vector,
48
49
  getCacheStats as _getCacheStats,
49
50
  getSemanticCacheStats,
50
51
  clearCache,
@@ -205,17 +206,38 @@ export async function getEmbedding(text, options = {}) {
205
206
  if (useCache && EMBEDDING_CONFIG.cache?.enabled) {
206
207
  const cached = queryCache.get(cacheKey);
207
208
  if (cached) {
208
- cacheStats.hits++;
209
- return { embedding: cached, cached: true, source: 'lru', latency_us: Math.round((performance.now() - start) * 1000) };
209
+ // Defensive guard: a cache value MUST be a vector with .length and
210
+ // .slice. Persisted vocabularies that round-tripped through JSON
211
+ // produce indexed-object shapes which crash downstream consumers.
212
+ // Coerce; if unrecoverable, drop the entry and fall through.
213
+ const cachedVec = coerceToFloat32Vector(cached);
214
+ if (cachedVec) {
215
+ if (cachedVec !== cached) queryCache.set(cacheKey, cachedVec);
216
+ cacheStats.hits++;
217
+ return { embedding: cachedVec, cached: true, source: 'lru', latency_us: Math.round((performance.now() - start) * 1000) };
218
+ }
219
+ queryCache.delete?.(cacheKey);
220
+ console.warn(`[embedding] LRU cache held non-vector for "${cacheKey.slice(0, 60)}"; regenerating`);
210
221
  }
211
222
 
212
223
  if (isQuery && EMBEDDING_CONFIG.cache?.useVocabulary !== false) {
213
224
  await vocabulary.load();
214
225
  const vocabHit = vocabulary.get(text);
215
226
  if (vocabHit) {
216
- cacheStats.vocabularyHits++;
217
- queryCache.set(cacheKey, vocabHit);
218
- return { embedding: vocabHit, cached: true, source: 'vocabulary', latency_us: Math.round((performance.now() - start) * 1000) };
227
+ const vocabVec = coerceToFloat32Vector(vocabHit);
228
+ if (vocabVec) {
229
+ // Backfill the in-memory vocab map with the typed-array form so
230
+ // subsequent hits skip re-coercion.
231
+ if (vocabVec !== vocabHit) vocabulary.set?.(text, vocabVec);
232
+ cacheStats.vocabularyHits++;
233
+ queryCache.set(cacheKey, vocabVec);
234
+ return { embedding: vocabVec, cached: true, source: 'vocabulary', latency_us: Math.round((performance.now() - start) * 1000) };
235
+ }
236
+ // Unrecoverable vocab entry — drop it and continue. (load() now
237
+ // normalises on read, so this branch should be unreachable in
238
+ // practice; it is the belt-and-braces for older code paths.)
239
+ vocabulary.delete?.(text);
240
+ console.warn(`[embedding] vocabulary held non-vector for "${text.slice(0, 60)}"; dropping and regenerating`);
219
241
  }
220
242
  }
221
243
  }
@@ -40,6 +40,7 @@ if (process.env.SWEET_SEARCH_UV_THREADPOOL_SIZE && !process.env.UV_THREADPOOL_SI
40
40
  import { existsSync } from 'fs';
41
41
 
42
42
  import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
43
+ import { applyPersistedLiModel } from '../infrastructure/init-config.js';
43
44
  import { resolveRelationshipTargets } from '../graph/relationship-resolver.js';
44
45
  import { requireNativeAnn as requireNativeAnnBackend } from '../vector-store/hnsw-index.js';
45
46
  import { getStats as getIncrementalStats } from './incremental-tracker.js';
@@ -124,11 +125,18 @@ async function main() {
124
125
  setVerboseMode(true);
125
126
  }
126
127
 
127
- // Apply late interaction model overrides before any model code runs
128
+ // Apply late interaction model overrides before any model code runs.
129
+ // Precedence: --no-late-interaction > --late-interaction-model=… > env
130
+ // var (already honoured by LATE_INTERACTION_CONFIG.model at module load) >
131
+ // .sweet-search/config.json::runtime.li.model > built-in default. Only
132
+ // touch the persisted-config branch when neither CLI flag was used —
133
+ // applyPersistedLiModel internally re-checks the env var.
128
134
  if (noLateInteraction) {
129
135
  LATE_INTERACTION_CONFIG.model = false;
130
136
  } else if (lateInteractionModel) {
131
137
  LATE_INTERACTION_CONFIG.model = lateInteractionModel;
138
+ } else {
139
+ applyPersistedLiModel(process.env.SWEET_SEARCH_PROJECT_ROOT || process.cwd());
132
140
  }
133
141
 
134
142
  log(`${colors.bright}╔═══════════════════════════════════════════════════╗${colors.reset}`, 'bright');
@@ -441,7 +449,28 @@ Output:
441
449
  }
442
450
  }
443
451
 
444
- if (import.meta.url === `file://${process.argv[1]}`) {
452
+ // Direct-run guard. The previous `import.meta.url === \`file://${process.argv[1]}\``
453
+ // form silently no-op'd under three real-world conditions:
454
+ // 1. `npm install ../sweet-search-private` (file install) symlinks
455
+ // `node_modules/sweet-search/` to the source — `process.argv[1]` is the
456
+ // symlink path while `import.meta.url` resolves to the realpath.
457
+ // 2. Paths containing spaces or unicode — the URL form encodes them but
458
+ // `file://` + raw path doesn't.
459
+ // 3. Windows backslash vs URL forward-slash mismatch.
460
+ // Resolve both sides through `realpathSync(fileURLToPath(...))` so the
461
+ // comparison survives every common install layout. Falls back to never-direct
462
+ // (safe default) if either side errors.
463
+ import { realpathSync } from 'node:fs';
464
+ import { fileURLToPath } from 'node:url';
465
+ const _isDirectRun = (() => {
466
+ if (!process.argv[1]) return false;
467
+ try {
468
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
469
+ } catch {
470
+ return false;
471
+ }
472
+ })();
473
+ if (_isDirectRun) {
445
474
  main().catch(err => {
446
475
  console.error(err);
447
476
  process.exit(1);
@@ -82,6 +82,8 @@ export {
82
82
  loadInitConfig,
83
83
  writeInitConfig,
84
84
  readPersistedLiPolicy,
85
+ resolveRuntimeLiModel,
86
+ applyPersistedLiModel,
85
87
  } from './init-config.js';
86
88
 
87
89
  // Language analysis
@@ -12,6 +12,8 @@
12
12
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
 
15
+ import { LATE_INTERACTION_CONFIG } from './config/ranking.js';
16
+
15
17
  export const INIT_DATA_DIR_NAME = '.sweet-search';
16
18
  export const INIT_CONFIG_FILE_NAME = 'config.json';
17
19
 
@@ -76,3 +78,139 @@ export function readPersistedLiPolicy(projectRoot) {
76
78
  if (typeof li.searchReranking === 'string') out.searchReranking = li.searchReranking;
77
79
  return out;
78
80
  }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Runtime LI model resolution (Phase 4 follow-up).
84
+ //
85
+ // Background: scripts/init.js writes the user's chosen LI variant into
86
+ // `runtime.li.model` of `.sweet-search/config.json` (one of 'lateon-code',
87
+ // 'lateon-code-edge', or 'none'). However the runtime late-interaction
88
+ // machinery — encodeQuery, the LI index header check, the native model
89
+ // loader, the CoreML cascade dispatcher, the indexer, the prewarm hook —
90
+ // all read `LATE_INTERACTION_CONFIG.model` directly from
91
+ // `core/infrastructure/config/ranking.js`, which only honours
92
+ // `process.env.SWEET_SEARCH_LATE_INTERACTION_MODEL`. Without this bridge
93
+ // an edge-only init would silently boot the standard model on every search,
94
+ // breaking `read-semantic`, ColGrep, and every encodeQuery path even when
95
+ // the persisted choice is correct.
96
+ //
97
+ // `applyPersistedLiModel` closes that gap. It is called from one place per
98
+ // process entry point (SweetSearch ctor, read-semantic CLI/API, server
99
+ // startup, prewarm hook) and is fully idempotent + cheap.
100
+ //
101
+ // Precedence (most-specific wins):
102
+ // 1. explicit env var SWEET_SEARCH_LATE_INTERACTION_MODEL (CI / scripts)
103
+ // 2. persisted runtime.li.model in .sweet-search/config.json
104
+ // 3. config default ('lateon-code')
105
+ //
106
+ // `'none'` from persisted config maps to the same disabled state the
107
+ // indexer's `--no-late-interaction` flag uses (`model = false`), so
108
+ // `LATE_INTERACTION_CONFIG.enabled` collapses to false everywhere LI is
109
+ // consulted.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const VALID_RUNTIME_LI_MODEL_IDS = new Set([
113
+ 'lateon-code',
114
+ 'lateon-code-edge',
115
+ 'none',
116
+ ]);
117
+
118
+ /**
119
+ * Resolve the active LI runtime model id given precedence inputs. Pure;
120
+ * does no I/O of its own. Caller passes in the persisted record (or null).
121
+ *
122
+ * Returns one of:
123
+ * - 'lateon-code' → standard variant
124
+ * - 'lateon-code-edge' → edge variant
125
+ * - false → LI disabled (matches index-codebase-v21's `--no-late-interaction`)
126
+ *
127
+ * @param {string|null|undefined} persistedModel
128
+ * @param {object} [env=process.env]
129
+ * @param {string} [defaultModel='lateon-code']
130
+ */
131
+ export function resolveRuntimeLiModel(
132
+ persistedModel,
133
+ env = process.env,
134
+ defaultModel = 'lateon-code',
135
+ ) {
136
+ // 1. Explicit env override always wins (preserves the CI/script contract
137
+ // documented on LATE_INTERACTION_CONFIG.model).
138
+ const fromEnv = env?.SWEET_SEARCH_LATE_INTERACTION_MODEL;
139
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) {
140
+ if (fromEnv === 'none') return false;
141
+ if (fromEnv === 'false') return false;
142
+ return fromEnv;
143
+ }
144
+ // 2. Persisted choice from .sweet-search/config.json.
145
+ if (typeof persistedModel === 'string' && VALID_RUNTIME_LI_MODEL_IDS.has(persistedModel)) {
146
+ if (persistedModel === 'none') return false;
147
+ return persistedModel;
148
+ }
149
+ // 3. Default — preserves the historical 'lateon-code' fallback.
150
+ return defaultModel;
151
+ }
152
+
153
+ /**
154
+ * Apply the persisted LI model choice from `.sweet-search/config.json`
155
+ * to the global `LATE_INTERACTION_CONFIG.model`, honouring the precedence
156
+ * ladder documented above. Idempotent + cheap; safe to call from every
157
+ * runtime entry point.
158
+ *
159
+ * Returns a small report object so the caller can log / surface the
160
+ * decision when verbose. Never throws.
161
+ *
162
+ * {
163
+ * applied: 'lateon-code' | 'lateon-code-edge' | false,
164
+ * before: <prior LATE_INTERACTION_CONFIG.model>,
165
+ * source: 'env' | 'persisted' | 'default',
166
+ * persistedModel: <raw persisted value or null>,
167
+ * changed: boolean,
168
+ * }
169
+ *
170
+ * @param {string} projectRoot
171
+ * @param {object} [opts]
172
+ * @param {object} [opts.env=process.env]
173
+ * @param {boolean} [opts.force=false] — when true, applies even if the
174
+ * active value already matches (used by tests resetting state).
175
+ */
176
+ export function applyPersistedLiModel(projectRoot, opts = {}) {
177
+ const env = opts.env ?? process.env;
178
+
179
+ const before = LATE_INTERACTION_CONFIG.model;
180
+
181
+ let persistedModel = null;
182
+ try {
183
+ const persisted = readPersistedLiPolicy(projectRoot);
184
+ persistedModel = typeof persisted.liModel === 'string' ? persisted.liModel : null;
185
+ } catch {
186
+ persistedModel = null;
187
+ }
188
+
189
+ const resolved = resolveRuntimeLiModel(persistedModel, env, 'lateon-code');
190
+
191
+ let source;
192
+ if (typeof env?.SWEET_SEARCH_LATE_INTERACTION_MODEL === 'string'
193
+ && env.SWEET_SEARCH_LATE_INTERACTION_MODEL.length > 0) {
194
+ source = 'env';
195
+ } else if (persistedModel != null && VALID_RUNTIME_LI_MODEL_IDS.has(persistedModel)) {
196
+ source = 'persisted';
197
+ } else {
198
+ source = 'default';
199
+ }
200
+
201
+ if (opts.force === true || resolved !== before) {
202
+ // Mutates the shared LATE_INTERACTION_CONFIG singleton — this is the
203
+ // entire point of this helper. The ESM namespace binding is read-only
204
+ // but the OBJECT it references is the same reference every consumer
205
+ // (encodeQuery, indexer, native-inference, prewarm) holds.
206
+ LATE_INTERACTION_CONFIG.model = resolved;
207
+ }
208
+
209
+ return {
210
+ applied: resolved,
211
+ before,
212
+ source,
213
+ persistedModel,
214
+ changed: resolved !== before,
215
+ };
216
+ }
@@ -32,8 +32,23 @@
32
32
  import path from 'node:path';
33
33
  import { CodebaseRepository } from '../infrastructure/codebase-repository.js';
34
34
  import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
35
+ import { applyPersistedLiModel } from '../infrastructure/init-config.js';
35
36
  import { readFile as readFileExact } from './search-read.js';
36
37
 
38
+ // Applies the user's persisted LI model exactly once per (projectRoot, env)
39
+ // pair so encodeQuery/_getLateInteractionIndex below see the right variant.
40
+ // Without this an edge-only init silently uses the standard 768d model for
41
+ // query encoding while the on-disk LI index was built with the 256d edge
42
+ // model — every score becomes nonsense (the dim mismatch trips the
43
+ // modelMismatch guard but query encoding has already paid the wrong-cost).
44
+ const _appliedLiPerRoot = new Map(); // projectRoot -> appliedModel
45
+ function _ensurePersistedLiModelApplied(projectRoot) {
46
+ const key = projectRoot || process.cwd();
47
+ if (_appliedLiPerRoot.has(key)) return;
48
+ const r = applyPersistedLiModel(key);
49
+ _appliedLiPerRoot.set(key, r.applied);
50
+ }
51
+
37
52
  // ---------------------------------------------------------------------------
38
53
  // Defaults — keep modest so a one-file call stays under ~100ms after warmup.
39
54
  // ---------------------------------------------------------------------------
@@ -448,6 +463,7 @@ export async function readSemantic(req) {
448
463
  if (!req.query || !String(req.query).trim()) throw new Error('query is required');
449
464
 
450
465
  const projectRoot = req.projectRoot || process.cwd();
466
+ _ensurePersistedLiModelApplied(projectRoot);
451
467
  const filePathRel = _projectRelative(req.path, projectRoot);
452
468
 
453
469
  const topK = req.topK ?? DEFAULTS.topK;
@@ -714,4 +730,5 @@ export function __resetReadSemanticCachesForTests() {
714
730
  _liIndex = null;
715
731
  _liInitPromise = null;
716
732
  _encodeQueryFn = null;
733
+ _appliedLiPerRoot.clear();
717
734
  }
@@ -257,8 +257,13 @@ export async function startServer() {
257
257
  ...(agentFormat && { format: agentFormat, tokenBudget }),
258
258
  });
259
259
 
260
- // Agent mode: return the packaged response directly as JSON
260
+ // Agent mode: return the packaged response directly as JSON.
261
+ // Inject server-side repo identity so callers can prove which repo
262
+ // produced these results (defends against multi-repo bench reusing
263
+ // a stale daemon — see eval/agent-read-workflows/run-bench.js).
261
264
  if (searchResult.format === 'agent') {
265
+ searchResult.serverProjectRoot = searcher.projectRoot || null;
266
+ searchResult.serverPid = process.pid;
262
267
  res.writeHead(200, { 'Content-Type': 'application/json' });
263
268
  res.end(JSON.stringify(searchResult));
264
269
  } else {
@@ -286,12 +291,22 @@ export async function startServer() {
286
291
  }
287
292
  } else if (req.method === 'GET' && reqUrl === '/health') {
288
293
  const status = initError ? 'failed' : (serverReady ? 'ready' : 'starting');
294
+ // Repo identity — harness uses these to verify the daemon serves the
295
+ // expected repo, not a leftover from a previous benchmark subprocess.
296
+ // We resolve the path so symlinks/relative differences are normalised.
297
+ const rawProjectRoot = searcher.projectRoot || null;
298
+ let resolvedProjectRoot = null;
299
+ try { if (rawProjectRoot) resolvedProjectRoot = (await import('path')).default.resolve(rawProjectRoot); } catch { /* */ }
289
300
  res.writeHead(200, { 'Content-Type': 'application/json' });
290
301
  res.end(JSON.stringify({
291
302
  status,
292
303
  warm: serverReady,
293
304
  pid: process.pid,
294
305
  uptimeSec: Math.round(process.uptime()),
306
+ projectRoot: rawProjectRoot,
307
+ resolvedProjectRoot,
308
+ codebaseDbPath: searcher.codebaseDbPath || null,
309
+ initialized: serverReady && !initError,
295
310
  init: {
296
311
  startedAt: new Date(initStartedAt).toISOString(),
297
312
  elapsedMs: initTimeMs ?? (Date.now() - initStartedAt),
@@ -481,6 +496,137 @@ export async function queryServer(query, options = {}) {
481
496
  });
482
497
  }
483
498
 
499
+ /**
500
+ * Fetch /health from the running daemon. Returns the parsed body, or null if
501
+ * the daemon is unreachable / replies non-200.
502
+ *
503
+ * Use this (not isServerRunning alone) when you need repo identity to make a
504
+ * decision — e.g., the agent-bench harness must know which repo the daemon
505
+ * is currently serving so it can refuse cross-repo contamination.
506
+ */
507
+ export async function getServerHealth({ timeoutMs = 1000 } = {}) {
508
+ try {
509
+ const http = await import('http');
510
+ return await new Promise((resolve) => {
511
+ const req = http.get(`http://localhost:${SEARCH_SERVER_PORT}/health`, (res) => {
512
+ let payload = '';
513
+ res.on('data', chunk => { payload += chunk; });
514
+ res.on('end', () => {
515
+ if (res.statusCode !== 200) { resolve(null); return; }
516
+ try { resolve(JSON.parse(payload)); }
517
+ catch { resolve(null); }
518
+ });
519
+ });
520
+ req.on('error', () => resolve(null));
521
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
522
+ });
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Send /stop to the running daemon (Unix-socket only — TCP is forbidden).
530
+ * Returns true if the request reached the daemon (200 reply or connection
531
+ * closed by the dying server). Caller is expected to poll until the socket
532
+ * disappears or wait a short cool-down.
533
+ */
534
+ export async function stopServer({ timeoutMs = 5000 } = {}) {
535
+ try {
536
+ const http = await import('http');
537
+ return await new Promise((resolve) => {
538
+ const req = http.request({
539
+ socketPath: SEARCH_SERVER_SOCKET, path: '/stop', method: 'GET',
540
+ }, (res) => {
541
+ res.on('data', () => {});
542
+ res.on('end', () => resolve(true));
543
+ });
544
+ // The server may close the socket abruptly as it exits before sending an
545
+ // end-of-response. Treat that as success too.
546
+ req.on('error', (err) => {
547
+ const msg = (err && err.code) || '';
548
+ if (msg === 'ECONNRESET' || msg === 'EPIPE' || msg === 'ENOENT') resolve(true);
549
+ else resolve(false);
550
+ });
551
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); });
552
+ req.end();
553
+ });
554
+ } catch {
555
+ return false;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Best-effort wait for the daemon to exit. Returns true once /health stops
561
+ * answering (within timeoutMs); false otherwise.
562
+ */
563
+ export async function waitForServerExit({ timeoutMs = 8000, intervalMs = 200 } = {}) {
564
+ const deadline = Date.now() + timeoutMs;
565
+ while (Date.now() < deadline) {
566
+ if (!(await isServerRunning())) return true;
567
+ await new Promise(r => setTimeout(r, intervalMs));
568
+ }
569
+ return false;
570
+ }
571
+
572
+ /**
573
+ * Ensure the warm daemon serves the requested projectRoot. If a daemon is
574
+ * already running with a different projectRoot, stop it first, then re-spawn.
575
+ *
576
+ * Returns:
577
+ * { ok: true, health, action: 'reused'|'spawned'|'restarted' }
578
+ * { ok: false, reason, health? }
579
+ *
580
+ * Used by the agent-bench harness to fail closed against cross-repo
581
+ * contamination (see eval/agent-read-workflows/run-bench.js warmup phase).
582
+ */
583
+ export async function ensureDaemonForProjectRoot(expectedProjectRoot, {
584
+ timeoutMs = 60000, intervalMs = 500,
585
+ } = {}) {
586
+ const path = (await import('path')).default;
587
+ const expected = path.resolve(expectedProjectRoot);
588
+ let action = null;
589
+
590
+ let health = await getServerHealth();
591
+ if (health && health.resolvedProjectRoot && health.resolvedProjectRoot === expected) {
592
+ return { ok: true, health, action: 'reused' };
593
+ }
594
+
595
+ if (health && health.resolvedProjectRoot && health.resolvedProjectRoot !== expected) {
596
+ // Wrong-repo daemon. Stop it and respawn with the correct env.
597
+ await stopServer();
598
+ const exited = await waitForServerExit();
599
+ if (!exited) {
600
+ return { ok: false, reason: 'previous-daemon-failed-to-exit', health };
601
+ }
602
+ action = 'restarted';
603
+ } else {
604
+ action = 'spawned';
605
+ }
606
+
607
+ // Spawn detached daemon. autoSpawnServer inherits env, so the caller must
608
+ // already have SWEET_SEARCH_PROJECT_ROOT set to expectedProjectRoot.
609
+ if (process.env.SWEET_SEARCH_PROJECT_ROOT) {
610
+ const envResolved = path.resolve(process.env.SWEET_SEARCH_PROJECT_ROOT);
611
+ if (envResolved !== expected) {
612
+ return {
613
+ ok: false,
614
+ reason: `caller env SWEET_SEARCH_PROJECT_ROOT=${envResolved} differs from expected=${expected}`,
615
+ };
616
+ }
617
+ }
618
+ await autoSpawnServer();
619
+ const deadline = Date.now() + timeoutMs;
620
+ while (Date.now() < deadline) {
621
+ health = await getServerHealth();
622
+ if (health && health.resolvedProjectRoot === expected && (health.warm === true || health.status === 'ready')) {
623
+ return { ok: true, health, action };
624
+ }
625
+ await new Promise(r => setTimeout(r, intervalMs));
626
+ }
627
+ return { ok: false, reason: 'daemon-did-not-become-ready-with-expected-root', health };
628
+ }
629
+
484
630
  export async function isServerRunning() {
485
631
  try {
486
632
  const http = await import('http');
@@ -22,7 +22,7 @@ import { BinaryHNSWIndex } from '../vector-store/binary-hnsw-index.js';
22
22
  import { Reranker } from '../ranking/flashrank.js';
23
23
  import { LateInteractionIndex } from '../ranking/late-interaction-index.js';
24
24
  import { resolveSearchRerankPolicy } from '../ranking/late-interaction-policy.js';
25
- import { readPersistedLiPolicy } from '../infrastructure/index.js';
25
+ import { applyPersistedLiModel, readPersistedLiPolicy } from '../infrastructure/index.js';
26
26
  import { getEmbedding, getBinaryEmbedding, truncateForHNSW, int8CosineSimilarity, warmup as warmupEmbedding, isWarm, registerAutoPersistOnExit } from '../embedding/embedding-service.js';
27
27
  import { FloatVectorStore, getFloatStorePath } from '../vector-store/float-vector-store.js';
28
28
  import { recordQueryTelemetry } from '../embedding/embedding-cache.js';
@@ -91,6 +91,14 @@ export class SweetSearch {
91
91
  constructor(options = {}) {
92
92
  const projectRoot = options.projectRoot || process.env.SWEET_SEARCH_PROJECT_ROOT || process.cwd();
93
93
  this.projectRoot = projectRoot;
94
+ // Honor the user's persisted `runtime.li.model` choice from
95
+ // `.sweet-search/config.json` BEFORE we read `LATE_INTERACTION_CONFIG.model`
96
+ // for activeConfigModel below or any downstream consumer (encodeQuery,
97
+ // LateInteractionIndex header check, native LI loader, CoreML cascade
98
+ // dispatcher). Without this an edge-only init silently activates the
99
+ // standard model path on every search. Env var still wins; see
100
+ // applyPersistedLiModel for the full precedence ladder.
101
+ this._liModelApply = applyPersistedLiModel(projectRoot);
94
102
  const projectConfig = loadProjectConfig(projectRoot);
95
103
  const projectCascade = projectConfig.cascade || {};
96
104
  const envOrProject = (envKey, cascadeKey, configKey) =>
@@ -405,7 +413,12 @@ export class SweetSearch {
405
413
  let searchMode;
406
414
  if (mode === 'auto') {
407
415
  searchMode = routing.mode;
408
- stats.routing = { mode: routing.mode, confidence: routing.confidence, latency_us: routing.routingLatency_us };
416
+ stats.routing = {
417
+ mode: routing.mode,
418
+ confidence: routing.confidence,
419
+ latency_us: routing.routingLatency_us,
420
+ method: routing.method,
421
+ };
409
422
  } else {
410
423
  searchMode = mode;
411
424
  stats.routing = {
@@ -1,6 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  // Minimal server-start entry point — avoids the circular import in sweet-search.js.
3
- // Used by the Rust CLI's auto_start_server() to spawn the background server.
3
+ // Used by the Rust CLI's auto_start_server() to spawn the background server,
4
+ // and by the SessionStart daemon-prewarm hook (core/search/session-daemon-prewarm.mjs)
5
+ // when Claude Code opens a new session.
4
6
 
5
- import { startServer } from './search/search-server.js';
7
+ // Apply the user's persisted `runtime.li.model` from .sweet-search/config.json
8
+ // BEFORE importing search-server (which transitively imports session-warmup,
9
+ // which gates warmup steps on `LATE_INTERACTION_CONFIG.enabled` and triggers a
10
+ // warmup search using `LATE_INTERACTION_CONFIG.model`). Without this, an
11
+ // edge-only init still spawns a daemon that prewarms the standard model.
12
+ const projectRoot = process.env.SWEET_SEARCH_PROJECT_ROOT || process.cwd();
13
+ const { applyPersistedLiModel } = await import('./infrastructure/init-config.js');
14
+ applyPersistedLiModel(projectRoot);
15
+
16
+ const { startServer } = await import('./search/search-server.js');
6
17
  await startServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sweet-search",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
4
4
  "description": "Sweet Search - SOTA Hybrid Code Search Engine with WASM CatBoost Query Router, Semantic/Lexical/Structural Search, and Multilingual Support",
5
5
  "type": "module",
6
6
  "main": "core/search/sweet-search.js",
@@ -142,12 +142,12 @@
142
142
  "vitest": "^4.0.16"
143
143
  },
144
144
  "optionalDependencies": {
145
- "@sweet-search/native-darwin-arm64": "2.5.1",
146
- "@sweet-search/native-darwin-x64": "2.5.1",
147
- "@sweet-search/native-linux-arm64-gnu": "2.5.1",
148
- "@sweet-search/native-linux-arm64-gnu-cuda": "2.5.1",
149
- "@sweet-search/native-linux-x64-gnu": "2.5.1",
150
- "@sweet-search/native-linux-x64-gnu-cuda": "2.5.1"
145
+ "@sweet-search/native-darwin-arm64": "2.5.2",
146
+ "@sweet-search/native-darwin-x64": "2.5.2",
147
+ "@sweet-search/native-linux-arm64-gnu": "2.5.2",
148
+ "@sweet-search/native-linux-arm64-gnu-cuda": "2.5.2",
149
+ "@sweet-search/native-linux-x64-gnu": "2.5.2",
150
+ "@sweet-search/native-linux-x64-gnu-cuda": "2.5.2"
151
151
  },
152
152
  "engines": {
153
153
  "node": ">=18.0.0"
@@ -132,15 +132,23 @@ function getModelCacheDirs(initConfig) {
132
132
  * return an empty array — uninstall doesn't print a "removing 0 B"
133
133
  * line.
134
134
  */
135
- function getCoremlCascadeRemovals() {
135
+ export function getCoremlCascadeRemovals() {
136
136
  const removals = [];
137
137
  try {
138
138
  const root = getCoremlCascadeRoot();
139
139
  if (existsSync(root)) {
140
140
  const state = getCoremlCascadeState();
141
+ // Sum across all advertised families — embed + standard LI + LI-edge.
142
+ // The earlier label only counted embed + standard LI (12 on the
143
+ // shipping spec) which contradicted init's "18 variants ready"
144
+ // (6 embed + 6 LI + 6 LI-edge). `liEdgeTotal` is 0 on hosts whose
145
+ // spec doesn't advertise the edge family, so older specs still
146
+ // collapse to the prior 12-count behaviour without ceremony.
147
+ const totalAll = state.embedTotal + state.liTotal + state.liEdgeTotal;
148
+ const presentAll = state.embedPresent + state.liPresent + state.liEdgePresent;
141
149
  const label = state.complete
142
- ? `coreml cascade (${state.embedTotal + state.liTotal} variants complete)`
143
- : `coreml cascade (${state.embedPresent + state.liPresent}/${state.embedTotal + state.liTotal} variants partial)`;
150
+ ? `coreml cascade (${totalAll} variants complete)`
151
+ : `coreml cascade (${presentAll}/${totalAll} variants partial)`;
144
152
  removals.push({ label, path: root, size: dirSize(root), type: 'coreml-cascade' });
145
153
  }
146
154
  } catch {
@@ -213,6 +221,70 @@ export function stopRunningDaemon({
213
221
  return result;
214
222
  }
215
223
 
224
+ /**
225
+ * Remove the index-maintainer daemon hook init copied into
226
+ * `.claude/hooks/index-maintainer.mjs`. Only removes the file when it
227
+ * matches the bytes init shipped — never deletes a user-modified file
228
+ * we don't own. The marker is the source path: init does
229
+ * `copyFileSync(<pkg>/core/indexing/index-maintainer.mjs, dest)`, so
230
+ * we compare destination bytes to the package source.
231
+ *
232
+ * Returns `{ status, detail }`:
233
+ * not-found — file absent (nothing to do)
234
+ * removed — file removed (matched shipped bytes)
235
+ * skipped — file present but contents differ (user-modified) — left intact
236
+ * dry-run — found the file but skipped the delete
237
+ * error — rm or read failed; uninstall continues
238
+ */
239
+ export function removeIndexMaintainerHook(projectRoot, { dryRun = false } = {}) {
240
+ const hookPath = join(projectRoot, '.claude', 'hooks', 'index-maintainer.mjs');
241
+ if (!existsSync(hookPath)) {
242
+ return { status: 'not-found', detail: 'no .claude/hooks/index-maintainer.mjs' };
243
+ }
244
+
245
+ // Only remove when the bytes match the version init shipped — refuses to
246
+ // delete a hook the user has customized. Failing the byte compare is a
247
+ // soft skip, not an error.
248
+ const shippedPath = join(PACKAGE_ROOT, 'core', 'indexing', 'index-maintainer.mjs');
249
+ let bytesMatch = false;
250
+ try {
251
+ if (existsSync(shippedPath)) {
252
+ const a = readFileSync(hookPath);
253
+ const b = readFileSync(shippedPath);
254
+ bytesMatch = a.length === b.length && a.equals(b);
255
+ }
256
+ } catch {
257
+ // Read errored on either side — treat as "don't remove, surface the
258
+ // file path so the user can clean up manually if they want to".
259
+ return { status: 'skipped', detail: `cannot compare bytes (${hookPath})` };
260
+ }
261
+
262
+ if (!bytesMatch) {
263
+ return {
264
+ status: 'skipped',
265
+ detail: `${hookPath} differs from shipped version — leaving in place (delete manually if intended)`,
266
+ };
267
+ }
268
+
269
+ if (dryRun) {
270
+ return { status: 'dry-run', detail: hookPath };
271
+ }
272
+
273
+ try {
274
+ unlinkSync(hookPath);
275
+ // Best-effort: prune the parent .claude/hooks/ if it's now empty (we
276
+ // own the file, not the directory; only delete if WE made it empty).
277
+ try {
278
+ const parent = dirname(hookPath);
279
+ const entries = readdirSync(parent);
280
+ if (entries.length === 0) rmdirSync(parent);
281
+ } catch { /* ignore — sibling files exist or rmdir failed */ }
282
+ return { status: 'removed', detail: hookPath };
283
+ } catch (err) {
284
+ return { status: 'error', detail: err.message };
285
+ }
286
+ }
287
+
216
288
  /**
217
289
  * Remove the sweet-search /sweet-index skill from `.claude/skills/sweet-index/`.
218
290
  * Only removes the directory we created — leaves `.claude/skills/` and `.claude/`
@@ -342,6 +414,39 @@ export function removePrewarmSessionStartHook(projectRoot, { dryRun = false } =
342
414
  return { status: 'removed', detail: `spliced out ${sessionStart.length - filtered.length} entry` };
343
415
  }
344
416
 
417
+ // ---------------------------------------------------------------------------
418
+ // Optional native package list (derived from package.json)
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /**
422
+ * Return the list of `@sweet-search/native-*` packages declared as
423
+ * `optionalDependencies` in package.json. `--purge` walks this list so
424
+ * additions (e.g. CUDA variants) are picked up automatically without
425
+ * having to keep two hand-maintained lists in sync.
426
+ *
427
+ * Falls back to a hard-coded list if package.json is unreadable, so a
428
+ * partial install still gets best-effort purge coverage.
429
+ */
430
+ export function getOptionalNativePackageNames() {
431
+ try {
432
+ const pkgPath = join(PACKAGE_ROOT, 'package.json');
433
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
434
+ const deps = pkg.optionalDependencies || {};
435
+ const out = Object.keys(deps).filter((n) => n.startsWith('@sweet-search/'));
436
+ if (out.length > 0) return out;
437
+ } catch { /* fall through to baseline */ }
438
+ // Baseline keeps the prior behaviour PLUS the CUDA variants that were
439
+ // missing from the pre-Phase-7 hand-maintained list.
440
+ return [
441
+ '@sweet-search/native-darwin-arm64',
442
+ '@sweet-search/native-darwin-x64',
443
+ '@sweet-search/native-linux-arm64-gnu',
444
+ '@sweet-search/native-linux-arm64-gnu-cuda',
445
+ '@sweet-search/native-linux-x64-gnu',
446
+ '@sweet-search/native-linux-x64-gnu-cuda',
447
+ ];
448
+ }
449
+
345
450
  // ---------------------------------------------------------------------------
346
451
  // Help text
347
452
  // ---------------------------------------------------------------------------
@@ -369,6 +474,8 @@ What gets removed:
369
474
  artifacts AND the sibling .mlmodelc compiled cache files next to
370
475
  each variant. Skipped by --keep-models.
371
476
  - .claude/skills/sweet-index/ (the per-project /sweet-index skill copy)
477
+ - .claude/hooks/index-maintainer.mjs (init-installed). User-modified
478
+ copies are detected via a byte-compare and left in place.
372
479
  - daemon-prewarm SessionStart entry inside .claude/settings.json
373
480
 
374
481
  What is NOT removed:
@@ -436,8 +543,15 @@ export async function runUninstall(args) {
436
543
  const skillPreview = removeSweetIndexSkill(projectRoot, { dryRun: true });
437
544
  const hasSkillEntry = skillPreview.status === 'dry-run';
438
545
 
546
+ // Check for the index-maintainer daemon hook init copies into
547
+ // `.claude/hooks/index-maintainer.mjs`. Same dry-run pattern.
548
+ const indexMaintainerPreview = removeIndexMaintainerHook(projectRoot, { dryRun: true });
549
+ const hasIndexMaintainerHook = indexMaintainerPreview.status === 'dry-run';
550
+ const indexMaintainerSkippedReason =
551
+ indexMaintainerPreview.status === 'skipped' ? indexMaintainerPreview.detail : null;
552
+
439
553
  // Nothing to remove?
440
- if (removals.length === 0 && !hasHookEntry && !hasSkillEntry) {
554
+ if (removals.length === 0 && !hasHookEntry && !hasSkillEntry && !hasIndexMaintainerHook) {
441
555
  console.log('Nothing to remove — Sweet Search is not initialized in this project.');
442
556
  return;
443
557
  }
@@ -457,6 +571,11 @@ export async function runUninstall(args) {
457
571
  if (hasSkillEntry) {
458
572
  console.log(` /sweet-index skill (.claude/skills/sweet-index/)`);
459
573
  }
574
+ if (hasIndexMaintainerHook) {
575
+ console.log(` index-maintainer hook (.claude/hooks/index-maintainer.mjs)`);
576
+ } else if (indexMaintainerSkippedReason) {
577
+ console.log(` [skipped] ${indexMaintainerSkippedReason}`);
578
+ }
460
579
  console.log(` Total: ${formatBytes(totalBytes)}`);
461
580
  if (parsed.keepModels) {
462
581
  console.log(' Model cache: kept (--keep-models)');
@@ -472,6 +591,12 @@ export async function runUninstall(args) {
472
591
  if (drySkill.status === 'dry-run') {
473
592
  console.log(` Would also remove: /sweet-index skill (${drySkill.detail})`);
474
593
  }
594
+ const dryMaintainer = removeIndexMaintainerHook(projectRoot, { dryRun: true });
595
+ if (dryMaintainer.status === 'dry-run') {
596
+ console.log(` Would also remove: index-maintainer hook (${dryMaintainer.detail})`);
597
+ } else if (dryMaintainer.status === 'skipped') {
598
+ console.log(` Would skip: index-maintainer hook — ${dryMaintainer.detail}`);
599
+ }
475
600
  console.log('Dry run — nothing was removed.');
476
601
  return;
477
602
  }
@@ -540,6 +665,21 @@ export async function runUninstall(args) {
540
665
  }
541
666
  // 'not-found' and 'dry-run' are silent in the main output.
542
667
 
668
+ // Reverse the index-maintainer daemon hook init copied into
669
+ // .claude/hooks/index-maintainer.mjs. Bytes-match check inside the
670
+ // helper guarantees we never delete a user-customised file.
671
+ const indexMaintainerResult = removeIndexMaintainerHook(projectRoot, { dryRun: parsed.dryRun });
672
+ if (indexMaintainerResult.status === 'removed') {
673
+ console.log(` Removed: index-maintainer hook (${indexMaintainerResult.detail})`);
674
+ removed++;
675
+ } else if (indexMaintainerResult.status === 'skipped') {
676
+ console.log(` Kept: index-maintainer hook — ${indexMaintainerResult.detail}`);
677
+ kept++;
678
+ } else if (indexMaintainerResult.status === 'error') {
679
+ console.log(` Failed to remove index-maintainer hook: ${indexMaintainerResult.detail}`);
680
+ kept++;
681
+ }
682
+
543
683
  // Stop any daemon that an earlier SessionStart hook spawned. Otherwise the
544
684
  // old daemon keeps running and holding the socket after uninstall, which
545
685
  // surprises users. Never throws — `stopRunningDaemon` swallows every error.
@@ -556,11 +696,17 @@ export async function runUninstall(args) {
556
696
  console.log('');
557
697
  console.log(' Purging npm packages...');
558
698
  try {
559
- execSync('npm uninstall sweet-search @sweet-search/native-darwin-arm64 @sweet-search/native-darwin-x64 @sweet-search/native-linux-x64-gnu @sweet-search/native-linux-arm64-gnu 2>/dev/null || true', {
699
+ const pkgs = ['sweet-search', ...getOptionalNativePackageNames()];
700
+ // Use shell-form so non-installed packages don't abort the whole
701
+ // command (npm exits non-zero per missing pkg). The OR-true keeps
702
+ // the script alive across npm exit codes from a partially-installed
703
+ // host (e.g. a Linux box without the darwin-* packages).
704
+ const cmd = `npm uninstall ${pkgs.join(' ')} 2>/dev/null || true`;
705
+ execSync(cmd, {
560
706
  cwd: projectRoot,
561
707
  stdio: ['pipe', 'pipe', 'pipe'],
562
708
  });
563
- console.log(' npm packages removed.');
709
+ console.log(` npm packages removed (${pkgs.length} candidates).`);
564
710
  } catch {
565
711
  console.log(' npm uninstall failed (packages may not be installed).');
566
712
  }