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.
- package/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- 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
|
+
}
|