@lotargo/memory_plugin 1.5.2 → 1.6.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.
@@ -1,565 +1,564 @@
1
- import readline from "readline";
2
- import { getModelStorageInfo } from "../ml/model_manager.js";
3
-
4
- export const EMBEDDING_PRESETS = [
5
- "Xenova/multilingual-e5-small",
6
- "Xenova/multilingual-e5-base",
7
- "Xenova/multilingual-e5-large",
8
- "Xenova/bge-small-en-v1.5",
9
- "Xenova/bge-base-en-v1.5",
10
- "Xenova/bge-large-en-v1.5",
11
- "Xenova/bge-m3",
12
- "Xenova/all-MiniLM-L6-v2",
13
- "Xenova/all-mpnet-base-v2",
14
- "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
15
- "Xenova/gte-small",
16
- "Xenova/gte-large",
17
- ];
18
-
19
- export const RERANKER_PRESETS = [
20
- "none",
21
- "Xenova/bge-reranker-base",
22
- "Xenova/bge-reranker-large",
23
- "Xenova/ms-marco-MiniLM-L-6-v2",
24
- "Xenova/ms-marco-TinyBERT-L-2-v2",
25
- ];
26
-
27
- export const PANEL_WIDTH = 58;
28
-
29
- export async function downloadModelWithProgress(modelName, type = "embedding") {
30
- console.clear();
31
- console.log(`\n MODEL DOWNLOAD & PRELOAD`);
32
- console.log(` \x1b[90m${type.toUpperCase()}: ${modelName.substring(0, 36)}\x1b[0m\n`);
33
-
34
- const spinFrames = ["|", "/", "-", "\\"];
35
- let spinIdx = 0;
36
- let lastProgress = 0;
37
-
38
- const handleProgress = (p) => {
39
- if (!p) return;
40
- spinIdx = (spinIdx + 1) % spinFrames.length;
41
- const spin = spinFrames[spinIdx];
42
-
43
- const filename = p.file ? p.file.split("/").pop() : (p.name || "weights");
44
- const pct = typeof p.progress === "number" ? Math.round(p.progress) : lastProgress;
45
- if (typeof p.progress === "number") lastProgress = pct;
46
-
47
- const loadedMB = p.loaded ? (p.loaded / (1024 * 1024)).toFixed(1) : "0.0";
48
- const totalMB = p.total ? (p.total / (1024 * 1024)).toFixed(1) : "0.0";
49
-
50
- const barLen = 18;
51
- const filled = Math.round((pct / 100) * barLen);
52
- const bar = "=".repeat(filled).padEnd(barLen);
53
-
54
- let statusMsg = "";
55
- if (p.status === "initiate") statusMsg = "Initiating...";
56
- else if (p.status === "download" || p.status === "progress") statusMsg = `${pct}% (${loadedMB}/${totalMB} MB)`;
57
- else if (p.status === "done") statusMsg = "Verifying...";
58
- else if (p.status === "ready") statusMsg = "Ready!";
59
- else statusMsg = `${pct}%`;
60
-
61
- const fileLabel = filename.length > 18 ? filename.substring(0, 15) + "..." : filename;
62
- process.stdout.write(`\r ${spin} [${bar}] ${fileLabel.padEnd(18)} ${statusMsg.padEnd(22)}`);
63
- };
64
-
65
- try {
66
- const { preloadModel } = await import("../ml/model_manager.js");
67
- await preloadModel(modelName, type, handleProgress);
68
- process.stdout.write("\r" + " ".repeat(72) + "\r");
69
- console.log(` \x1b[32m[OK] Model "${modelName}" ready!\x1b[0m\n`);
70
- } catch (err) {
71
- process.stdout.write("\r" + " ".repeat(72) + "\r");
72
- console.error(` \x1b[31m[ERROR] Download for "${modelName}" failed: ${err.message}\x1b[0m\n`);
73
- }
74
- }
75
-
76
- export function printHeaderPanel(title, stats) {
77
- console.log(`\n \x1b[1m\x1b[37m${title}\x1b[0m`);
78
- console.log(` \x1b[90mStorage: ${stats.docCount} Docs | ${stats.chunkCount} Chunks | ${stats.factCount} Facts\x1b[0m`);
79
- }
80
-
81
- export function printQuickInfoBox(infoText) {
82
- console.log(` \x1b[90mINFO: ${infoText}\x1b[0m\n`);
83
- }
84
-
85
- export function padVisible(str, width, align = "left") {
86
- const visibleLength = String(str).replace(/\x1b\[[0-9;]*m/g, "").length;
87
- const padding = " ".repeat(Math.max(0, width - visibleLength));
88
- return align === "right" ? padding + str : str + padding;
89
- }
90
-
91
- export function formatRankColor(rankStr) {
92
- if (rankStr === "#1") return "\x1b[1m\x1b[32m#1\x1b[0m";
93
- if (rankStr.startsWith("#")) return `\x1b[33m${rankStr}\x1b[0m`;
94
- return "\x1b[90mMISSED\x1b[0m";
95
- }
96
-
97
- export function wrapText(text, width) {
98
- if (!text || text.length <= width) return [text || ""];
99
- const words = text.split(/\s+/);
100
- const lines = [];
101
- let currentLine = "";
102
-
103
- for (const word of words) {
104
- if (word.length > width) {
105
- if (currentLine) {
106
- lines.push(currentLine);
107
- currentLine = "";
108
- }
109
- let rem = word;
110
- while (rem.length > width) {
111
- lines.push(rem.substring(0, width));
112
- rem = rem.substring(width);
113
- }
114
- currentLine = rem;
115
- } else if ((currentLine + (currentLine ? " " : "") + word).length <= width) {
116
- currentLine += (currentLine ? " " : "") + word;
117
- } else {
118
- lines.push(currentLine);
119
- currentLine = word;
120
- }
121
- }
122
- if (currentLine) lines.push(currentLine);
123
- return lines;
124
- }
125
-
126
- export function renderPerQueryBreakdownTable(breakdown) {
127
- if (!breakdown || breakdown.length === 0) return;
128
-
129
- console.log(`\n PER-QUERY RESULTS BREAKDOWN (${breakdown.length} Queries)\n`);
130
-
131
- breakdown.forEach((item, itemIdx) => {
132
- const isMatch = item.topHit && (item.topHit === item.target || (item.expectedDocIds && item.expectedDocIds.includes(item.topHit)));
133
-
134
- console.log(` ${item.id}. ${item.query}`);
135
- console.log(` Target: \x1b[36m${item.target}\x1b[0m`);
136
- console.log(` BM25: ${formatRankColor(item.bm25Rank)} Vector: ${formatRankColor(item.vectorRank)} RRF: ${formatRankColor(item.rrfRank)} RSF: ${formatRankColor(item.rsfRank)}`);
137
- console.log(` Top Hit: ${isMatch ? "\x1b[32m" : "\x1b[33m"}${item.topHit || "NONE"}\x1b[0m`);
138
-
139
- if (itemIdx < breakdown.length - 1) {
140
- console.log("");
141
- }
142
- });
143
-
144
- console.log("");
145
- }
146
-
147
- export function renderBenchmarkResultsTable(results) {
148
- const isSmoke = results && results.mode === "smoke";
149
- const title = isSmoke ? "SMOKE BENCHMARK RESULTS" : "SEARCH QUALITY BENCHMARK RESULTS";
150
- const nQueries = results && results.bm25 ? results.bm25.n : 0;
151
- const subtitle = isSmoke
152
- ? `Smoke: ${nQueries} queries (stats skipped, fast iteration)`
153
- : `Evaluated over ${nQueries} challenging cross-lingual queries`;
154
-
155
- console.log(`\n \x1b[1m\x1b[37m${title}\x1b[0m`);
156
- console.log(` \x1b[90m${subtitle}\x1b[0m\n`);
157
-
158
- if (results && results.breakdown) {
159
- renderPerQueryBreakdownTable(results.breakdown);
160
- }
161
-
162
- console.log(`\n METRIC COMPARISON BY SEARCH STRATEGY\n`);
163
- console.log(` Strategy MRR@5 Recall@5 NDCG@5`);
164
- console.log(` ${"─".repeat(50)}`);
165
-
166
- const strategies = [
167
- { name: "BM25 Search Only", data: results.bm25, key: "bm25" },
168
- { name: "Dense ONNX Vector", data: results.vector, key: "vector" },
169
- { name: "Hybrid RRF (Rank)", data: results.hybridRrf, key: "hybrid_rrf" },
170
- { name: "Hybrid RSF (Score)", data: results.hybridRsf, key: "hybrid_rsf" },
171
- ];
172
-
173
- const getMrr = (d) => (d ? (d.mrr ?? d.mrrAtK ?? 0) : 0);
174
- const getRecall = (d) => (d ? (d.recall ?? d.recallAtK ?? 0) : 0);
175
- const getNdcg = (d) => (d ? (d.ndcg ?? d.ndcgAtK ?? 0) : 0);
176
-
177
- strategies.forEach((s) => {
178
- const nameStr = s.name.padEnd(20);
179
- const mrrStr = getMrr(s.data).toFixed(4).padEnd(10);
180
- const recallPct = (getRecall(s.data) * 100).toFixed(1) + "%";
181
- const recallStr = recallPct.padEnd(13);
182
- const ndcgStr = getNdcg(s.data).toFixed(4);
183
-
184
- const isBest = results.winner && s.key === results.winner;
185
- const color = isBest ? "\x1b[1m\x1b[36m" : "\x1b[37m";
186
-
187
- console.log(` ${color}${nameStr}${mrrStr}${recallStr}${ndcgStr}\x1b[0m`);
188
- });
189
-
190
- console.log("");
191
-
192
- if (results && results.winner) {
193
- const winnerLabel =
194
- results.winner === "hybrid_rsf" ? "RSF"
195
- : results.winner === "hybrid_rrf" ? "RRF"
196
- : results.winner === "vector" ? "Vector"
197
- : results.winner === "bm25" ? "BM25"
198
- : results.winner;
199
- const p = results.pairedTests && results.pairedTests.rrfVsRsf;
200
- const sigNote = p
201
- ? (p.p < 0.05 ? ` (RRF vs RSF p=${p.p}, significant)` : ` (RRF vs RSF p=${p.p}, NOT significant at N=${p.n})`)
202
- : (results.mode === "smoke" ? " (smoke: stats skipped)" : "");
203
- console.log(` \x1b[90m Winner by MRR: \x1b[1m\x1b[36m${winnerLabel}\x1b[0m\x1b[90m${sigNote}\x1b[0m\n`);
204
- }
205
- }
206
-
207
- export function selectCategoryMenu({ title, stats, categories, initialIndex = 0 }) {
208
- return new Promise((resolve) => {
209
- let activeIndex = Math.min(Math.max(0, initialIndex), categories.length - 1);
210
-
211
- if (process.stdin.isTTY) {
212
- process.stdin.setRawMode(true);
213
- }
214
- process.stdin.resume();
215
-
216
- function render() {
217
- console.clear();
218
- console.log(`\n ${title}\n`);
219
- console.log(` Storage: ${stats.docCount} Docs | ${stats.chunkCount} Chunks | ${stats.factCount} Facts\n`);
220
- console.log(" Controls: ↑ / ↓ - Navigate [ENTER] - Select [BACKSPACE] - Exit\n");
221
-
222
- categories.forEach((cat, idx) => {
223
- const isSelected = idx === activeIndex;
224
- const pointer = isSelected ? " > " : " ";
225
- const label = isSelected ? `\x1b[1m\x1b[36m${cat.label}\x1b[0m` : cat.label;
226
- const hint = cat.hint ? ` \x1b[90m(${cat.hint})\x1b[0m` : "";
227
- console.log(`${pointer}${label}${hint}`);
228
- });
229
-
230
- console.log("");
231
-
232
- const activeCat = categories[activeIndex];
233
- if (activeCat && activeCat.info) {
234
- console.log(` \x1b[90m${activeCat.info}\x1b[0m\n`);
235
- }
236
- }
237
-
238
- render();
239
-
240
- function onKeypress(str, key) {
241
- if (!key) return;
242
- if (key.ctrl && key.name === "c") {
243
- cleanup();
244
- process.exit(0);
245
- }
246
- if (key.name === "up") {
247
- activeIndex = (activeIndex - 1 + categories.length) % categories.length;
248
- render();
249
- } else if (key.name === "down") {
250
- activeIndex = (activeIndex + 1) % categories.length;
251
- render();
252
- } else if (key.name === "return") {
253
- cleanup();
254
- resolve({ action: "select", index: activeIndex, value: categories[activeIndex].value });
255
- } else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
256
- cleanup();
257
- resolve({ action: "back" });
258
- }
259
- }
260
-
261
- function cleanup() {
262
- process.stdin.removeListener("keypress", onKeypress);
263
- if (process.stdin.isTTY) {
264
- process.stdin.setRawMode(false);
265
- }
266
- process.stdin.pause();
267
- }
268
-
269
- process.stdin.on("keypress", onKeypress);
270
- });
271
- }
272
-
273
- export function selectSimpleMenu({ title, subtitle = "", items, initialIndex = 0 }) {
274
- return new Promise((resolve) => {
275
- let index = Math.min(Math.max(0, initialIndex), items.length - 1);
276
-
277
- if (process.stdin.isTTY) {
278
- process.stdin.setRawMode(true);
279
- }
280
- process.stdin.resume();
281
-
282
- function render() {
283
- console.clear();
284
- console.log(`\n ${title}`);
285
- if (subtitle) {
286
- console.log(` \x1b[90m${subtitle}\x1b[0m`);
287
- }
288
- console.log("\n Controls: ↑ / ↓ - Navigate [ENTER] - Select [BACKSPACE] - Back\n");
289
-
290
- items.forEach((item, idx) => {
291
- const isSelected = idx === index;
292
- const pointer = isSelected ? " > " : " ";
293
- const label = isSelected ? `\x1b[1m\x1b[36m${item.label}\x1b[0m` : item.label;
294
- const badge = item.badge ? ` \x1b[33m[${item.badge}]\x1b[0m` : "";
295
- const hint = item.hint ? ` \x1b[90m(${item.hint})\x1b[0m` : "";
296
- console.log(`${pointer}${label}${badge}${hint}`);
297
- });
298
-
299
- console.log("");
300
-
301
- const activeItem = items[index];
302
- if (activeItem && activeItem.info) {
303
- console.log(` \x1b[90m${activeItem.info}\x1b[0m\n`);
304
- }
305
- }
306
-
307
- render();
308
-
309
- function onKeypress(str, key) {
310
- if (!key) return;
311
- if (key.ctrl && key.name === "c") {
312
- cleanup();
313
- process.exit(0);
314
- }
315
- if (key.name === "up") {
316
- index = (index - 1 + items.length) % items.length;
317
- render();
318
- } else if (key.name === "down") {
319
- index = (index + 1) % items.length;
320
- render();
321
- } else if (key.name === "return") {
322
- cleanup();
323
- resolve({ action: "select", index, value: items[index].value });
324
- } else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
325
- cleanup();
326
- resolve({ action: "back" });
327
- }
328
- }
329
-
330
- function cleanup() {
331
- process.stdin.removeListener("keypress", onKeypress);
332
- if (process.stdin.isTTY) {
333
- process.stdin.setRawMode(false);
334
- }
335
- process.stdin.pause();
336
- }
337
-
338
- process.stdin.on("keypress", onKeypress);
339
- });
340
- }
341
-
342
- export function adjustAlphaMenu(initialAlpha) {
343
- return new Promise((resolve) => {
344
- let alpha = initialAlpha;
345
-
346
- if (process.stdin.isTTY) {
347
- process.stdin.setRawMode(true);
348
- }
349
- process.stdin.resume();
350
-
351
- function render() {
352
- console.clear();
353
- console.log(`\n RSF ALPHA WEIGHT BALANCER`);
354
- console.log(` \x1b[90mAdjust Vector Similarity vs BM25 Score Weight\x1b[0m`);
355
- console.log("\n Controls: ← / → or ↑ / ↓ - Adjust (5% step) [ENTER] - Save [BACKSPACE] - Cancel\n");
356
-
357
- const semPct = Math.round(alpha * 100);
358
- const lexPct = 100 - semPct;
359
-
360
- const totalBlocks = 20;
361
- const semBlocks = Math.round(alpha * totalBlocks);
362
- const lexBlocks = totalBlocks - semBlocks;
363
-
364
- const bar = "━".repeat(semBlocks) + "─".repeat(lexBlocks);
365
-
366
- console.log(` Balance: \x1b[36m${semPct}% Semantic (Vector)\x1b[0m / \x1b[33m${lexPct}% Lexical (BM25)\x1b[0m`);
367
- console.log(` [ \x1b[36m${bar}\x1b[0m ] Alpha: \x1b[1m\x1b[32m${alpha.toFixed(2)}\x1b[0m\n`);
368
-
369
- if (alpha === 0.5) {
370
- console.log(" [*] \x1b[32mMode: 50 / 50 Balanced Hybrid Fusion (Recommended)\x1b[0m\n");
371
- } else if (alpha > 0.5) {
372
- console.log(` [*] Mode: Semantic Vector Priority (${semPct}%)\n`);
373
- } else {
374
- console.log(` [*] Mode: Exact Keyword BM25 Priority (${lexPct}%)\n`);
375
- }
376
-
377
- printQuickInfoBox(`RSF Formula: Score = ${alpha.toFixed(2)} * NormVector + ${(1 - alpha).toFixed(2)} * NormBM25`);
378
- }
379
-
380
- render();
381
-
382
- function onKeypress(str, key) {
383
- if (!key) return;
384
- if (key.ctrl && key.name === "c") {
385
- cleanup();
386
- process.exit(0);
387
- }
388
- if (key.name === "left" || key.name === "down") {
389
- alpha = Math.max(0.0, Math.round((alpha - 0.05) * 100) / 100);
390
- render();
391
- } else if (key.name === "right" || key.name === "up") {
392
- alpha = Math.min(1.0, Math.round((alpha + 0.05) * 100) / 100);
393
- render();
394
- } else if (key.name === "return") {
395
- cleanup();
396
- resolve({ action: "save", value: alpha });
397
- } else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
398
- cleanup();
399
- resolve({ action: "cancel" });
400
- }
401
- }
402
-
403
- function cleanup() {
404
- process.stdin.removeListener("keypress", onKeypress);
405
- if (process.stdin.isTTY) {
406
- process.stdin.setRawMode(false);
407
- }
408
- process.stdin.pause();
409
- }
410
-
411
- process.stdin.on("keypress", onKeypress);
412
- });
413
- }
414
-
415
- export function readTextInput(promptText, defaultValue = "") {
416
- return new Promise((resolve) => {
417
- let text = defaultValue;
418
-
419
- if (process.stdin.isTTY) {
420
- process.stdin.setRawMode(true);
421
- }
422
- process.stdin.resume();
423
-
424
- function render() {
425
- console.clear();
426
- console.log(`\n INPUT: ${promptText.toUpperCase()}`);
427
- console.log(" Controls: Type text [ENTER] - Submit [BACKSPACE] - Delete / Cancel\n");
428
- console.log(` > \x1b[36m${text}\x1b[0m_\n`);
429
- }
430
-
431
- render();
432
-
433
- function onKeypress(str, key) {
434
- if (!key) return;
435
- if (key.ctrl && key.name === "c") {
436
- cleanup();
437
- process.exit(0);
438
- }
439
- if (key.name === "return") {
440
- cleanup();
441
- resolve({ action: "submit", value: text.trim() });
442
- } else if (key.name === "backspace" || key.name === "delete") {
443
- if (text.length > 0) {
444
- text = text.slice(0, -1);
445
- render();
446
- } else {
447
- cleanup();
448
- resolve({ action: "cancel" });
449
- }
450
- } else if (key.name === "escape") {
451
- cleanup();
452
- resolve({ action: "cancel" });
453
- } else if (str && str.length === 1 && str.charCodeAt(0) >= 32) {
454
- text += str;
455
- render();
456
- }
457
- }
458
-
459
- function cleanup() {
460
- process.stdin.removeListener("keypress", onKeypress);
461
- if (process.stdin.isTTY) {
462
- process.stdin.setRawMode(false);
463
- }
464
- process.stdin.pause();
465
- }
466
-
467
- process.stdin.on("keypress", onKeypress);
468
- });
469
- }
470
-
471
- export function waitForEnter() {
472
- return new Promise((resolve) => {
473
- console.log("\n \x1b[90mPress [ENTER] or [BACKSPACE] to return to menu...\x1b[0m");
474
- if (process.stdin.isTTY) {
475
- process.stdin.setRawMode(true);
476
- }
477
- process.stdin.resume();
478
-
479
- function onKeypress(str, key) {
480
- if (!key) return;
481
- if (key.ctrl && key.name === "c") {
482
- cleanup();
483
- process.exit(0);
484
- }
485
- if (key.name === "return" || key.name === "backspace" || key.name === "escape" || key.name === "delete" || key.name === "space") {
486
- cleanup();
487
- resolve();
488
- }
489
- }
490
-
491
- function cleanup() {
492
- process.stdin.removeListener("keypress", onKeypress);
493
- if (process.stdin.isTTY) {
494
- process.stdin.setRawMode(false);
495
- }
496
- process.stdin.pause();
497
- }
498
-
499
- process.stdin.on("keypress", onKeypress);
500
- });
501
- }
502
-
503
- export function promptText(question) {
504
- return new Promise((resolve) => {
505
- let input = "";
506
- let cursorPos = 0;
507
-
508
- console.log(`\n ${question}\n > `);
509
-
510
- if (process.stdin.isTTY) {
511
- process.stdin.setRawMode(true);
512
- }
513
- process.stdin.resume();
514
-
515
- function render() {
516
- process.stdout.write(`\r > ${input}\x1b[K`);
517
- process.stdout.write(`\r > ${input.substring(0, cursorPos)}`);
518
- }
519
-
520
- function onKeypress(str, key) {
521
- if (!key) return;
522
- if (key.ctrl && key.name === "c") {
523
- cleanup();
524
- process.exit(0);
525
- }
526
- if (key.name === "return") {
527
- cleanup();
528
- resolve(input.trim());
529
- } else if (key.name === "backspace") {
530
- if (cursorPos > 0) {
531
- input = input.substring(0, cursorPos - 1) + input.substring(cursorPos);
532
- cursorPos--;
533
- render();
534
- }
535
- } else if (key.name === "delete") {
536
- if (cursorPos < input.length) {
537
- input = input.substring(0, cursorPos) + input.substring(cursorPos + 1);
538
- render();
539
- }
540
- } else if (key.name === "left") {
541
- if (cursorPos > 0) { cursorPos--; render(); }
542
- } else if (key.name === "right") {
543
- if (cursorPos < input.length) { cursorPos++; render(); }
544
- } else if (key.name === "home") {
545
- cursorPos = 0; render();
546
- } else if (key.name === "end") {
547
- cursorPos = input.length; render();
548
- } else if (str && !key.ctrl && !key.meta) {
549
- input = input.substring(0, cursorPos) + str + input.substring(cursorPos);
550
- cursorPos += str.length;
551
- render();
552
- }
553
- }
554
-
555
- function cleanup() {
556
- process.stdin.removeListener("keypress", onKeypress);
557
- if (process.stdin.isTTY) {
558
- process.stdin.setRawMode(false);
559
- }
560
- process.stdin.pause();
561
- }
562
-
563
- process.stdin.on("keypress", onKeypress);
564
- });
565
- }
1
+ import readline from "readline";
2
+
3
+ export const EMBEDDING_PRESETS = [
4
+ "Xenova/multilingual-e5-small",
5
+ "Xenova/multilingual-e5-base",
6
+ "Xenova/multilingual-e5-large",
7
+ "Xenova/bge-small-en-v1.5",
8
+ "Xenova/bge-base-en-v1.5",
9
+ "Xenova/bge-large-en-v1.5",
10
+ "Xenova/bge-m3",
11
+ "Xenova/all-MiniLM-L6-v2",
12
+ "Xenova/all-mpnet-base-v2",
13
+ "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
14
+ "Xenova/gte-small",
15
+ "Xenova/gte-large",
16
+ ];
17
+
18
+ export const RERANKER_PRESETS = [
19
+ "none",
20
+ "Xenova/bge-reranker-base",
21
+ "Xenova/bge-reranker-large",
22
+ "Xenova/ms-marco-MiniLM-L-6-v2",
23
+ "Xenova/ms-marco-TinyBERT-L-2-v2",
24
+ ];
25
+
26
+ export const PANEL_WIDTH = 58;
27
+
28
+ export async function downloadModelWithProgress(modelName, type = "embedding") {
29
+ console.clear();
30
+ console.log(`\n MODEL DOWNLOAD & PRELOAD`);
31
+ console.log(` \x1b[90m${type.toUpperCase()}: ${modelName.substring(0, 36)}\x1b[0m\n`);
32
+
33
+ const spinFrames = ["|", "/", "-", "\\"];
34
+ let spinIdx = 0;
35
+ let lastProgress = 0;
36
+
37
+ const handleProgress = (p) => {
38
+ if (!p) return;
39
+ spinIdx = (spinIdx + 1) % spinFrames.length;
40
+ const spin = spinFrames[spinIdx];
41
+
42
+ const filename = p.file ? p.file.split("/").pop() : (p.name || "weights");
43
+ const pct = typeof p.progress === "number" ? Math.round(p.progress) : lastProgress;
44
+ if (typeof p.progress === "number") lastProgress = pct;
45
+
46
+ const loadedMB = p.loaded ? (p.loaded / (1024 * 1024)).toFixed(1) : "0.0";
47
+ const totalMB = p.total ? (p.total / (1024 * 1024)).toFixed(1) : "0.0";
48
+
49
+ const barLen = 18;
50
+ const filled = Math.round((pct / 100) * barLen);
51
+ const bar = "=".repeat(filled).padEnd(barLen);
52
+
53
+ let statusMsg = "";
54
+ if (p.status === "initiate") statusMsg = "Initiating...";
55
+ else if (p.status === "download" || p.status === "progress") statusMsg = `${pct}% (${loadedMB}/${totalMB} MB)`;
56
+ else if (p.status === "done") statusMsg = "Verifying...";
57
+ else if (p.status === "ready") statusMsg = "Ready!";
58
+ else statusMsg = `${pct}%`;
59
+
60
+ const fileLabel = filename.length > 18 ? filename.substring(0, 15) + "..." : filename;
61
+ process.stdout.write(`\r ${spin} [${bar}] ${fileLabel.padEnd(18)} ${statusMsg.padEnd(22)}`);
62
+ };
63
+
64
+ try {
65
+ const { preloadModel } = await import("../ml/model_manager.js");
66
+ await preloadModel(modelName, type, handleProgress);
67
+ process.stdout.write("\r" + " ".repeat(72) + "\r");
68
+ console.log(` \x1b[32m[OK] Model "${modelName}" ready!\x1b[0m\n`);
69
+ } catch (err) {
70
+ process.stdout.write("\r" + " ".repeat(72) + "\r");
71
+ console.error(` \x1b[31m[ERROR] Download for "${modelName}" failed: ${err.message}\x1b[0m\n`);
72
+ }
73
+ }
74
+
75
+ export function printHeaderPanel(title, stats) {
76
+ console.log(`\n \x1b[1m\x1b[37m${title}\x1b[0m`);
77
+ console.log(` \x1b[90mStorage: ${stats.docCount} Docs | ${stats.chunkCount} Chunks | ${stats.factCount} Facts\x1b[0m`);
78
+ }
79
+
80
+ export function printQuickInfoBox(infoText) {
81
+ console.log(` \x1b[90mINFO: ${infoText}\x1b[0m\n`);
82
+ }
83
+
84
+ export function padVisible(str, width, align = "left") {
85
+ const visibleLength = String(str).replace(/\x1b\[[0-9;]*m/g, "").length;
86
+ const padding = " ".repeat(Math.max(0, width - visibleLength));
87
+ return align === "right" ? padding + str : str + padding;
88
+ }
89
+
90
+ export function formatRankColor(rankStr) {
91
+ if (rankStr === "#1") return "\x1b[1m\x1b[32m#1\x1b[0m";
92
+ if (rankStr.startsWith("#")) return `\x1b[33m${rankStr}\x1b[0m`;
93
+ return "\x1b[90mMISSED\x1b[0m";
94
+ }
95
+
96
+ export function wrapText(text, width) {
97
+ if (!text || text.length <= width) return [text || ""];
98
+ const words = text.split(/\s+/);
99
+ const lines = [];
100
+ let currentLine = "";
101
+
102
+ for (const word of words) {
103
+ if (word.length > width) {
104
+ if (currentLine) {
105
+ lines.push(currentLine);
106
+ currentLine = "";
107
+ }
108
+ let rem = word;
109
+ while (rem.length > width) {
110
+ lines.push(rem.substring(0, width));
111
+ rem = rem.substring(width);
112
+ }
113
+ currentLine = rem;
114
+ } else if ((currentLine + (currentLine ? " " : "") + word).length <= width) {
115
+ currentLine += (currentLine ? " " : "") + word;
116
+ } else {
117
+ lines.push(currentLine);
118
+ currentLine = word;
119
+ }
120
+ }
121
+ if (currentLine) lines.push(currentLine);
122
+ return lines;
123
+ }
124
+
125
+ export function renderPerQueryBreakdownTable(breakdown) {
126
+ if (!breakdown || breakdown.length === 0) return;
127
+
128
+ console.log(`\n PER-QUERY RESULTS BREAKDOWN (${breakdown.length} Queries)\n`);
129
+
130
+ breakdown.forEach((item, itemIdx) => {
131
+ const isMatch = item.topHit && (item.topHit === item.target || (item.expectedDocIds && item.expectedDocIds.includes(item.topHit)));
132
+
133
+ console.log(` ${item.id}. ${item.query}`);
134
+ console.log(` Target: \x1b[36m${item.target}\x1b[0m`);
135
+ console.log(` BM25: ${formatRankColor(item.bm25Rank)} Vector: ${formatRankColor(item.vectorRank)} RRF: ${formatRankColor(item.rrfRank)} RSF: ${formatRankColor(item.rsfRank)}`);
136
+ console.log(` Top Hit: ${isMatch ? "\x1b[32m" : "\x1b[33m"}${item.topHit || "NONE"}\x1b[0m`);
137
+
138
+ if (itemIdx < breakdown.length - 1) {
139
+ console.log("");
140
+ }
141
+ });
142
+
143
+ console.log("");
144
+ }
145
+
146
+ export function renderBenchmarkResultsTable(results) {
147
+ const isSmoke = results && results.mode === "smoke";
148
+ const title = isSmoke ? "SMOKE BENCHMARK RESULTS" : "SEARCH QUALITY BENCHMARK RESULTS";
149
+ const nQueries = results && results.bm25 ? results.bm25.n : 0;
150
+ const subtitle = isSmoke
151
+ ? `Smoke: ${nQueries} queries (stats skipped, fast iteration)`
152
+ : `Evaluated over ${nQueries} challenging cross-lingual queries`;
153
+
154
+ console.log(`\n \x1b[1m\x1b[37m${title}\x1b[0m`);
155
+ console.log(` \x1b[90m${subtitle}\x1b[0m\n`);
156
+
157
+ if (results && results.breakdown) {
158
+ renderPerQueryBreakdownTable(results.breakdown);
159
+ }
160
+
161
+ console.log(`\n METRIC COMPARISON BY SEARCH STRATEGY\n`);
162
+ console.log(` Strategy MRR@5 Recall@5 NDCG@5`);
163
+ console.log(` ${"─".repeat(50)}`);
164
+
165
+ const strategies = [
166
+ { name: "BM25 Search Only", data: results.bm25, key: "bm25" },
167
+ { name: "Dense ONNX Vector", data: results.vector, key: "vector" },
168
+ { name: "Hybrid RRF (Rank)", data: results.hybridRrf, key: "hybrid_rrf" },
169
+ { name: "Hybrid RSF (Score)", data: results.hybridRsf, key: "hybrid_rsf" },
170
+ ];
171
+
172
+ const getMrr = (d) => (d ? (d.mrr ?? d.mrrAtK ?? 0) : 0);
173
+ const getRecall = (d) => (d ? (d.recall ?? d.recallAtK ?? 0) : 0);
174
+ const getNdcg = (d) => (d ? (d.ndcg ?? d.ndcgAtK ?? 0) : 0);
175
+
176
+ strategies.forEach((s) => {
177
+ const nameStr = s.name.padEnd(20);
178
+ const mrrStr = getMrr(s.data).toFixed(4).padEnd(10);
179
+ const recallPct = (getRecall(s.data) * 100).toFixed(1) + "%";
180
+ const recallStr = recallPct.padEnd(13);
181
+ const ndcgStr = getNdcg(s.data).toFixed(4);
182
+
183
+ const isBest = results.winner && s.key === results.winner;
184
+ const color = isBest ? "\x1b[1m\x1b[36m" : "\x1b[37m";
185
+
186
+ console.log(` ${color}${nameStr}${mrrStr}${recallStr}${ndcgStr}\x1b[0m`);
187
+ });
188
+
189
+ console.log("");
190
+
191
+ if (results && results.winner) {
192
+ const winnerLabel =
193
+ results.winner === "hybrid_rsf" ? "RSF"
194
+ : results.winner === "hybrid_rrf" ? "RRF"
195
+ : results.winner === "vector" ? "Vector"
196
+ : results.winner === "bm25" ? "BM25"
197
+ : results.winner;
198
+ const p = results.pairedTests && results.pairedTests.rrfVsRsf;
199
+ const sigNote = p
200
+ ? (p.p < 0.05 ? ` (RRF vs RSF p=${p.p}, significant)` : ` (RRF vs RSF p=${p.p}, NOT significant at N=${p.n})`)
201
+ : (results.mode === "smoke" ? " (smoke: stats skipped)" : "");
202
+ console.log(` \x1b[90m Winner by MRR: \x1b[1m\x1b[36m${winnerLabel}\x1b[0m\x1b[90m${sigNote}\x1b[0m\n`);
203
+ }
204
+ }
205
+
206
+ export function selectCategoryMenu({ title, stats, categories, initialIndex = 0 }) {
207
+ return new Promise((resolve) => {
208
+ let activeIndex = Math.min(Math.max(0, initialIndex), categories.length - 1);
209
+
210
+ if (process.stdin.isTTY) {
211
+ process.stdin.setRawMode(true);
212
+ }
213
+ process.stdin.resume();
214
+
215
+ function render() {
216
+ console.clear();
217
+ console.log(`\n ${title}\n`);
218
+ console.log(` Storage: ${stats.docCount} Docs | ${stats.chunkCount} Chunks | ${stats.factCount} Facts\n`);
219
+ console.log(" Controls: / - Navigate [ENTER] - Select [BACKSPACE] - Exit\n");
220
+
221
+ categories.forEach((cat, idx) => {
222
+ const isSelected = idx === activeIndex;
223
+ const pointer = isSelected ? " > " : " ";
224
+ const label = isSelected ? `\x1b[1m\x1b[36m${cat.label}\x1b[0m` : cat.label;
225
+ const hint = cat.hint ? ` \x1b[90m(${cat.hint})\x1b[0m` : "";
226
+ console.log(`${pointer}${label}${hint}`);
227
+ });
228
+
229
+ console.log("");
230
+
231
+ const activeCat = categories[activeIndex];
232
+ if (activeCat && activeCat.info) {
233
+ console.log(` \x1b[90m${activeCat.info}\x1b[0m\n`);
234
+ }
235
+ }
236
+
237
+ render();
238
+
239
+ function onKeypress(str, key) {
240
+ if (!key) return;
241
+ if (key.ctrl && key.name === "c") {
242
+ cleanup();
243
+ process.exit(0);
244
+ }
245
+ if (key.name === "up") {
246
+ activeIndex = (activeIndex - 1 + categories.length) % categories.length;
247
+ render();
248
+ } else if (key.name === "down") {
249
+ activeIndex = (activeIndex + 1) % categories.length;
250
+ render();
251
+ } else if (key.name === "return") {
252
+ cleanup();
253
+ resolve({ action: "select", index: activeIndex, value: categories[activeIndex].value });
254
+ } else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
255
+ cleanup();
256
+ resolve({ action: "back" });
257
+ }
258
+ }
259
+
260
+ function cleanup() {
261
+ process.stdin.removeListener("keypress", onKeypress);
262
+ if (process.stdin.isTTY) {
263
+ process.stdin.setRawMode(false);
264
+ }
265
+ process.stdin.pause();
266
+ }
267
+
268
+ process.stdin.on("keypress", onKeypress);
269
+ });
270
+ }
271
+
272
+ export function selectSimpleMenu({ title, subtitle = "", items, initialIndex = 0 }) {
273
+ return new Promise((resolve) => {
274
+ let index = Math.min(Math.max(0, initialIndex), items.length - 1);
275
+
276
+ if (process.stdin.isTTY) {
277
+ process.stdin.setRawMode(true);
278
+ }
279
+ process.stdin.resume();
280
+
281
+ function render() {
282
+ console.clear();
283
+ console.log(`\n ${title}`);
284
+ if (subtitle) {
285
+ console.log(` \x1b[90m${subtitle}\x1b[0m`);
286
+ }
287
+ console.log("\n Controls: ↑ / ↓ - Navigate [ENTER] - Select [BACKSPACE] - Back\n");
288
+
289
+ items.forEach((item, idx) => {
290
+ const isSelected = idx === index;
291
+ const pointer = isSelected ? " > " : " ";
292
+ const label = isSelected ? `\x1b[1m\x1b[36m${item.label}\x1b[0m` : item.label;
293
+ const badge = item.badge ? ` \x1b[33m[${item.badge}]\x1b[0m` : "";
294
+ const hint = item.hint ? ` \x1b[90m(${item.hint})\x1b[0m` : "";
295
+ console.log(`${pointer}${label}${badge}${hint}`);
296
+ });
297
+
298
+ console.log("");
299
+
300
+ const activeItem = items[index];
301
+ if (activeItem && activeItem.info) {
302
+ console.log(` \x1b[90m${activeItem.info}\x1b[0m\n`);
303
+ }
304
+ }
305
+
306
+ render();
307
+
308
+ function onKeypress(str, key) {
309
+ if (!key) return;
310
+ if (key.ctrl && key.name === "c") {
311
+ cleanup();
312
+ process.exit(0);
313
+ }
314
+ if (key.name === "up") {
315
+ index = (index - 1 + items.length) % items.length;
316
+ render();
317
+ } else if (key.name === "down") {
318
+ index = (index + 1) % items.length;
319
+ render();
320
+ } else if (key.name === "return") {
321
+ cleanup();
322
+ resolve({ action: "select", index, value: items[index].value });
323
+ } else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
324
+ cleanup();
325
+ resolve({ action: "back" });
326
+ }
327
+ }
328
+
329
+ function cleanup() {
330
+ process.stdin.removeListener("keypress", onKeypress);
331
+ if (process.stdin.isTTY) {
332
+ process.stdin.setRawMode(false);
333
+ }
334
+ process.stdin.pause();
335
+ }
336
+
337
+ process.stdin.on("keypress", onKeypress);
338
+ });
339
+ }
340
+
341
+ export function adjustAlphaMenu(initialAlpha) {
342
+ return new Promise((resolve) => {
343
+ let alpha = initialAlpha;
344
+
345
+ if (process.stdin.isTTY) {
346
+ process.stdin.setRawMode(true);
347
+ }
348
+ process.stdin.resume();
349
+
350
+ function render() {
351
+ console.clear();
352
+ console.log(`\n RSF ALPHA WEIGHT BALANCER`);
353
+ console.log(` \x1b[90mAdjust Vector Similarity vs BM25 Score Weight\x1b[0m`);
354
+ console.log("\n Controls: / or / ↓ - Adjust (5% step) [ENTER] - Save [BACKSPACE] - Cancel\n");
355
+
356
+ const semPct = Math.round(alpha * 100);
357
+ const lexPct = 100 - semPct;
358
+
359
+ const totalBlocks = 20;
360
+ const semBlocks = Math.round(alpha * totalBlocks);
361
+ const lexBlocks = totalBlocks - semBlocks;
362
+
363
+ const bar = "━".repeat(semBlocks) + "─".repeat(lexBlocks);
364
+
365
+ console.log(` Balance: \x1b[36m${semPct}% Semantic (Vector)\x1b[0m / \x1b[33m${lexPct}% Lexical (BM25)\x1b[0m`);
366
+ console.log(` [ \x1b[36m${bar}\x1b[0m ] Alpha: \x1b[1m\x1b[32m${alpha.toFixed(2)}\x1b[0m\n`);
367
+
368
+ if (alpha === 0.5) {
369
+ console.log(" [*] \x1b[32mMode: 50 / 50 Balanced Hybrid Fusion (Recommended)\x1b[0m\n");
370
+ } else if (alpha > 0.5) {
371
+ console.log(` [*] Mode: Semantic Vector Priority (${semPct}%)\n`);
372
+ } else {
373
+ console.log(` [*] Mode: Exact Keyword BM25 Priority (${lexPct}%)\n`);
374
+ }
375
+
376
+ printQuickInfoBox(`RSF Formula: Score = ${alpha.toFixed(2)} * NormVector + ${(1 - alpha).toFixed(2)} * NormBM25`);
377
+ }
378
+
379
+ render();
380
+
381
+ function onKeypress(str, key) {
382
+ if (!key) return;
383
+ if (key.ctrl && key.name === "c") {
384
+ cleanup();
385
+ process.exit(0);
386
+ }
387
+ if (key.name === "left" || key.name === "down") {
388
+ alpha = Math.max(0.0, Math.round((alpha - 0.05) * 100) / 100);
389
+ render();
390
+ } else if (key.name === "right" || key.name === "up") {
391
+ alpha = Math.min(1.0, Math.round((alpha + 0.05) * 100) / 100);
392
+ render();
393
+ } else if (key.name === "return") {
394
+ cleanup();
395
+ resolve({ action: "save", value: alpha });
396
+ } else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
397
+ cleanup();
398
+ resolve({ action: "cancel" });
399
+ }
400
+ }
401
+
402
+ function cleanup() {
403
+ process.stdin.removeListener("keypress", onKeypress);
404
+ if (process.stdin.isTTY) {
405
+ process.stdin.setRawMode(false);
406
+ }
407
+ process.stdin.pause();
408
+ }
409
+
410
+ process.stdin.on("keypress", onKeypress);
411
+ });
412
+ }
413
+
414
+ export function readTextInput(promptText, defaultValue = "") {
415
+ return new Promise((resolve) => {
416
+ let text = defaultValue;
417
+
418
+ if (process.stdin.isTTY) {
419
+ process.stdin.setRawMode(true);
420
+ }
421
+ process.stdin.resume();
422
+
423
+ function render() {
424
+ console.clear();
425
+ console.log(`\n INPUT: ${promptText.toUpperCase()}`);
426
+ console.log(" Controls: Type text [ENTER] - Submit [BACKSPACE] - Delete / Cancel\n");
427
+ console.log(` > \x1b[36m${text}\x1b[0m_\n`);
428
+ }
429
+
430
+ render();
431
+
432
+ function onKeypress(str, key) {
433
+ if (!key) return;
434
+ if (key.ctrl && key.name === "c") {
435
+ cleanup();
436
+ process.exit(0);
437
+ }
438
+ if (key.name === "return") {
439
+ cleanup();
440
+ resolve({ action: "submit", value: text.trim() });
441
+ } else if (key.name === "backspace" || key.name === "delete") {
442
+ if (text.length > 0) {
443
+ text = text.slice(0, -1);
444
+ render();
445
+ } else {
446
+ cleanup();
447
+ resolve({ action: "cancel" });
448
+ }
449
+ } else if (key.name === "escape") {
450
+ cleanup();
451
+ resolve({ action: "cancel" });
452
+ } else if (str && str.length === 1 && str.charCodeAt(0) >= 32) {
453
+ text += str;
454
+ render();
455
+ }
456
+ }
457
+
458
+ function cleanup() {
459
+ process.stdin.removeListener("keypress", onKeypress);
460
+ if (process.stdin.isTTY) {
461
+ process.stdin.setRawMode(false);
462
+ }
463
+ process.stdin.pause();
464
+ }
465
+
466
+ process.stdin.on("keypress", onKeypress);
467
+ });
468
+ }
469
+
470
+ export function waitForEnter() {
471
+ return new Promise((resolve) => {
472
+ console.log("\n \x1b[90mPress [ENTER] or [BACKSPACE] to return to menu...\x1b[0m");
473
+ if (process.stdin.isTTY) {
474
+ process.stdin.setRawMode(true);
475
+ }
476
+ process.stdin.resume();
477
+
478
+ function onKeypress(str, key) {
479
+ if (!key) return;
480
+ if (key.ctrl && key.name === "c") {
481
+ cleanup();
482
+ process.exit(0);
483
+ }
484
+ if (key.name === "return" || key.name === "backspace" || key.name === "escape" || key.name === "delete" || key.name === "space") {
485
+ cleanup();
486
+ resolve();
487
+ }
488
+ }
489
+
490
+ function cleanup() {
491
+ process.stdin.removeListener("keypress", onKeypress);
492
+ if (process.stdin.isTTY) {
493
+ process.stdin.setRawMode(false);
494
+ }
495
+ process.stdin.pause();
496
+ }
497
+
498
+ process.stdin.on("keypress", onKeypress);
499
+ });
500
+ }
501
+
502
+ export function promptText(question) {
503
+ return new Promise((resolve) => {
504
+ let input = "";
505
+ let cursorPos = 0;
506
+
507
+ console.log(`\n ${question}\n > `);
508
+
509
+ if (process.stdin.isTTY) {
510
+ process.stdin.setRawMode(true);
511
+ }
512
+ process.stdin.resume();
513
+
514
+ function render() {
515
+ process.stdout.write(`\r > ${input}\x1b[K`);
516
+ process.stdout.write(`\r > ${input.substring(0, cursorPos)}`);
517
+ }
518
+
519
+ function onKeypress(str, key) {
520
+ if (!key) return;
521
+ if (key.ctrl && key.name === "c") {
522
+ cleanup();
523
+ process.exit(0);
524
+ }
525
+ if (key.name === "return") {
526
+ cleanup();
527
+ resolve(input.trim());
528
+ } else if (key.name === "backspace") {
529
+ if (cursorPos > 0) {
530
+ input = input.substring(0, cursorPos - 1) + input.substring(cursorPos);
531
+ cursorPos--;
532
+ render();
533
+ }
534
+ } else if (key.name === "delete") {
535
+ if (cursorPos < input.length) {
536
+ input = input.substring(0, cursorPos) + input.substring(cursorPos + 1);
537
+ render();
538
+ }
539
+ } else if (key.name === "left") {
540
+ if (cursorPos > 0) { cursorPos--; render(); }
541
+ } else if (key.name === "right") {
542
+ if (cursorPos < input.length) { cursorPos++; render(); }
543
+ } else if (key.name === "home") {
544
+ cursorPos = 0; render();
545
+ } else if (key.name === "end") {
546
+ cursorPos = input.length; render();
547
+ } else if (str && !key.ctrl && !key.meta) {
548
+ input = input.substring(0, cursorPos) + str + input.substring(cursorPos);
549
+ cursorPos += str.length;
550
+ render();
551
+ }
552
+ }
553
+
554
+ function cleanup() {
555
+ process.stdin.removeListener("keypress", onKeypress);
556
+ if (process.stdin.isTTY) {
557
+ process.stdin.setRawMode(false);
558
+ }
559
+ process.stdin.pause();
560
+ }
561
+
562
+ process.stdin.on("keypress", onKeypress);
563
+ });
564
+ }