sweet-search 0.0.1 → 2.3.0

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 (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,46 @@
1
+ {
2
+ "version": 2,
3
+ "runtimeAssets": {
4
+ "wasmRouter": "crates/wasm-router/pkg/query_router_wasm.js",
5
+ "wasmRouterBinary": "crates/wasm-router/pkg/query_router_wasm_bg.wasm",
6
+ "maxsimWasm": "core/infrastructure/maxsim.wasm",
7
+ "simdDistanceWasm": "core/infrastructure/simd-distance.wasm",
8
+ "catboostRouter": "core/training/query-router/output/v45_router_d4.js",
9
+ "featureExtractor": "core/training/query-router/features/extractor.js"
10
+ },
11
+ "nativePackages": {
12
+ "darwin-arm64": "@sweet-search/native-darwin-arm64",
13
+ "darwin-x64": "@sweet-search/native-darwin-x64",
14
+ "linux-x64-gnu": "@sweet-search/native-linux-x64-gnu",
15
+ "linux-arm64-gnu": "@sweet-search/native-linux-arm64-gnu"
16
+ },
17
+ "profiles": {
18
+ "core": {
19
+ "description": "Lightweight search (no models)",
20
+ "models": []
21
+ },
22
+ "full": {
23
+ "description": "Full search with all models",
24
+ "models": [
25
+ "lateon-code",
26
+ "lateon-code-edge",
27
+ "gte-reranker-modernbert-base",
28
+ "coderankembed-int8"
29
+ ]
30
+ }
31
+ },
32
+ "modelSources": {
33
+ "lateon-code": {
34
+ "type": "init-managed"
35
+ },
36
+ "lateon-code-edge": {
37
+ "type": "init-managed"
38
+ },
39
+ "gte-reranker-modernbert-base": {
40
+ "type": "init-managed"
41
+ },
42
+ "coderankembed-int8": {
43
+ "type": "init-managed"
44
+ }
45
+ }
46
+ }
Binary file
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Model Fetcher — robust model file downloader with checksums, resumability,
3
+ * atomic writes, retries, and progress reporting.
4
+ *
5
+ * Replaces the bare fetch→writeFileSync in late-interaction-model.js.
6
+ */
7
+
8
+ import { createHash } from 'crypto';
9
+ import {
10
+ createReadStream,
11
+ createWriteStream,
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ statSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from 'fs';
20
+ import { join } from 'path';
21
+ import os from 'os';
22
+ import { pipeline } from 'stream/promises';
23
+ import { MODEL_DELIVERY_CONFIG } from './config/index.js';
24
+ import { getModelEntry } from './model-registry.js';
25
+
26
+ const MAX_RETRIES = 3;
27
+ const RETRY_BASE_MS = 1000;
28
+
29
+ // =============================================================================
30
+ // VERIFICATION CACHE (H6 — avoid N-worker SHA256 stall)
31
+ // =============================================================================
32
+ //
33
+ // Without this cache, every worker_thread that calls `fetchModel(...)` for
34
+ // the same ~600 MB safetensors file independently runs its own SHA256 stream
35
+ // over the bytes. Node's microtask scheduler is unfair under ORT load, so
36
+ // several concurrent verifications can stall for minutes.
37
+ //
38
+ // Two-layer cache:
39
+ // 1. In-process Map<absPath, {mtimeMs,size,dev,ino,sha256,verifiedAt}>
40
+ // — skips repeat verifications within the same worker.
41
+ // 2. Disk sidecar `{filePath}.verified.json` — lets a DIFFERENT worker
42
+ // (separate V8 isolate, separate Map) short-circuit on next load
43
+ // without re-streaming. The sidecar is invalidated on any stat mismatch.
44
+ //
45
+ // TRUST MODEL — READ BEFORE CHANGING ANY OF THIS:
46
+ //
47
+ // This is a MEMOIZATION OPTIMIZATION, NOT a cryptographic trust anchor. The
48
+ // cache remembers a prior successful SHA256 stream, and returns it when the
49
+ // file "looks the same" by a stat-fingerprint: (size, mtimeMs, dev, ino).
50
+ // Adding (dev, ino) raises the bar for a local same-user attacker who
51
+ // replaces the file via `unlink + write` — the new file gets a fresh inode
52
+ // and `stat.ino !== sidecar.ino` triggers a re-hash (2026-04-15 hardening).
53
+ // On an in-place overwrite that preserves (size, ino, mtimeMs) — which a
54
+ // determined local attacker can forge — the memoized hash is trusted.
55
+ //
56
+ // This is an acceptable trade-off ONLY because:
57
+ // (a) the H6 speedup (~2 min → ~1 s under worker-pool contention) is real;
58
+ // (b) a local same-user attacker who controls the model cache dir has
59
+ // already cleared a significant bar on a developer workstation;
60
+ // (c) the trust anchor for model bytes remains the registry SHA256 in
61
+ // `core/infrastructure/model-registry.js` (code, reviewed via git),
62
+ // which is what gets written as `record.sha256` when we first hash.
63
+ //
64
+ // Defense-in-depth follow-ups under consideration (not yet implemented):
65
+ // - HMAC the sidecar with a per-install secret in `.sweet-search/install-secret`
66
+ // - Re-verify in the Rust loader before mmap (defense-in-depth across the
67
+ // JS→Rust boundary)
68
+ //
69
+ // Mandatory invariants — any change that weakens these regresses security:
70
+ // 1. The cache NEVER skips verification against a NEW expected hash. A
71
+ // caller supplying `expectedSha256 = X` gets a fresh stream unless the
72
+ // cache already holds a positive record for X.
73
+ // 2. The in-process Map is invalidated on every explicit `fetchModelFile`
74
+ // atomic rename (see `invalidateVerifiedSidecar` before the rename).
75
+ // 3. A positive record is written ONLY after a successful streaming hash.
76
+ // `recordVerified` is never called without a prior `computeFileHash`
77
+ // that matched `expectedSha256`.
78
+
79
+ const _verificationMemCache = new Map();
80
+
81
+ function verifiedSidecarPath(filePath) {
82
+ return filePath + '.verified.json';
83
+ }
84
+
85
+ function readVerifiedSidecar(filePath) {
86
+ const sidecarPath = verifiedSidecarPath(filePath);
87
+ if (!existsSync(sidecarPath)) return null;
88
+ try {
89
+ const raw = readFileSync(sidecarPath, 'utf-8');
90
+ const parsed = JSON.parse(raw);
91
+ if (!parsed || typeof parsed !== 'object') return null;
92
+ return parsed;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function writeVerifiedSidecar(filePath, record) {
99
+ try {
100
+ writeFileSync(verifiedSidecarPath(filePath), JSON.stringify(record));
101
+ } catch {
102
+ // Best-effort — a failed sidecar write just means the next run re-verifies.
103
+ }
104
+ }
105
+
106
+ function invalidateVerifiedSidecar(filePath) {
107
+ try {
108
+ unlinkSync(verifiedSidecarPath(filePath));
109
+ } catch (err) {
110
+ if (err && err.code !== 'ENOENT') { /* ignore */ }
111
+ }
112
+ _verificationMemCache.delete(filePath);
113
+ }
114
+
115
+ /**
116
+ * Check if the cached stat-fingerprint matches the live file.
117
+ *
118
+ * The fingerprint is `(size, mtimeMs, dev, ino)`. Adding `(dev, ino)` to the
119
+ * original `(size, mtime)` pair raises the bar for local same-user file
120
+ * replacement: an attacker who does `unlink + write` to plant a forged file
121
+ * while reusing a matching `verified.json` sidecar gets a FRESH inode on
122
+ * the new file, so `stat.ino !== record.ino` triggers a re-hash. This is
123
+ * not cryptographic protection, but it turns a common forgery pattern from
124
+ * a silent bypass into a logged re-verification.
125
+ *
126
+ * Records from older sidecars that lack dev/ino (written before this
127
+ * hardening landed) still validate on (size, mtime) alone — this is
128
+ * necessary for backwards compatibility with existing caches.
129
+ */
130
+ function stableStatFingerprint(stat) {
131
+ return { size: stat.size, mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino };
132
+ }
133
+
134
+ function fingerprintMatches(record, stat) {
135
+ if (record.size !== stat.size) return false;
136
+ if (record.mtimeMs !== stat.mtimeMs) return false;
137
+ // dev / ino are optional on legacy sidecars. When the record carries them,
138
+ // require a match; when it doesn't, accept on (size, mtime) alone so we
139
+ // don't force a re-hash on every existing cache entry.
140
+ if (record.dev != null && record.dev !== stat.dev) return false;
141
+ if (record.ino != null && record.ino !== stat.ino) return false;
142
+ return true;
143
+ }
144
+
145
+ /**
146
+ * Check the verification cache for a file. Returns true iff the file's
147
+ * current stat-fingerprint matches a cached record AND the cached
148
+ * record's sha256 matches `expectedSha256`. Checks the in-process map
149
+ * first, falls back to the on-disk sidecar.
150
+ */
151
+ function isVerified(filePath, expectedSha256) {
152
+ if (!expectedSha256) return false;
153
+ let stat;
154
+ try { stat = statSync(filePath); } catch { return false; }
155
+
156
+ const memRecord = _verificationMemCache.get(filePath);
157
+ if (memRecord && memRecord.sha256 === expectedSha256 && fingerprintMatches(memRecord, stat)) {
158
+ return true;
159
+ }
160
+
161
+ const diskRecord = readVerifiedSidecar(filePath);
162
+ if (diskRecord && diskRecord.sha256 === expectedSha256 && fingerprintMatches(diskRecord, stat)) {
163
+ _verificationMemCache.set(filePath, diskRecord);
164
+ return true;
165
+ }
166
+
167
+ return false;
168
+ }
169
+
170
+ function recordVerified(filePath, sha256) {
171
+ let stat;
172
+ try { stat = statSync(filePath); } catch { return; }
173
+ const record = {
174
+ sha256,
175
+ ...stableStatFingerprint(stat),
176
+ verifiedAt: Date.now(),
177
+ };
178
+ _verificationMemCache.set(filePath, record);
179
+ writeVerifiedSidecar(filePath, record);
180
+ }
181
+
182
+ /**
183
+ * Get the managed cache directory for a HuggingFace model.
184
+ */
185
+ export function getModelCacheDir(hfId) {
186
+ const normalized = hfId.replace('/', '--');
187
+ const dir = join(MODEL_DELIVERY_CONFIG.modelCacheRoot, normalized);
188
+ mkdirSync(dir, { recursive: true });
189
+ return dir;
190
+ }
191
+
192
+ /**
193
+ * Compute SHA256 of a local file.
194
+ */
195
+ export async function computeFileHash(filePath) {
196
+ const hash = createHash('sha256');
197
+ await pipeline(createReadStream(filePath), hash);
198
+ return hash.digest('hex');
199
+ }
200
+
201
+ /**
202
+ * Check if a cached file is valid (exists, correct size, correct checksum if known).
203
+ *
204
+ * Short-circuits via the verification cache when possible: if a previous
205
+ * call to this function (in this process OR a prior one) verified the same
206
+ * (path, mtime, size, sha256) tuple and the stat hasn't changed, we skip
207
+ * the expensive streaming hash. This is the H6 fix — without it, N embedding
208
+ * workers each independently re-hash the 596 MB safetensors file.
209
+ */
210
+ export async function isCacheValid(filePath, expectedSize, expectedSha256) {
211
+ if (!existsSync(filePath)) return false;
212
+
213
+ const stat = statSync(filePath);
214
+ if (stat.size === 0) return false;
215
+ if (expectedSize && stat.size !== expectedSize) return false;
216
+
217
+ if (expectedSha256) {
218
+ if (isVerified(filePath, expectedSha256)) {
219
+ return true;
220
+ }
221
+ const hash = await computeFileHash(filePath);
222
+ if (hash !== expectedSha256) {
223
+ invalidateVerifiedSidecar(filePath);
224
+ return false;
225
+ }
226
+ recordVerified(filePath, hash);
227
+ }
228
+
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * Download a single file from HuggingFace with retries, resumability, and atomic writes.
234
+ *
235
+ * @param {string} hfId - HuggingFace model ID (e.g. 'lightonai/LateOn-Code')
236
+ * @param {string} filePath - File path within the model repo (e.g. 'model_int8.onnx')
237
+ * @param {string} destDir - Local directory to save into
238
+ * @param {object} options
239
+ * @param {string} [options.sha256] - Expected SHA256 checksum
240
+ * @param {number} [options.expectedSize] - Expected file size in bytes
241
+ * @param {string} [options.hfEndpoint] - HuggingFace endpoint override
242
+ * @param {function} [options.onProgress] - Progress callback(downloadedBytes, totalBytes)
243
+ * @param {AbortSignal} [options.signal] - Abort signal
244
+ * @param {boolean} [options.allowDownload] - Explicit override for
245
+ * MODEL_DELIVERY_CONFIG.allowRuntimeModelDownload. When true, bypasses
246
+ * the config-based download gate. Used by `scripts/init.js` during its
247
+ * own download phase — the `allowRuntimeModelDownload: false` flag
248
+ * is meant to stop RUNTIME (query-time) downloads, not init-time
249
+ * downloads, but the config is persisted before init re-runs can
250
+ * download more, so init needs the explicit bypass. Runtime callers
251
+ * (embedding-service, model-pool, etc.) omit this and stay gated.
252
+ * @returns {string} Absolute path to the verified local file
253
+ */
254
+ export async function fetchModelFile(hfId, filePath, destDir, options = {}) {
255
+ const { sha256, expectedSize, onProgress, signal, allowDownload } = options;
256
+ const hfEndpoint = options.hfEndpoint || MODEL_DELIVERY_CONFIG.hfEndpoint;
257
+
258
+ // Preserve directory structure (e.g. onnx/model.onnx stays as onnx/model.onnx)
259
+ const finalPath = join(destDir, filePath);
260
+ mkdirSync(join(finalPath, '..'), { recursive: true });
261
+ const tmpPath = finalPath + '.tmp';
262
+
263
+ // Check existing cache
264
+ if (await isCacheValid(finalPath, expectedSize, sha256)) {
265
+ return finalPath;
266
+ }
267
+
268
+ // Check if runtime download is allowed. Explicit `options.allowDownload`
269
+ // overrides the persisted config flag — init passes `true` so re-runs
270
+ // can fetch a bumped SHA256, a newly added model, or a `--force` rebuild.
271
+ const downloadAllowed = allowDownload === true || MODEL_DELIVERY_CONFIG.allowRuntimeModelDownload;
272
+ if (!downloadAllowed) {
273
+ throw new Error(
274
+ `[ModelFetcher] Model file not found: ${hfId}/${filePath}\n` +
275
+ ` Expected at: ${finalPath}\n` +
276
+ ` Runtime model downloads are disabled.\n` +
277
+ ` Run \`sweet-search init\` to download required models.`
278
+ );
279
+ }
280
+
281
+ const url = `${hfEndpoint}/${hfId}/resolve/main/${filePath}`;
282
+
283
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
284
+ try {
285
+ // Check for partial download (resumability)
286
+ let startByte = 0;
287
+ if (existsSync(tmpPath)) {
288
+ startByte = statSync(tmpPath).size;
289
+ }
290
+
291
+ const headers = {};
292
+ if (startByte > 0) {
293
+ headers['Range'] = `bytes=${startByte}-`;
294
+ }
295
+
296
+ const resp = await fetch(url, { headers, signal });
297
+
298
+ if (resp.status === 416) {
299
+ // Range not satisfiable — file changed on server, restart
300
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
301
+ startByte = 0;
302
+ continue;
303
+ }
304
+
305
+ if (!resp.ok && resp.status !== 206) {
306
+ throw new Error(`HTTP ${resp.status} downloading ${url}`);
307
+ }
308
+
309
+ const totalSize = expectedSize || parseInt(resp.headers.get('content-length') || '0', 10) + startByte;
310
+ const isResume = resp.status === 206;
311
+
312
+ process.stderr.write(`[ModelFetcher] ${isResume ? 'Resuming' : 'Downloading'} ${filePath} from ${hfId}...`);
313
+
314
+ const writeStream = createWriteStream(tmpPath, { flags: isResume ? 'a' : 'w' });
315
+ const reader = resp.body.getReader();
316
+ let downloaded = startByte;
317
+
318
+ while (true) {
319
+ const { done, value } = await reader.read();
320
+ if (done) break;
321
+ writeStream.write(Buffer.from(value));
322
+ downloaded += value.byteLength;
323
+ if (onProgress) onProgress(downloaded, totalSize);
324
+ }
325
+
326
+ writeStream.end();
327
+ await new Promise((resolve, reject) => {
328
+ writeStream.on('finish', resolve);
329
+ writeStream.on('error', reject);
330
+ });
331
+
332
+ const downloadedSize = statSync(tmpPath).size;
333
+ const sizeMB = (downloadedSize / 1024 / 1024).toFixed(1);
334
+ process.stderr.write(` ${sizeMB} MB\n`);
335
+
336
+ // Verify size
337
+ if (expectedSize && downloadedSize !== expectedSize) {
338
+ unlinkSync(tmpPath);
339
+ throw new Error(`Size mismatch: expected ${expectedSize} bytes, got ${downloadedSize}`);
340
+ }
341
+
342
+ // Verify checksum
343
+ if (sha256) {
344
+ process.stderr.write(`[ModelFetcher] Verifying checksum...`);
345
+ const hash = await computeFileHash(tmpPath);
346
+ if (hash !== sha256) {
347
+ unlinkSync(tmpPath);
348
+ throw new Error(`Checksum mismatch for ${filePath}: expected ${sha256}, got ${hash}`);
349
+ }
350
+ process.stderr.write(` OK\n`);
351
+ }
352
+
353
+ // Atomic rename — a fresh file overwrites the verification sidecar's
354
+ // mtime/size assumptions, so drop the cache before we record a new one.
355
+ invalidateVerifiedSidecar(finalPath);
356
+ renameSync(tmpPath, finalPath);
357
+ if (sha256) {
358
+ recordVerified(finalPath, sha256);
359
+ }
360
+ return finalPath;
361
+
362
+ } catch (err) {
363
+ if (signal?.aborted) throw err;
364
+ if (attempt < MAX_RETRIES) {
365
+ const delay = RETRY_BASE_MS * Math.pow(2, attempt - 1);
366
+ process.stderr.write(`\n[ModelFetcher] Attempt ${attempt} failed: ${err.message}. Retrying in ${delay}ms...\n`);
367
+ await new Promise(r => setTimeout(r, delay));
368
+ } else {
369
+ // Clean up tmp on final failure
370
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
371
+ throw new Error(`[ModelFetcher] Failed to download ${hfId}/${filePath} after ${MAX_RETRIES} attempts: ${err.message}`);
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Download all files for a registered model.
379
+ *
380
+ * @param {string} registryKey - Key in MODEL_REGISTRY
381
+ * @param {object} [options] - Options passed to fetchModelFile
382
+ * @returns {{ files: Map<string, string>, cached: number, downloaded: number }}
383
+ */
384
+ export async function fetchModel(registryKey, options = {}) {
385
+ const entry = getModelEntry(registryKey);
386
+ if (!entry) throw new Error(`[ModelFetcher] Unknown model: ${registryKey}`);
387
+
388
+ const destDir = getModelCacheDir(entry.hfId);
389
+ const files = new Map();
390
+ let cached = 0;
391
+ let downloaded = 0;
392
+
393
+ for (const file of entry.files) {
394
+ const finalPath = join(destDir, file.path);
395
+
396
+ const wasPresent = await isCacheValid(finalPath, file.sizeBytes, file.sha256);
397
+
398
+ const resultPath = await fetchModelFile(entry.hfId, file.path, destDir, {
399
+ sha256: file.sha256,
400
+ expectedSize: file.sizeBytes,
401
+ ...options,
402
+ });
403
+
404
+ files.set(file.path, resultPath);
405
+ if (wasPresent) cached++;
406
+ else downloaded++;
407
+ }
408
+
409
+ return { files, cached, downloaded };
410
+ }
411
+
412
+ /**
413
+ * Resolve the local cache path for a model file. Returns null if not cached or invalid.
414
+ */
415
+ export async function resolveModelFile(hfId, filePath, expectedSize, expectedSha256) {
416
+ const destDir = getModelCacheDir(hfId);
417
+ const finalPath = join(destDir, filePath);
418
+
419
+ if (await isCacheValid(finalPath, expectedSize, expectedSha256)) {
420
+ return finalPath;
421
+ }
422
+ return null;
423
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Model Registry — known model artifacts with HuggingFace IDs, file paths,
3
+ * sizes, and SHA256 content checksums.
4
+ *
5
+ * Only models with active runtime paths in the current full-profile search.
6
+ * Checksums are LFS content SHA256 from the HuggingFace API.
7
+ * Small non-LFS files (tokenizer.json, config.json) omit sha256 — they are
8
+ * integrity-checked by git and verified by size only.
9
+ *
10
+ * Use scripts/verify-model-registry.js to regenerate/verify checksums.
11
+ */
12
+
13
+ export const MODEL_REGISTRY = {
14
+ 'lateon-code': {
15
+ hfId: 'lightonai/LateOn-Code',
16
+ profile: 'full',
17
+ description: 'Late interaction model (INT8, 128d)',
18
+ files: [
19
+ { path: 'model_int8.onnx', sizeBytes: 150008364, sha256: 'a62a88b4e3ebb76e8bc5f0263d17b773c667d27bc73c5120e3131048dd1554ef' },
20
+ { path: '1_Dense/model.safetensors', sizeBytes: 393304, sha256: '22ea6a53cad3ed034934b5db7a214a0bcc28ff4cc440babea44029989e4bbcca' },
21
+ { path: 'tokenizer.json', sizeBytes: 3583847, sha256: null },
22
+ { path: 'tokenizer_config.json', sizeBytes: 21372, sha256: null },
23
+ { path: 'special_tokens_map.json', sizeBytes: 581, sha256: null },
24
+ { path: 'config.json', sizeBytes: 1208, sha256: null },
25
+ ],
26
+ },
27
+
28
+ 'lateon-code-edge': {
29
+ hfId: 'lightonai/LateOn-Code-edge',
30
+ profile: 'full',
31
+ description: 'Late interaction edge model (FP32, 48d)',
32
+ files: [
33
+ { path: 'model.onnx', sizeBytes: 67970609, sha256: 'ac5a92a685512b163c3c591438f518379309d2a98c4818a9c6e2986f789dc8ef' },
34
+ { path: '1_Dense/model.safetensors', sizeBytes: 524376, sha256: '9efb17fcb2106cd8fcb01d57a9cd9c997a487ad20630ec8e44ce3f9d89efe0a7' },
35
+ { path: '2_Dense/model.safetensors', sizeBytes: 98392, sha256: 'a7a388138b3c4bb1a81c8c3bcb9de123f1e652b9e9464a72707ca19ee86a26b1' },
36
+ { path: 'tokenizer.json', sizeBytes: 3583847, sha256: null },
37
+ { path: 'tokenizer_config.json', sizeBytes: 21373, sha256: null },
38
+ { path: 'special_tokens_map.json', sizeBytes: 581, sha256: null },
39
+ { path: 'config.json', sizeBytes: 1252, sha256: null },
40
+ ],
41
+ },
42
+
43
+ 'gte-reranker-modernbert-base': {
44
+ hfId: 'Alibaba-NLP/gte-reranker-modernbert-base',
45
+ profile: 'full',
46
+ description: 'Local reranker (INT8 quantized) — opt-in',
47
+ // Disabled by default since commit 43a61eb (2026-04-22): measured
48
+ // MRR-neutral on gencodesearchnet (82.15% with vs without) at 3x
49
+ // latency cost. init should NOT fetch this 143 MB model unless
50
+ // the user explicitly opts in via SWEET_SEARCH_ENABLE_LOCAL_RERANKER=1.
51
+ // Infrastructure (local-reranker.js, this registry entry) remains
52
+ // intact so the CE can be swapped back in without re-adding code.
53
+ optIn: {
54
+ envVars: ['SWEET_SEARCH_ENABLE_LOCAL_RERANKER'],
55
+ reason: 'Local cross-encoder reranker — disabled by default (MRR-neutral at 3x latency, see commit 43a61eb)',
56
+ },
57
+ files: [
58
+ { path: 'onnx/model_quantized.onnx', sizeBytes: 150871837, sha256: 'ecc6a0ae67cee3d898167802383112d9185ca9250e07bd5d1fa65019b050179d' },
59
+ { path: 'tokenizer.json', sizeBytes: 3583499, sha256: null },
60
+ { path: 'tokenizer_config.json', sizeBytes: 21031, sha256: null },
61
+ { path: 'special_tokens_map.json', sizeBytes: 694, sha256: null },
62
+ { path: 'config.json', sizeBytes: 1333, sha256: null },
63
+ ],
64
+ },
65
+
66
+ 'coderankembed-int8': {
67
+ hfId: 'mrsladoje/CodeRankEmbed-onnx-int8',
68
+ profile: 'full',
69
+ description: 'Local embedding model (INT8 quantized, 768d)',
70
+ files: [
71
+ // SHA256 updated 2026-04-23: re-quantized with `reduce_range=True`
72
+ // to fix AVX2-only INT8 kernel overflow on pre-VNNI x86 CPUs
73
+ // (notably AMD Zen 3 EPYC). Size unchanged — reduce_range only
74
+ // affects weight *values*, not storage format. See HF model card
75
+ // for full details: https://huggingface.co/mrsladoje/CodeRankEmbed-onnx-int8
76
+ { path: 'onnx/model.onnx', sizeBytes: 138619279, sha256: '4eae31d09b1843103a1ebd5e2b2e24b5a5cad441a33906b35b12b1e2ed91d1db' },
77
+ { path: 'tokenizer.json', sizeBytes: 711649, sha256: null },
78
+ { path: 'tokenizer_config.json', sizeBytes: 1447, sha256: null },
79
+ { path: 'special_tokens_map.json', sizeBytes: 695, sha256: null },
80
+ { path: 'config.json', sizeBytes: 1371, sha256: null },
81
+ { path: 'vocab.txt', sizeBytes: 231508, sha256: null },
82
+ ],
83
+ },
84
+
85
+ 'coderankembed-fp32': {
86
+ hfId: 'nomic-ai/CodeRankEmbed',
87
+ profile: 'full',
88
+ description: 'Local embedding model (FP32 safetensors, 768d) for native inference',
89
+ files: [
90
+ { path: 'model.safetensors', sizeBytes: 546938168, sha256: '827529bcd58aef0d9082e66eeff7e7d53a02f62bd005f841a26b3d3e2fb17ebe' },
91
+ { path: 'config.json', sizeBytes: 1525, sha256: null },
92
+ ],
93
+ },
94
+
95
+ 'lateon-code-fp32': {
96
+ hfId: 'lightonai/LateOn-Code',
97
+ profile: 'full',
98
+ description: 'Late interaction model (FP32 safetensors, backbone 768d) for native inference',
99
+ files: [
100
+ { path: 'model.safetensors', sizeBytes: 596076280, sha256: '45c40bb4ba6b45f0c66b2deb3d27dd06efc3af23c78c8093b8cad2af61c683b2' },
101
+ { path: '1_Dense/model.safetensors', sizeBytes: 393304, sha256: '22ea6a53cad3ed034934b5db7a214a0bcc28ff4cc440babea44029989e4bbcca' },
102
+ { path: 'config.json', sizeBytes: 1208, sha256: null },
103
+ ],
104
+ },
105
+
106
+ 'ms-marco-tinybert': {
107
+ hfId: 'Xenova/ms-marco-TinyBERT-L-2-v2',
108
+ profile: 'full',
109
+ description: 'FlashRank cross-encoder reranker (quantized, ~4.3MB) — opt-in',
110
+ // Disabled by default since commits 43a61eb + d92a5a7 (2026-04-22):
111
+ // the MaxSim+CE cascade is no longer active in the default config
112
+ // (CASCADE_CONFIG.enabled=false, shadowMode=false). init should not
113
+ // fetch this model unless the user explicitly opts into cascade or
114
+ // shadow mode.
115
+ optIn: {
116
+ envVars: ['SWEET_SEARCH_CASCADE_ENABLED', 'SWEET_SEARCH_CASCADE_SHADOW'],
117
+ reason: 'FlashRank cross-encoder for MaxSim+CE cascade — disabled by default (see commits 43a61eb, d92a5a7)',
118
+ },
119
+ files: [
120
+ { path: 'onnx/model_quantized.onnx', sizeBytes: 4496298, sha256: '026c2ec3257cd351696e45bbd6040bb83cf818ba89059b4344bd6350138b62ce' },
121
+ { path: 'tokenizer.json', sizeBytes: 711396, sha256: null },
122
+ { path: 'tokenizer_config.json', sizeBytes: 1242, sha256: null },
123
+ { path: 'special_tokens_map.json', sizeBytes: 125, sha256: null },
124
+ { path: 'config.json', sizeBytes: 824, sha256: null },
125
+ { path: 'vocab.txt', sizeBytes: 231508, sha256: null },
126
+ ],
127
+ },
128
+
129
+ 'all-minilm-l6-v2': {
130
+ hfId: 'Xenova/all-MiniLM-L6-v2',
131
+ profile: 'full',
132
+ description: 'Semantic cache embedding model (quantized, ~23MB)',
133
+ files: [
134
+ { path: 'onnx/model_quantized.onnx', sizeBytes: 22972370, sha256: 'afdb6f1a0e45b715d0bb9b11772f032c399babd23bfc31fed1c170afc848bdb1' },
135
+ { path: 'tokenizer.json', sizeBytes: 711661, sha256: null },
136
+ { path: 'tokenizer_config.json', sizeBytes: 366, sha256: null },
137
+ { path: 'special_tokens_map.json', sizeBytes: 125, sha256: null },
138
+ { path: 'config.json', sizeBytes: 650, sha256: null },
139
+ { path: 'vocab.txt', sizeBytes: 231508, sha256: null },
140
+ ],
141
+ },
142
+ };
143
+
144
+ /**
145
+ * Get registry entry by key. Returns null if not found.
146
+ */
147
+ export function getModelEntry(key) {
148
+ return MODEL_REGISTRY[key] || null;
149
+ }
150
+
151
+ /**
152
+ * Truthy env-flag parser. Matches the conventions used across the codebase:
153
+ * "1" / "true" / "on" / "yes" (case-insensitive) → true
154
+ * anything else / unset → false
155
+ */
156
+ function isEnvTruthy(name) {
157
+ const raw = (process.env[name] ?? '').trim().toLowerCase();
158
+ return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes';
159
+ }
160
+
161
+ /**
162
+ * Check whether an opt-in model should be included given the current
163
+ * environment. Opt-in entries declare a list of env vars; if ANY is
164
+ * truthy, the model is pulled in. Entries without `optIn` are always
165
+ * considered enabled (standard models).
166
+ */
167
+ export function isModelEnabled(entry) {
168
+ if (!entry?.optIn) return true;
169
+ const vars = entry.optIn.envVars ?? [];
170
+ return vars.some((v) => isEnvTruthy(v));
171
+ }
172
+
173
+ /**
174
+ * Get all model keys for a given profile, excluding opt-in models
175
+ * whose enabling env vars are unset.
176
+ *
177
+ * Currently-skipped-by-default models (since 2026-04-22 ranking refactor):
178
+ * - gte-reranker-modernbert-base — needs SWEET_SEARCH_ENABLE_LOCAL_RERANKER=1
179
+ * - ms-marco-tinybert — needs SWEET_SEARCH_CASCADE_ENABLED=1
180
+ * or SWEET_SEARCH_CASCADE_SHADOW=1
181
+ *
182
+ * This matches the runtime behavior: the ranking config defaults to
183
+ * these rerankers OFF, so init shouldn't fetch their ~155 MB of weights
184
+ * for no reason. The models stay in the registry (with SHA256 and size)
185
+ * so flipping the opt-in env var on any host fetches them on the next init.
186
+ */
187
+ export function getModelsForProfile(profile) {
188
+ return Object.entries(MODEL_REGISTRY)
189
+ .filter(([, entry]) => {
190
+ const inProfile = entry.profile === profile || profile === 'offline-max';
191
+ if (!inProfile) return false;
192
+ return isModelEnabled(entry);
193
+ })
194
+ .map(([key]) => key);
195
+ }
196
+
197
+ /**
198
+ * Get the keys of models currently SKIPPED by `getModelsForProfile(profile)`
199
+ * because their opt-in env vars aren't set. Used by init to print a
200
+ * one-line summary so users know optional models exist without being
201
+ * surprised they weren't installed.
202
+ */
203
+ export function getSkippedOptInModels(profile) {
204
+ return Object.entries(MODEL_REGISTRY)
205
+ .filter(([, entry]) => {
206
+ const inProfile = entry.profile === profile || profile === 'offline-max';
207
+ return inProfile && entry.optIn && !isModelEnabled(entry);
208
+ })
209
+ .map(([key, entry]) => ({
210
+ key,
211
+ envVars: entry.optIn.envVars,
212
+ reason: entry.optIn.reason,
213
+ }));
214
+ }