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,532 @@
1
+ /**
2
+ * Warmup Metrics & Adaptive Learning (Step 9 of Vocabulary Prewarm)
3
+ *
4
+ * Tracks per-mode cache hit rates, latency percentiles, and provides
5
+ * promotion/demotion rules for adaptive vocabulary management.
6
+ * Integrates with Step 0 telemetry from embedding-cache.js.
7
+ */
8
+
9
+ // =============================================================================
10
+ // WARMUP METRICS CLASS
11
+ // =============================================================================
12
+
13
+ export class WarmupMetrics {
14
+ constructor() {
15
+ this.lexical = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
16
+ this.semantic = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
17
+ this.hybrid = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
18
+ }
19
+
20
+ /**
21
+ * Cache hit rate for a specific mode.
22
+ * @param {'lexical'|'semantic'|'hybrid'} mode
23
+ * @returns {number} 0-1 ratio
24
+ */
25
+ hitRate(mode) {
26
+ const bucket = this[mode];
27
+ if (!bucket) return 0;
28
+ const total = bucket.hits + bucket.misses;
29
+ return total > 0 ? bucket.hits / total : 0;
30
+ }
31
+
32
+ /**
33
+ * Overall cache hit rate across all modes.
34
+ * @returns {number} 0-1 ratio
35
+ */
36
+ overallHitRate() {
37
+ let totalHits = 0;
38
+ let totalQueries = 0;
39
+ for (const mode of ['lexical', 'semantic', 'hybrid']) {
40
+ totalHits += this[mode].hits;
41
+ totalQueries += this[mode].hits + this[mode].misses;
42
+ }
43
+ return totalQueries > 0 ? totalHits / totalQueries : 0;
44
+ }
45
+
46
+ /**
47
+ * Average latency for a mode.
48
+ * @param {'lexical'|'semantic'|'hybrid'} mode
49
+ * @returns {number} milliseconds
50
+ */
51
+ avgLatency(mode) {
52
+ const bucket = this[mode];
53
+ if (!bucket || bucket.count === 0) return 0;
54
+ return bucket.totalLatencyMs / bucket.count;
55
+ }
56
+
57
+ /**
58
+ * Latency percentile for a mode.
59
+ * @param {'lexical'|'semantic'|'hybrid'} mode
60
+ * @param {number} percentile - 0-100
61
+ * @returns {number} milliseconds
62
+ */
63
+ latencyPercentile(mode, percentile) {
64
+ const bucket = this[mode];
65
+ if (!bucket || bucket.latencies.length === 0) return 0;
66
+ const sorted = [...bucket.latencies].sort((a, b) => a - b);
67
+ const idx = Math.min(
68
+ Math.floor((percentile / 100) * sorted.length),
69
+ sorted.length - 1,
70
+ );
71
+ return sorted[idx];
72
+ }
73
+
74
+ /**
75
+ * Load metrics from parsed telemetry entries.
76
+ * @param {Array<{mode: string, hit: boolean, latencyMs: number}>} entries
77
+ */
78
+ loadFromEntries(entries) {
79
+ for (const entry of entries) {
80
+ const bucket = this[entry.mode];
81
+ if (!bucket) continue;
82
+
83
+ bucket.count++;
84
+ bucket.totalLatencyMs += entry.latencyMs || 0;
85
+ bucket.latencies.push(entry.latencyMs || 0);
86
+
87
+ if (entry.hit) {
88
+ bucket.hits++;
89
+ } else {
90
+ bucket.misses++;
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Load from a getTelemetryReport()-style object (modes with hits/misses/count).
97
+ * Note: loses per-query latency granularity (no percentiles).
98
+ * @param {{ modes: Record<string, { hits: number, misses: number, avgLatencyMs: number, count: number }> }} report
99
+ */
100
+ loadFromReport(report) {
101
+ if (!report?.modes) return;
102
+ for (const [mode, stats] of Object.entries(report.modes)) {
103
+ const bucket = this[mode];
104
+ if (!bucket) continue;
105
+ bucket.hits = stats.hits || 0;
106
+ bucket.misses = stats.misses || 0;
107
+ bucket.count = stats.count || 0;
108
+ bucket.totalLatencyMs = (stats.avgLatencyMs || 0) * bucket.count;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Reset all counters.
114
+ */
115
+ reset() {
116
+ for (const mode of ['lexical', 'semantic', 'hybrid']) {
117
+ this[mode] = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
118
+ }
119
+ }
120
+ }
121
+
122
+ // =============================================================================
123
+ // CACHE HIT DEFINITIONS
124
+ // =============================================================================
125
+
126
+ /**
127
+ * Determine if a query event counts as a cache hit for its mode.
128
+ *
129
+ * Lexical: FTS5 query latency < 5ms = hit
130
+ * Semantic: source is 'vocabulary' or 'semantic-cache' = hit
131
+ * Hybrid: both sub-mode hits = hit
132
+ *
133
+ * @param {object} entry - Telemetry entry
134
+ * @param {string} entry.mode
135
+ * @param {number} [entry.latencyMs]
136
+ * @param {string} [entry.source] - 'vocabulary', 'semantic-cache', 'api', 'local', etc.
137
+ * @param {boolean} [entry.lexicalHit] - For hybrid: lexical sub-hit
138
+ * @param {boolean} [entry.semanticHit] - For hybrid: semantic sub-hit
139
+ * @returns {boolean}
140
+ */
141
+ export function isWarmupHit(entry) {
142
+ switch (entry.mode) {
143
+ case 'lexical':
144
+ return (entry.latencyMs || Infinity) < 5;
145
+ case 'semantic':
146
+ return entry.source === 'vocabulary' || entry.source === 'semantic-cache';
147
+ case 'hybrid':
148
+ return Boolean(entry.lexicalHit && entry.semanticHit);
149
+ default:
150
+ return false;
151
+ }
152
+ }
153
+
154
+ // =============================================================================
155
+ // PROMOTION / DEMOTION (Adaptive Learning)
156
+ // =============================================================================
157
+
158
+ /**
159
+ * Find terms that should be promoted to the warmup vocabulary.
160
+ *
161
+ * Rules:
162
+ * - PROMOTE: Query used 3+ times and not in warmup set
163
+ * - FAST-PROMOTE: Cache miss for an identifier found in code (source: 'code-identifier')
164
+ *
165
+ * @param {Array<{query?: string, mode?: string, hit?: boolean, source?: string}>} telemetryEntries
166
+ * @param {Set<string>|Map<string,any>|{has: (t: string) => boolean}} vocabularyTerms
167
+ * @returns {Array<{term: string, reason: 'promote'|'fast-promote', queryCount: number}>}
168
+ */
169
+ export function getPromotionCandidates(telemetryEntries, vocabularyTerms) {
170
+ const queryCounts = new Map();
171
+ const fastPromoteCandidates = new Set();
172
+
173
+ const hasVocab = (term) => {
174
+ if (!term) return true;
175
+ const normalized = term.toLowerCase().trim();
176
+ if (typeof vocabularyTerms.has === 'function') return vocabularyTerms.has(normalized);
177
+ return false;
178
+ };
179
+
180
+ for (const entry of telemetryEntries) {
181
+ const query = entry.query?.toLowerCase().trim();
182
+ if (!query) continue;
183
+
184
+ // Count occurrences
185
+ queryCounts.set(query, (queryCounts.get(query) || 0) + 1);
186
+
187
+ // Fast-promote: cache miss + code identifier source
188
+ if (!entry.hit && entry.source === 'code-identifier' && !hasVocab(query)) {
189
+ fastPromoteCandidates.add(query);
190
+ }
191
+ }
192
+
193
+ const candidates = [];
194
+
195
+ // PROMOTE: 3+ uses, not in vocabulary
196
+ for (const [term, count] of queryCounts) {
197
+ if (count >= 3 && !hasVocab(term)) {
198
+ candidates.push({ term, reason: 'promote', queryCount: count });
199
+ }
200
+ }
201
+
202
+ // FAST-PROMOTE: code identifier misses
203
+ for (const term of fastPromoteCandidates) {
204
+ // Skip if already in promote list
205
+ if (candidates.some(c => c.term === term)) continue;
206
+ candidates.push({
207
+ term,
208
+ reason: 'fast-promote',
209
+ queryCount: queryCounts.get(term) || 1,
210
+ });
211
+ }
212
+
213
+ // Sort by query count descending
214
+ candidates.sort((a, b) => b.queryCount - a.queryCount);
215
+ return candidates;
216
+ }
217
+
218
+ /**
219
+ * Find terms that should be demoted from the warmup vocabulary.
220
+ *
221
+ * Rule: Warmup term not queried in `days` days -> DEMOTE
222
+ *
223
+ * @param {Set<string>|Map<string,any>|Iterable<string>} vocabularyTerms
224
+ * @param {Array<{query?: string, timestamp?: string}>} telemetryEntries
225
+ * @param {number} [days=7] - Inactivity threshold in days
226
+ * @returns {Array<{term: string, reason: 'inactive', lastSeen: string|null}>}
227
+ */
228
+ export function getDemotionCandidates(vocabularyTerms, telemetryEntries, days = 7) {
229
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
230
+
231
+ // Build a map of term -> last seen timestamp
232
+ const lastSeen = new Map();
233
+ for (const entry of telemetryEntries) {
234
+ const query = entry.query?.toLowerCase().trim();
235
+ if (!query) continue;
236
+ const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
237
+ const prev = lastSeen.get(query) || 0;
238
+ if (ts > prev) lastSeen.set(query, ts);
239
+ }
240
+
241
+ const candidates = [];
242
+ const terms = vocabularyTerms instanceof Map
243
+ ? vocabularyTerms.keys()
244
+ : vocabularyTerms[Symbol.iterator]
245
+ ? vocabularyTerms
246
+ : [];
247
+
248
+ for (const term of terms) {
249
+ const normalized = typeof term === 'string' ? term.toLowerCase().trim() : term;
250
+ const seen = lastSeen.get(normalized) || 0;
251
+ if (seen < cutoff) {
252
+ candidates.push({
253
+ term: normalized,
254
+ reason: 'inactive',
255
+ lastSeen: seen > 0 ? new Date(seen).toISOString() : null,
256
+ });
257
+ }
258
+ }
259
+
260
+ return candidates;
261
+ }
262
+
263
+ // =============================================================================
264
+ // WORKING SET SIZE ESTIMATION
265
+ // =============================================================================
266
+
267
+ /**
268
+ * Estimate working set size = unique query clusters in a time window.
269
+ *
270
+ * Uses simple normalization (lowercase, trim) for clustering.
271
+ *
272
+ * @param {Array<{query?: string, timestamp?: string}>} telemetryEntries
273
+ * @param {number} [windowMs=604800000] - Time window (default 7 days)
274
+ * @param {number} [warmupSize=0] - Current warmup vocabulary size
275
+ * @param {number} [maxWarmup=2000] - Maximum warmup set size
276
+ * @returns {{ wss: number, warmupSize: number, recommendation: 'well-sized'|'expand'|'converged' }}
277
+ */
278
+ export function estimateWorkingSetSize(
279
+ telemetryEntries,
280
+ windowMs = 7 * 24 * 60 * 60 * 1000,
281
+ warmupSize = 0,
282
+ maxWarmup = 2000,
283
+ ) {
284
+ const MAX_CLUSTER_QUERIES = 1000;
285
+ const cutoff = Date.now() - windowMs;
286
+ const uniqueQueries = new Set();
287
+
288
+ for (const entry of telemetryEntries) {
289
+ const query = entry.query?.toLowerCase().trim();
290
+ if (!query) continue;
291
+ const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
292
+ if (ts >= cutoff) {
293
+ uniqueQueries.add(query);
294
+ }
295
+ }
296
+
297
+ // F12: Basic string-similarity clustering (Jaccard on word sets) to avoid
298
+ // counting near-duplicate queries as separate working set entries.
299
+ const queryList = [...uniqueQueries];
300
+ // Guard against O(n^2) blow-ups on very large telemetry windows.
301
+ // Deterministic truncation keeps behavior stable across runs/tests.
302
+ if (queryList.length > MAX_CLUSTER_QUERIES) {
303
+ queryList.sort();
304
+ queryList.length = MAX_CLUSTER_QUERIES;
305
+ }
306
+ const clusters = [];
307
+ const assigned = new Set();
308
+
309
+ for (let i = 0; i < queryList.length; i++) {
310
+ if (assigned.has(i)) continue;
311
+ const cluster = [queryList[i]];
312
+ assigned.add(i);
313
+ const wordsI = new Set(queryList[i].split(/\s+/));
314
+ for (let j = i + 1; j < queryList.length; j++) {
315
+ if (assigned.has(j)) continue;
316
+ const wordsJ = new Set(queryList[j].split(/\s+/));
317
+ // Jaccard similarity
318
+ let intersection = 0;
319
+ for (const w of wordsI) { if (wordsJ.has(w)) intersection++; }
320
+ const union = wordsI.size + wordsJ.size - intersection;
321
+ if (union > 0 && intersection / union >= 0.6) {
322
+ cluster.push(queryList[j]);
323
+ assigned.add(j);
324
+ }
325
+ }
326
+ clusters.push(cluster);
327
+ }
328
+
329
+ const wss = clusters.length;
330
+ let recommendation;
331
+
332
+ if (warmupSize === 0 && wss === 0) {
333
+ recommendation = 'well-sized';
334
+ } else if (wss > warmupSize && warmupSize < maxWarmup) {
335
+ recommendation = 'expand';
336
+ } else if (wss <= warmupSize) {
337
+ recommendation = 'well-sized';
338
+ } else {
339
+ // wss > warmupSize but warmupSize >= maxWarmup → can't expand further
340
+ recommendation = 'converged';
341
+ }
342
+
343
+ return { wss, warmupSize, recommendation };
344
+ }
345
+
346
+ // =============================================================================
347
+ // STATS REPORT FORMATTER
348
+ // =============================================================================
349
+
350
+ /**
351
+ * Render a horizontal bar chart segment.
352
+ * @param {number} ratio - 0 to 1
353
+ * @param {number} [width=10] - Number of characters
354
+ * @returns {string} e.g. "████████░░"
355
+ */
356
+ function _barChart(ratio, width = 10) {
357
+ const filled = Math.round(ratio * width);
358
+ const empty = width - filled;
359
+ return '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
360
+ }
361
+
362
+ /**
363
+ * Format a human-friendly elapsed-time string.
364
+ * @param {Date|string|number} timestamp
365
+ * @returns {string} e.g. "2 hours ago"
366
+ */
367
+ function _timeAgo(timestamp) {
368
+ if (!timestamp) return 'never';
369
+ const ms = Date.now() - new Date(timestamp).getTime();
370
+ if (ms < 0) return 'just now';
371
+ const seconds = Math.floor(ms / 1000);
372
+ if (seconds < 60) return `${seconds}s ago`;
373
+ const minutes = Math.floor(seconds / 60);
374
+ if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
375
+ const hours = Math.floor(minutes / 60);
376
+ if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
377
+ const days = Math.floor(hours / 24);
378
+ return `${days} day${days !== 1 ? 's' : ''} ago`;
379
+ }
380
+
381
+ /**
382
+ * Depth label from identifier + phrase counts.
383
+ * @param {number} identifiers
384
+ * @param {number} phrases
385
+ * @returns {string}
386
+ */
387
+ function _depthLabel(identifiers, phrases) {
388
+ const total = identifiers + phrases;
389
+ if (total < 200) return 'shallow';
390
+ if (total < 1000) return 'medium';
391
+ return 'deep';
392
+ }
393
+
394
+ /**
395
+ * Format the --stats output report.
396
+ *
397
+ * @param {WarmupMetrics} metrics
398
+ * @param {Array<{id?: string|number, size?: number, name?: string}>} [communities=[]]
399
+ * @param {object} [vocabInfo={}]
400
+ * @param {number} [vocabInfo.identifierCount=0]
401
+ * @param {number} [vocabInfo.phraseCount=0]
402
+ * @param {string} [vocabInfo.depth]
403
+ * @param {string|Date} [vocabInfo.lastWarmup]
404
+ * @param {string} [vocabInfo.warmupType] - 'incremental' | 'full'
405
+ * @param {number} [vocabInfo.newTerms]
406
+ * @param {string} [vocabInfo.graphHash]
407
+ * @param {string} [vocabInfo.nlHash]
408
+ * @param {Array<{term: string, queryCount: number}>} [vocabInfo.topMisses=[]] - promotion candidates
409
+ * @returns {string}
410
+ */
411
+ export function formatStatsReport(metrics, communities = [], vocabInfo = {}) {
412
+ const lines = [];
413
+ const identifiers = vocabInfo.identifierCount || 0;
414
+ const phrases = vocabInfo.phraseCount || 0;
415
+ const depth = vocabInfo.depth || _depthLabel(identifiers, phrases);
416
+ const communityCount = communities.length;
417
+
418
+ lines.push('Vocabulary Warmup Statistics');
419
+ lines.push('\u2501'.repeat(37));
420
+
421
+ // Vocabulary info
422
+ lines.push(
423
+ `Vocabulary size: ${identifiers.toLocaleString()} identifiers + ${phrases.toLocaleString()} community phrases (${depth} depth)`,
424
+ );
425
+
426
+ // Communities
427
+ lines.push(
428
+ `Communities: ${communityCount} detected (Leiden, import-graph-based)`,
429
+ );
430
+
431
+ // Last warmup
432
+ const warmupAgo = _timeAgo(vocabInfo.lastWarmup);
433
+ const warmupDetail = vocabInfo.warmupType
434
+ ? ` (${vocabInfo.warmupType}${vocabInfo.newTerms ? `, ${vocabInfo.newTerms} new terms` : ''})`
435
+ : '';
436
+ lines.push(`Last warmup: ${warmupAgo}${warmupDetail}`);
437
+
438
+ // Hashes
439
+ if (vocabInfo.graphHash) {
440
+ lines.push(`Import graph hash: ${vocabInfo.graphHash} (up to date)`);
441
+ }
442
+ if (vocabInfo.nlHash) {
443
+ lines.push(`NL content hash: ${vocabInfo.nlHash} (up to date)`);
444
+ }
445
+
446
+ lines.push('');
447
+
448
+ // Per-mode cache hit rates
449
+ const totalQueries = ['lexical', 'semantic', 'hybrid'].reduce(
450
+ (sum, m) => sum + metrics[m].hits + metrics[m].misses,
451
+ 0,
452
+ );
453
+ lines.push(`Cache Hit Rates (last ${totalQueries} queries):`);
454
+
455
+ for (const mode of ['lexical', 'semantic', 'hybrid']) {
456
+ const bucket = metrics[mode];
457
+ const total = bucket.hits + bucket.misses;
458
+ const rate = total > 0 ? bucket.hits / total : 0;
459
+ const pct = (rate * 100).toFixed(1);
460
+ const bar = _barChart(rate);
461
+ const label = mode.charAt(0).toUpperCase() + mode.slice(1);
462
+ lines.push(` ${label.padEnd(10)} ${pct.padStart(5)}% ${bar} (${bucket.hits}/${total} queries)`);
463
+ }
464
+
465
+ // Overall
466
+ const overallRate = metrics.overallHitRate();
467
+ const overallPct = (overallRate * 100).toFixed(1);
468
+ const overallBar = _barChart(overallRate);
469
+ const overallHits = ['lexical', 'semantic', 'hybrid'].reduce((s, m) => s + metrics[m].hits, 0);
470
+ lines.push(` ${'Overall'.padEnd(10)} ${overallPct.padStart(5)}% ${overallBar} (${overallHits}/${totalQueries} queries)`);
471
+
472
+ lines.push('');
473
+
474
+ // Latency percentiles
475
+ lines.push('Latency (p50 / p95):');
476
+ for (const mode of ['lexical', 'semantic', 'hybrid']) {
477
+ const p50 = Math.round(metrics.latencyPercentile(mode, 50));
478
+ const p95 = Math.round(metrics.latencyPercentile(mode, 95));
479
+ const label = mode.charAt(0).toUpperCase() + mode.slice(1);
480
+ lines.push(` ${label.padEnd(10)} ${p50}ms / ${p95}ms`);
481
+ }
482
+
483
+ lines.push('');
484
+
485
+ // Top cache misses (promotion candidates)
486
+ const topMisses = vocabInfo.topMisses || [];
487
+ if (topMisses.length > 0) {
488
+ lines.push(`Top ${Math.min(topMisses.length, 5)} Cache Misses (candidates for next warmup):`);
489
+ for (let i = 0; i < Math.min(topMisses.length, 5); i++) {
490
+ const m = topMisses[i];
491
+ lines.push(` ${i + 1}. "${m.term}" (queried ${m.queryCount}x, not in vocab)`);
492
+ }
493
+ }
494
+
495
+ // F5: Community breakdown with phrases and topEntities
496
+ if (communities.length > 0) {
497
+ lines.push('');
498
+ lines.push('Communities (Leiden):');
499
+ for (let i = 0; i < Math.min(communities.length, 10); i++) {
500
+ const c = communities[i];
501
+ const name = c.name || `community-${c.id ?? i}`;
502
+ const fileCount = c.fileIds?.length || c.size || c.entityCount || 0;
503
+ const phraseList = (c.phrases || []).slice(0, 3).map(p => `"${p}"`).join(', ');
504
+ const detail = phraseList ? ` \u2014 phrases: ${phraseList}` : '';
505
+ lines.push(` ${name}: ${fileCount} files${detail}`);
506
+ }
507
+ }
508
+
509
+ return lines.join('\n');
510
+ }
511
+
512
+ // =============================================================================
513
+ // TELEMETRY ENTRY PARSING HELPERS
514
+ // =============================================================================
515
+
516
+ /**
517
+ * Parse raw JSONL telemetry file content into entry objects.
518
+ * @param {string} content - Raw JSONL text
519
+ * @param {number} [lastN=0] - Limit to last N entries (0 = all)
520
+ * @returns {Array<object>}
521
+ */
522
+ export function parseTelemetryEntries(content, lastN = 0) {
523
+ const lines = content.split('\n').filter(Boolean);
524
+ const subset = lastN > 0 ? lines.slice(-lastN) : lines;
525
+ const entries = [];
526
+ for (const line of subset) {
527
+ try {
528
+ entries.push(JSON.parse(line));
529
+ } catch { /* skip malformed */ }
530
+ }
531
+ return entries;
532
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Minimal server-start entry point — avoids the circular import in sweet-search.js.
3
+ // Used by the Rust CLI's auto_start_server() to spawn the background server.
4
+
5
+ import { startServer } from './search/search-server.js';
6
+ await startServer();