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 +24 -0
- package/core/embedding/embedding-cache.js +90 -4
- package/core/embedding/embedding-service.js +27 -5
- package/core/indexing/index-codebase-v21.js +31 -2
- package/core/infrastructure/index.js +2 -0
- package/core/infrastructure/init-config.js +138 -0
- package/core/search/search-read-semantic.js +17 -0
- package/core/search/search-server.js +147 -1
- package/core/search/sweet-search.js +15 -2
- package/core/start-server.js +13 -2
- package/package.json +7 -7
- package/scripts/uninstall.js +152 -6
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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 = {
|
|
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 = {
|
package/core/start-server.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
146
|
-
"@sweet-search/native-darwin-x64": "2.5.
|
|
147
|
-
"@sweet-search/native-linux-arm64-gnu": "2.5.
|
|
148
|
-
"@sweet-search/native-linux-arm64-gnu-cuda": "2.5.
|
|
149
|
-
"@sweet-search/native-linux-x64-gnu": "2.5.
|
|
150
|
-
"@sweet-search/native-linux-x64-gnu-cuda": "2.5.
|
|
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"
|
package/scripts/uninstall.js
CHANGED
|
@@ -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 (${
|
|
143
|
-
: `coreml cascade (${
|
|
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
|
-
|
|
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(
|
|
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
|
}
|