sweet-search 2.4.2 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/core/cli.js +19 -5
  2. package/core/embedding/embedding-cache.js +177 -15
  3. package/core/embedding/embedding-service.js +18 -4
  4. package/core/graph/graph-expansion.js +52 -12
  5. package/core/graph/graph-extractor.js +30 -1
  6. package/core/indexing/ast-chunker.js +331 -16
  7. package/core/indexing/chunking/chunk-builder.js +34 -1
  8. package/core/indexing/index.js +6 -3
  9. package/core/indexing/indexer-ann.js +45 -6
  10. package/core/indexing/indexer-build.js +9 -1
  11. package/core/indexing/indexer-phases.js +6 -4
  12. package/core/indexing/indexing-file-policy.js +140 -0
  13. package/core/indexing/li-skip-policy.js +11 -220
  14. package/core/infrastructure/codebase-repository.js +21 -0
  15. package/core/infrastructure/config/embedding.js +20 -1
  16. package/core/infrastructure/config/graph.js +2 -2
  17. package/core/infrastructure/config/ranking.js +10 -0
  18. package/core/infrastructure/config/vector-store.js +1 -1
  19. package/core/infrastructure/coreml-cascade.js +236 -30
  20. package/core/infrastructure/coreml-cascade.json +25 -0
  21. package/core/infrastructure/index.js +15 -0
  22. package/core/infrastructure/init-config.js +78 -0
  23. package/core/infrastructure/language-patterns/registry-core.js +18 -0
  24. package/core/infrastructure/model-registry.js +12 -0
  25. package/core/infrastructure/native-inference.js +143 -51
  26. package/core/infrastructure/tree-sitter-provider.js +92 -2
  27. package/core/ranking/cascaded-scorer.js +6 -2
  28. package/core/ranking/file-kind-ranking.js +264 -0
  29. package/core/ranking/late-interaction-index.js +10 -4
  30. package/core/ranking/late-interaction-policy.js +304 -0
  31. package/core/search/context-expander.js +267 -28
  32. package/core/search/index.js +4 -0
  33. package/core/search/search-cli.js +3 -1
  34. package/core/search/search-pattern.js +4 -3
  35. package/core/search/search-postprocess.js +189 -8
  36. package/core/search/search-read-semantic.js +717 -0
  37. package/core/search/search-read.js +481 -0
  38. package/core/search/search-server.js +6 -4
  39. package/core/search/sweet-search.js +119 -15
  40. package/mcp/server.js +41 -0
  41. package/mcp/tool-handlers.js +117 -6
  42. package/package.json +9 -7
  43. package/scripts/init.js +386 -5
package/scripts/init.js CHANGED
@@ -23,6 +23,16 @@ import {
23
23
  isDedupAvailable, getDedupLoadError, computeFingerprints, clusterFingerprints,
24
24
  DEDUP_CONFIG,
25
25
  } from '../core/infrastructure/index.js';
26
+ import {
27
+ LI_MODEL_EDGE,
28
+ LI_MODEL_NONE,
29
+ LI_MODEL_STANDARD,
30
+ VALID_LI_MODELS,
31
+ VALID_RERANK_POLICIES,
32
+ normalizeLiModel,
33
+ normalizePolicy,
34
+ recommendInitDefaults,
35
+ } from '../core/ranking/late-interaction-policy.js';
26
36
  import { describeDedupConfig } from '../core/infrastructure/index.js';
27
37
  import { verifyRuntime, getMaxsimTier, getRouterType } from './verify-runtime.js';
28
38
 
@@ -48,6 +58,9 @@ export function parseInitArgs(args) {
48
58
  skipDedup: false,
49
59
  skipPrewarmHook: false,
50
60
  skipCuda: false,
61
+ liModel: null, // Phase 4: --li-model standard|edge|none (raw user input; 'standard' aliased to 'lateon-code')
62
+ searchReranking: null, // Phase 4: --search-reranking auto|on|off
63
+ wizard: false, // Phase 4: --wizard runs interactive prompts
51
64
  };
52
65
 
53
66
  for (let i = 0; i < args.length; i++) {
@@ -58,6 +71,16 @@ export function parseInitArgs(args) {
58
71
  result.profile = args[++i] || null;
59
72
  } else if (arg.startsWith('--profile=')) {
60
73
  result.profile = arg.split('=')[1];
74
+ } else if (arg === '--li-model') {
75
+ result.liModel = args[++i] || null;
76
+ } else if (arg.startsWith('--li-model=')) {
77
+ result.liModel = arg.split('=')[1];
78
+ } else if (arg === '--search-reranking') {
79
+ result.searchReranking = args[++i] || null;
80
+ } else if (arg.startsWith('--search-reranking=')) {
81
+ result.searchReranking = arg.split('=')[1];
82
+ } else if (arg === '--wizard') {
83
+ result.wizard = true;
61
84
  } else if (arg === '--verify-deep') {
62
85
  result.verifyDeep = true;
63
86
  } else if (arg === '--force') {
@@ -157,6 +180,234 @@ export function writeInitConfig(dataDir, config) {
157
180
  renameSync(tmpPath, configPath);
158
181
  }
159
182
 
183
+ // ---------------------------------------------------------------------------
184
+ // LI policy resolution (Phase 4)
185
+ //
186
+ // Two persisted choices land in `.sweet-search/config.json::runtime.li`:
187
+ // - `model` : 'lateon-code' | 'lateon-code-edge' | 'none'
188
+ // - `searchReranking` : 'auto' | 'on' | 'off'
189
+ //
190
+ // Precedence (most-specific wins):
191
+ // 1. CLI flag (--li-model, --search-reranking) — explicit, persisted
192
+ // 2. --wizard interactive prompt (when TTY); non-TTY falls back to (3)/(4)
193
+ // 3. Existing persisted config (re-uses prior choice across re-runs)
194
+ // 4. Hardware-aware recommendation (recommendInitDefaults)
195
+ // ---------------------------------------------------------------------------
196
+
197
+ const LI_MODEL_ALIASES = {
198
+ standard: LI_MODEL_STANDARD,
199
+ full: LI_MODEL_STANDARD,
200
+ edge: LI_MODEL_EDGE,
201
+ small: LI_MODEL_EDGE,
202
+ none: LI_MODEL_NONE,
203
+ off: LI_MODEL_NONE,
204
+ disable: LI_MODEL_NONE,
205
+ };
206
+
207
+ /**
208
+ * Coerce a user-facing model alias to a canonical id. Returns null when
209
+ * the input is unrecognized so the caller can produce a clear error.
210
+ */
211
+ export function coerceLiModelChoice(value) {
212
+ if (typeof value !== 'string') return null;
213
+ const v = value.trim().toLowerCase();
214
+ if (LI_MODEL_ALIASES[v]) return LI_MODEL_ALIASES[v];
215
+ return normalizeLiModel(v);
216
+ }
217
+
218
+ /**
219
+ * Read the LI policy section from a parsed init config (the format written
220
+ * by `buildConfig`). Defensive — returns an empty object when the section
221
+ * is missing or malformed.
222
+ */
223
+ function readPersistedLi(existingConfig) {
224
+ const li = existingConfig?.runtime?.li;
225
+ if (!li || typeof li !== 'object') return {};
226
+ const out = {};
227
+ if (typeof li.model === 'string' && VALID_LI_MODELS.includes(li.model)) {
228
+ out.liModel = li.model;
229
+ }
230
+ if (typeof li.searchReranking === 'string' && VALID_RERANK_POLICIES.includes(li.searchReranking)) {
231
+ out.searchReranking = li.searchReranking;
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * Resolve the LI model + rerank policy from CLI args, persisted config,
238
+ * and hardware capability. Pure (modulo `wizardFn` which performs I/O when
239
+ * --wizard is set). Suitable for unit testing — pass a fake `wizardFn` and
240
+ * a fixed capability snapshot.
241
+ *
242
+ * @param {object} input
243
+ * @param {object} input.parsed - parseInitArgs result
244
+ * @param {object|null} input.existingConfig - prior `.sweet-search/config.json`
245
+ * @param {object} input.capability - detectHardwareCapability snapshot
246
+ * @param {boolean} input.isTTY - whether stdin is a TTY (for wizard fallback)
247
+ * @param {Function} [input.wizardFn] - async ({existingConfig, capability, recommendation, defaults}) => {liModel, searchReranking}
248
+ *
249
+ * @returns {Promise<{liModel: string, searchReranking: string, source: string, recommendation: object}>}
250
+ */
251
+ export async function resolveLiPolicyChoices({
252
+ parsed,
253
+ existingConfig,
254
+ capability,
255
+ isTTY,
256
+ wizardFn,
257
+ }) {
258
+ const recommendation = recommendInitDefaults(capability ?? {});
259
+ const persisted = readPersistedLi(existingConfig);
260
+
261
+ // (1) CLI flags — validate now, fail fast with a clear message so users
262
+ // don't need to dig into the resolver. Wizard runs after CLI flags
263
+ // are validated so a bad --li-model never reaches the prompt.
264
+ let cliModel = null;
265
+ if (parsed.liModel) {
266
+ cliModel = coerceLiModelChoice(parsed.liModel);
267
+ if (cliModel == null) {
268
+ throw new Error(
269
+ `Invalid --li-model "${parsed.liModel}". Valid choices: ${VALID_LI_MODELS.join(', ')} (aliases: standard, edge, none).`,
270
+ );
271
+ }
272
+ }
273
+ let cliReranking = null;
274
+ if (parsed.searchReranking) {
275
+ const v = String(parsed.searchReranking).trim().toLowerCase();
276
+ if (!VALID_RERANK_POLICIES.includes(v)) {
277
+ throw new Error(
278
+ `Invalid --search-reranking "${parsed.searchReranking}". Valid choices: ${VALID_RERANK_POLICIES.join(', ')}.`,
279
+ );
280
+ }
281
+ cliReranking = v;
282
+ }
283
+
284
+ // (2) --wizard. Only meaningful on a TTY; on a non-TTY, fall through
285
+ // silently to (3)/(4) — wizard exists for humans, not CI.
286
+ let wizardChoice = null;
287
+ if (parsed.wizard && isTTY && typeof wizardFn === 'function') {
288
+ wizardChoice = await wizardFn({
289
+ existingConfig,
290
+ capability,
291
+ recommendation,
292
+ defaults: {
293
+ liModel: persisted.liModel ?? recommendation.liModel,
294
+ searchReranking: persisted.searchReranking ?? recommendation.searchReranking,
295
+ },
296
+ });
297
+ } else if (parsed.wizard && !isTTY) {
298
+ process.stderr.write(
299
+ '[init] --wizard requested on a non-TTY stdin; falling back to persisted config / hardware recommendation.\n',
300
+ );
301
+ }
302
+
303
+ // Final selection — first non-null source for each field wins.
304
+ const liModel =
305
+ cliModel
306
+ ?? wizardChoice?.liModel
307
+ ?? persisted.liModel
308
+ ?? recommendation.liModel;
309
+
310
+ const searchReranking =
311
+ cliReranking
312
+ ?? wizardChoice?.searchReranking
313
+ ?? persisted.searchReranking
314
+ ?? recommendation.searchReranking;
315
+
316
+ // Source label for logging — "where did each field actually come from".
317
+ const sourceFor = (cli, wiz, per) => {
318
+ if (cli) return 'cli';
319
+ if (wiz) return 'wizard';
320
+ if (per) return 'persisted';
321
+ return 'recommendation';
322
+ };
323
+ const source = {
324
+ liModel: sourceFor(cliModel, wizardChoice?.liModel, persisted.liModel),
325
+ searchReranking: sourceFor(cliReranking, wizardChoice?.searchReranking, persisted.searchReranking),
326
+ };
327
+
328
+ return { liModel, searchReranking, source, recommendation };
329
+ }
330
+
331
+ /**
332
+ * Interactive prompt — readline/promises. Caller has already verified
333
+ * `process.stdin.isTTY`. Echoes the recommendation, accepts blank input
334
+ * as the default, and validates each prompt before persisting.
335
+ *
336
+ * Exit ergonomics: blank input always defaults; ^C is allowed to throw,
337
+ * caller treats it as "no choice made" and re-runs without --wizard.
338
+ */
339
+ export async function runInitWizard({ existingConfig, capability, recommendation, defaults }) {
340
+ const { createInterface } = await import('node:readline/promises');
341
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
342
+ try {
343
+ process.stdout.write('\n');
344
+ process.stdout.write('Sweet Search init — interactive setup\n');
345
+ process.stdout.write('---------------------------------------\n');
346
+ if (capability?.brandString) {
347
+ process.stdout.write(`Hardware: ${capability.brandString} (${capability.totalMemGB?.toFixed?.(0) ?? '?'} GB RAM)\n`);
348
+ }
349
+ if (recommendation?.reason) {
350
+ process.stdout.write(`Recommendation: ${recommendation.reason}\n`);
351
+ }
352
+ if (existingConfig?.runtime?.li) {
353
+ process.stdout.write(
354
+ `Currently persisted: liModel=${existingConfig.runtime.li.model ?? '?'}, `
355
+ + `searchReranking=${existingConfig.runtime.li.searchReranking ?? '?'}\n`,
356
+ );
357
+ }
358
+ process.stdout.write('\n');
359
+
360
+ // ---- LI model prompt ----
361
+ const modelPrompt =
362
+ `Late-interaction model? [standard / edge / none] (default: ${defaults.liModel}): `;
363
+ const liModelRaw = (await rl.question(modelPrompt)).trim() || defaults.liModel;
364
+ const liModel = coerceLiModelChoice(liModelRaw);
365
+ if (!liModel) {
366
+ throw new Error(
367
+ `Invalid model choice "${liModelRaw}". Valid: ${VALID_LI_MODELS.join(', ')} (aliases: standard, edge, none).`,
368
+ );
369
+ }
370
+
371
+ // ---- Search reranking prompt ----
372
+ // Suppress when liModel === 'none' — without an LI index there's
373
+ // nothing to rerank, so persist 'off' explicitly.
374
+ let searchReranking;
375
+ if (liModel === LI_MODEL_NONE) {
376
+ process.stdout.write(' (skipping search-reranking prompt — liModel=none implies no rerank)\n');
377
+ searchReranking = 'off';
378
+ } else {
379
+ const rerankPrompt =
380
+ `Search-time LI reranking? [auto / on / off] (default: ${defaults.searchReranking}): `;
381
+ const raw = (await rl.question(rerankPrompt)).trim() || defaults.searchReranking;
382
+ const v = raw.toLowerCase();
383
+ if (!VALID_RERANK_POLICIES.includes(v)) {
384
+ throw new Error(
385
+ `Invalid rerank choice "${raw}". Valid: ${VALID_RERANK_POLICIES.join(', ')}.`,
386
+ );
387
+ }
388
+ searchReranking = v;
389
+ }
390
+
391
+ // ---- Echo + confirm ----
392
+ process.stdout.write('\n');
393
+ process.stdout.write(`Plan: liModel=${liModel}, searchReranking=${searchReranking}\n`);
394
+ if (liModel === LI_MODEL_EDGE && searchReranking === 'on') {
395
+ process.stdout.write(
396
+ 'Note: edge LI rerank benchmarked below no-rerank on gencodesearchnet '
397
+ + '(80.65% vs 82.91% MRR). "auto" is recommended.\n',
398
+ );
399
+ }
400
+ const confirm = (await rl.question('Proceed? [Y/n]: ')).trim().toLowerCase();
401
+ if (confirm === 'n' || confirm === 'no') {
402
+ throw new Error('Wizard cancelled by user.');
403
+ }
404
+
405
+ return { liModel, searchReranking };
406
+ } finally {
407
+ rl.close();
408
+ }
409
+ }
410
+
160
411
  // ---------------------------------------------------------------------------
161
412
  // Profile resolution
162
413
  // ---------------------------------------------------------------------------
@@ -219,8 +470,36 @@ export function checkNativeStatus() {
219
470
  // Model download
220
471
  // ---------------------------------------------------------------------------
221
472
 
473
+ /**
474
+ * Filter the profile's model keys based on the resolved `liModel` choice.
475
+ * - 'lateon-code' → exclude all lateon-code-edge* keys (don't fetch the edge variant)
476
+ * - 'lateon-code-edge' → exclude all standard lateon-code* keys (don't fetch the standard 768d backbone)
477
+ * - 'none' → exclude every lateon-* key (search rerank disabled product-wide)
478
+ *
479
+ * Order-preserving + non-mutating: returns a new array.
480
+ */
481
+ export function filterModelKeysForLiChoice(modelKeys, liModel) {
482
+ if (liModel === LI_MODEL_NONE) {
483
+ return modelKeys.filter((k) => !k.startsWith('lateon-code'));
484
+ }
485
+ if (liModel === LI_MODEL_STANDARD) {
486
+ return modelKeys.filter((k) => !k.startsWith('lateon-code-edge'));
487
+ }
488
+ if (liModel === LI_MODEL_EDGE) {
489
+ // Keep only edge-flavoured keys; everything matching `lateon-code` but
490
+ // not `lateon-code-edge` is a standard variant.
491
+ return modelKeys.filter(
492
+ (k) => !k.startsWith('lateon-code') || k.startsWith('lateon-code-edge'),
493
+ );
494
+ }
495
+ return modelKeys;
496
+ }
497
+
222
498
  export async function downloadModelsForProfile(profile, options = {}) {
223
- const modelKeys = getModelsForProfile(profile);
499
+ let modelKeys = getModelsForProfile(profile);
500
+ if (options.liModel) {
501
+ modelKeys = filterModelKeysForLiChoice(modelKeys, options.liModel);
502
+ }
224
503
  if (modelKeys.length === 0) {
225
504
  return { results: new Map(), totalDownloaded: 0, totalCached: 0, failures: [] };
226
505
  }
@@ -278,6 +557,7 @@ function printReport(report) {
278
557
  const {
279
558
  profile, maxsimTier, routerType, models, verification, runtimeDownloads,
280
559
  capability, cascadeReport, dedupReport, prewarmHookReport, skillReport,
560
+ liChoices,
281
561
  } = report;
282
562
 
283
563
  console.log('');
@@ -292,6 +572,20 @@ function printReport(report) {
292
572
  console.log(` MaxSim: ${maxsimTier}`);
293
573
  console.log(` Router: ${routerType}`);
294
574
 
575
+ if (liChoices) {
576
+ const { liModel, searchReranking, source } = liChoices;
577
+ const srcStr = source
578
+ ? ` (model: ${source.liModel}, rerank: ${source.searchReranking})`
579
+ : '';
580
+ console.log(` LI model: ${liModel}${srcStr}`);
581
+ console.log(` LI search rerank: ${searchReranking}`);
582
+ if (liModel === LI_MODEL_NONE) {
583
+ console.log(' ↳ rerank, read-semantic, ColGrep all disabled');
584
+ } else if (liModel === LI_MODEL_EDGE && searchReranking === 'on') {
585
+ console.log(' ↳ NOTE: edge LI rerank benchmarked below no-rerank — consider "auto"');
586
+ }
587
+ }
588
+
295
589
  // NVIDIA / CUDA status line. Shown only when it's actionable:
296
590
  // cudaAvailable=true → confirm GPU detection (user-visible win)
297
591
  // nvidiaGpu present but cudaAddonEnabled=false → warn that the
@@ -606,6 +900,18 @@ Usage:
606
900
 
607
901
  Options:
608
902
  --profile <profile> Install profile: core, full (default: full)
903
+ --li-model <choice> Late-interaction model: standard | edge | none.
904
+ Aliases: 'standard' = lateon-code (149M, 128d, accuracy
905
+ default), 'edge' = lateon-code-edge (17M, 48d, smaller +
906
+ faster), 'none' = disable LI rerank, read-semantic and
907
+ ColGrep entirely. Persisted to .sweet-search/config.json.
908
+ --search-reranking <p> Search-time LI rerank policy: auto | on | off.
909
+ 'auto' resolves from the LI index manifest at search time
910
+ (standard index → on, edge index → off, missing/mismatched
911
+ → off). 'on' / 'off' are explicit overrides. Persisted.
912
+ --wizard Run interactive setup prompts for --li-model and
913
+ --search-reranking. Falls back to persisted/recommended
914
+ defaults on a non-TTY stdin (CI-safe).
609
915
  --verify-deep Run deep verification (load modules, verify checksums)
610
916
  --force Re-download all models even if cached
611
917
  --build-coreml-cascade (M3+ Apple Silicon, local builds only) Trace the
@@ -688,6 +994,39 @@ export async function runInit(args) {
688
994
  const profile = resolveProfile(parsed.profile, existingConfig);
689
995
  process.stderr.write(`[init] Profile: ${profile}\n`);
690
996
 
997
+ // 4.5. Resolve LI policy (Phase 4 — `--li-model`, `--search-reranking`,
998
+ // `--wizard`). Done early so the model-fetch loop, cascade fetch,
999
+ // and final config write all see the same choices. Uses an early
1000
+ // hardware capability snapshot for the recommendation; the snapshot
1001
+ // is recomputed later for the runtime config (cheap + cacheable).
1002
+ const earlyCapability = detectHardwareCapability();
1003
+ let liChoices;
1004
+ try {
1005
+ liChoices = await resolveLiPolicyChoices({
1006
+ parsed,
1007
+ existingConfig,
1008
+ capability: earlyCapability,
1009
+ isTTY: process.stdin.isTTY === true,
1010
+ wizardFn: runInitWizard,
1011
+ });
1012
+ } catch (err) {
1013
+ process.stderr.write(`[init] LI policy resolution failed: ${err.message}\n`);
1014
+ process.exit(1);
1015
+ }
1016
+ process.stderr.write(
1017
+ `[init] LI policy: model=${liChoices.liModel} (${liChoices.source.liModel}), `
1018
+ + `searchReranking=${liChoices.searchReranking} (${liChoices.source.searchReranking})\n`,
1019
+ );
1020
+ if (liChoices.source.liModel === 'recommendation' && liChoices.recommendation?.reason) {
1021
+ process.stderr.write(`[init] recommendation reason: ${liChoices.recommendation.reason}\n`);
1022
+ }
1023
+ if (liChoices.liModel === LI_MODEL_NONE) {
1024
+ process.stderr.write(
1025
+ '[init] note: liModel=none disables LI rerank, read-semantic and ColGrep — '
1026
+ + 're-run with --li-model standard|edge to re-enable.\n',
1027
+ );
1028
+ }
1029
+
691
1030
  // 5. Verify runtime assets (WASM, router, etc.)
692
1031
  const assetCheck = verifyRuntimeAssets(PACKAGE_ROOT);
693
1032
  if (!assetCheck.ok) {
@@ -709,8 +1048,18 @@ export async function runInit(args) {
709
1048
  process.stderr.write(`[init] Router type: ${routerType}\n`);
710
1049
  }
711
1050
 
712
- // 7. Download models for profile
713
- const modelKeys = getModelsForProfile(profile);
1051
+ // 7. Download models for profile (filtered by LI choice).
1052
+ // `liModel === 'none'` skips every lateon-* key; 'standard' skips edge
1053
+ // variants; 'edge' skips standard variants. Saves disk + bandwidth on
1054
+ // constrained installs.
1055
+ const allModelKeys = getModelsForProfile(profile);
1056
+ const modelKeys = filterModelKeysForLiChoice(allModelKeys, liChoices.liModel);
1057
+ const skippedByLiChoice = allModelKeys.filter((k) => !modelKeys.includes(k));
1058
+ if (skippedByLiChoice.length > 0 && parsed.verbose) {
1059
+ process.stderr.write(
1060
+ `[init] liModel=${liChoices.liModel} → skipping ${skippedByLiChoice.length} model key(s): ${skippedByLiChoice.join(', ')}\n`,
1061
+ );
1062
+ }
714
1063
  const skippedOptIns = getSkippedOptInModels(profile);
715
1064
  let modelResults = new Map();
716
1065
 
@@ -732,6 +1081,7 @@ export async function runInit(args) {
732
1081
  process.stderr.write(`[init] Downloading models for profile "${profile}"...\n`);
733
1082
  const downloadResult = await downloadModelsForProfile(profile, {
734
1083
  force: parsed.force,
1084
+ liModel: liChoices.liModel,
735
1085
  });
736
1086
 
737
1087
  modelResults = downloadResult.results;
@@ -748,6 +1098,7 @@ export async function runInit(args) {
748
1098
  profile, maxsimTier, routerType, nativeStatus,
749
1099
  modelResults, verification: { type: 'none', timestamp: new Date().toISOString(), checks: [] },
750
1100
  failed: true,
1101
+ liChoices,
751
1102
  });
752
1103
  writeInitConfig(dataDir, partialConfig);
753
1104
 
@@ -790,7 +1141,17 @@ export async function runInit(args) {
790
1141
  // from the addon's point of view.
791
1142
  const capability = detectHardwareCapability();
792
1143
  let cascadeReport = { status: 'skipped', detail: 'Cascade inspection skipped' };
793
- if (!parsed.skipCoremlCascade && profile !== 'core') {
1144
+ // Phase 4: liModel === 'none' opts out of the cascade entirely (no LI rerank,
1145
+ // no read-semantic, no ColGrep — the only consumer of the LI cascade family).
1146
+ // The embed cascade is still useful for fast NomicBERT inference, but
1147
+ // without an LI consumer the "auto" family resolution would still pick
1148
+ // standard or edge LI tarballs we'll never use. Skipping outright is
1149
+ // cleaner and saves ~600 MB.
1150
+ const skipCascadeForLiNone = liChoices.liModel === LI_MODEL_NONE;
1151
+ if (skipCascadeForLiNone && !parsed.skipCoremlCascade && profile !== 'core' && parsed.verbose) {
1152
+ process.stderr.write('[init] liModel=none → skipping CoreML cascade fetch\n');
1153
+ }
1154
+ if (!parsed.skipCoremlCascade && !skipCascadeForLiNone && profile !== 'core') {
794
1155
  cascadeReport = getCoremlCascadeReport();
795
1156
 
796
1157
  if (cascadeReport.applicable && cascadeReport.status !== 'present') {
@@ -818,6 +1179,11 @@ export async function runInit(args) {
818
1179
  const fetchResult = await fetchCoremlCascade({
819
1180
  force: parsed.force,
820
1181
  allowDownload: true, // init-time bypass — see downloadModelsForProfile comment
1182
+ // Phase 4: pass the user's persisted choice as the cascade variant
1183
+ // selector. Without this the cascade would default to whatever
1184
+ // SWEET_SEARCH_LATE_INTERACTION_MODEL says, which may diverge
1185
+ // from what the user just chose interactively or via --li-model.
1186
+ liVariantKey: liChoices.liModel,
821
1187
  });
822
1188
  if (fetchResult.status === 'fetched' || fetchResult.status === 'cached' || fetchResult.status === 'partial') {
823
1189
  const total = fetchResult.fetched + fetchResult.cached;
@@ -884,6 +1250,7 @@ export async function runInit(args) {
884
1250
  capability,
885
1251
  cascadeReport,
886
1252
  dedupReport,
1253
+ liChoices,
887
1254
  });
888
1255
  writeInitConfig(dataDir, preVerifyConfig);
889
1256
 
@@ -905,6 +1272,7 @@ export async function runInit(args) {
905
1272
  capability,
906
1273
  cascadeReport,
907
1274
  dedupReport,
1275
+ liChoices,
908
1276
  });
909
1277
  writeInitConfig(dataDir, finalConfig);
910
1278
 
@@ -979,6 +1347,7 @@ export async function runInit(args) {
979
1347
  dedupReport,
980
1348
  prewarmHookReport,
981
1349
  skillReport,
1350
+ liChoices,
982
1351
  });
983
1352
  }
984
1353
 
@@ -1031,7 +1400,7 @@ function runCoremlCascadeBuild(options = {}) {
1031
1400
  function buildConfig({
1032
1401
  profile, maxsimTier, routerType, nativeStatus, modelResults,
1033
1402
  allowRuntimeModelDownload, verification, failed,
1034
- capability, cascadeReport, dedupReport,
1403
+ capability, cascadeReport, dedupReport, liChoices,
1035
1404
  }) {
1036
1405
  const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
1037
1406
 
@@ -1083,6 +1452,18 @@ function buildConfig({
1083
1452
  smokeTest: dedupReport.smokeTest ?? null,
1084
1453
  };
1085
1454
  }
1455
+ if (liChoices) {
1456
+ // Phase 4: persisted LI policy. Read at runtime by SweetSearch via
1457
+ // `core/infrastructure/init-config.js::readPersistedLiPolicy`. The
1458
+ // `source` block is diagnostic only — it tells us which input wins
1459
+ // on the next re-run.
1460
+ runtime.li = {
1461
+ model: liChoices.liModel,
1462
+ searchReranking: liChoices.searchReranking,
1463
+ source: liChoices.source ?? null,
1464
+ recommendation: liChoices.recommendation ?? null,
1465
+ };
1466
+ }
1086
1467
 
1087
1468
  return {
1088
1469
  version: 1,