sweet-search 2.5.4 → 2.5.6

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.
Binary file
@@ -0,0 +1,10 @@
1
+ {
2
+ "format": "grid-webp",
3
+ "file": "banner-frames.webp",
4
+ "gridCols": 8,
5
+ "cellW": 1200,
6
+ "cellH": 400,
7
+ "count": 96,
8
+ "fps": 12,
9
+ "frameMs": 83
10
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Animated terminal banner — universal renderer.
3
+ *
4
+ * Decodes the pre-baked lossless WebP sprite-sheet (assets/banner/) once with sharp,
5
+ * then animates it via the best path the terminal supports:
6
+ *
7
+ * Kitty graphics (Ghostty, kitty, WezTerm, Konsole) — pixel-perfect
8
+ * iTerm2 inline (iTerm.app) — pixel-perfect
9
+ * Sixel (Windows Terminal, Konsole, xterm, foot…) — crisp raster
10
+ * half-blocks (everything else: Apple Terminal, VS Code, SSH, CI-tty) — truecolor ▀
11
+ *
12
+ * Zero prerequisites for users: the only dependency is sharp (already a package dep,
13
+ * auto-installed per-platform by npm). Chrome is used ONLY at bake time (scripts/bake-banner.mjs).
14
+ *
15
+ * Safe by construction: renders only to an interactive TTY, never throws, honours
16
+ * NO_BANNER / SWEET_SEARCH_NO_BANNER / CI, and leaves the final frame in place when done.
17
+ */
18
+ import { createRequire } from 'node:module';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { readFileSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+ import { encodeSixelFrame, buildPalette, makeMapper, sampleColors } from './sixel.js';
23
+
24
+ const require = createRequire(import.meta.url);
25
+
26
+ const ASSET_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'banner');
27
+ const ESC = '\x1b', BEL = '\x07', ST = ESC + '\\';
28
+ const SYNC_ON = `${ESC}[?2026h`, SYNC_OFF = `${ESC}[?2026l`;
29
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
30
+
31
+ // ---------------- gating ----------------
32
+ function shouldRender(stream, env) {
33
+ if (env.SWEET_SEARCH_NO_BANNER || env.NO_BANNER) return false;
34
+ if (env.CI) return false;
35
+ if (!stream || !stream.isTTY) return false;
36
+ if ((env.TERM || '') === 'dumb') return false;
37
+ return true;
38
+ }
39
+
40
+ // ---------------- terminal capability query (DA1 + text-area size) ----------------
41
+ function queryTerminal(stream, env, timeout = 140) {
42
+ return new Promise((resolve) => {
43
+ const stdin = process.stdin;
44
+ if (env.SS_NO_QUERY || !stdin || !stdin.isTTY) { resolve({}); return; }
45
+ let buf = '', done = false, t;
46
+ const finish = () => {
47
+ if (done) return; done = true;
48
+ try { stdin.removeListener('data', onData); stdin.setRawMode(false); stdin.pause(); } catch { /* noop */ }
49
+ clearTimeout(t);
50
+ const da1 = (buf.match(/\x1b\[\?([0-9;]+)c/) || [])[1] || '';
51
+ const size = buf.match(/\x1b\[4;(\d+);(\d+)t/);
52
+ resolve({ sixel: da1.split(';').includes('4'), areaH: size ? +size[1] : 0, areaW: size ? +size[2] : 0 });
53
+ };
54
+ const onData = (d) => { buf += d.toString('latin1'); if (/\x1b\[\?[0-9;]+c/.test(buf) && (/\x1b\[4;\d+;\d+t/.test(buf) || buf.length > 64)) finish(); };
55
+ try { stdin.setRawMode(true); stdin.resume(); stdin.on('data', onData); } catch { resolve({}); return; }
56
+ stream.write(`${ESC}[14t${ESC}[c`); // text-area pixel size, then primary device attributes
57
+ t = setTimeout(finish, timeout);
58
+ });
59
+ }
60
+
61
+ function detectProto(env, caps) {
62
+ if (env.SS_PROTO) return env.SS_PROTO;
63
+ const tp = env.TERM_PROGRAM || '';
64
+ if (env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR || tp === 'ghostty' || tp === 'WezTerm' || /kitty/i.test(env.TERM || '')) return 'kitty';
65
+ if (tp === 'iTerm.app') return 'iterm';
66
+ if (caps.sixel || env.WT_SESSION || /foot|contour|mlterm/i.test(env.TERM || '')) return 'sixel';
67
+ return 'blocks';
68
+ }
69
+
70
+ // ---------------- color (truecolor default; 256 fallback) ----------------
71
+ function rgb256(r, g, b) {
72
+ if (Math.abs(r - g) < 10 && Math.abs(g - b) < 10) { if (r < 8) return 16; if (r > 248) return 231; return 232 + Math.round(((r - 8) / 247) * 24); }
73
+ return 16 + 36 * Math.round(r / 255 * 5) + 6 * Math.round(g / 255 * 5) + Math.round(b / 255 * 5);
74
+ }
75
+ const GHALF = [' ', '▀', '▄', '█'];
76
+ function sextantChar(v) {
77
+ if (v === 0) return ' '; if (v === 63) return '█'; if (v === 21) return '▌'; if (v === 42) return '▐';
78
+ return String.fromCodePoint(0x1FB00 + (v - 1 - (v > 21 ? 1 : 0) - (v > 42 ? 1 : 0)));
79
+ }
80
+
81
+ // ---------------- main ----------------
82
+ export async function showBanner(opts = {}) {
83
+ const env = opts.env || process.env;
84
+ const out = opts.stream || process.stdout;
85
+ let onSig = null;
86
+ try {
87
+ if (!shouldRender(out, env)) return false;
88
+
89
+ const sharp = require('sharp');
90
+ const man = JSON.parse(readFileSync(join(ASSET_DIR, 'banner-manifest.json'), 'utf8'));
91
+ const { gridCols: GC, cellW: CW, cellH: CH, count: N, frameMs } = man;
92
+ const maxMs = opts.maxMs ?? Number(env.SS_BANNER_MS || 2600);
93
+ const color = opts.color || env.SS_COLOR || 'truecolor';
94
+ const cellMode = opts.cells || env.SS_CELLS || 'half';
95
+
96
+ const caps = opts.query === false ? {} : await queryTerminal(out, env);
97
+ const proto = opts.proto || detectProto(env, caps);
98
+ const capCols = Number(opts.cols || env.SS_COLS || 120);
99
+ const cols = Math.max(20, Math.min((out.columns || 80) - 2, capCols));
100
+
101
+ // decode the sprite sheet once
102
+ const { data: big, info } = await sharp(join(ASSET_DIR, man.file)).raw().toBuffer({ resolveWithObject: true });
103
+ const BW = info.width, ch = info.channels;
104
+ const frameRaw = (i) => {
105
+ const gx = (i % GC) * CW, gy = Math.floor(i / GC) * CH, f = Buffer.allocUnsafe(CW * CH * 4);
106
+ for (let y = 0; y < CH; y++) { const so = ((gy + y) * BW + gx) * ch; big.copy(f, y * CW * 4, so, so + CW * 4); }
107
+ return f;
108
+ };
109
+
110
+ const fg = color === '256' ? (r, g, b) => `${ESC}[38;5;${rgb256(r, g, b)}m` : (r, g, b) => `${ESC}[38;2;${r};${g};${b}m`;
111
+ const bg = color === '256' ? (r, g, b) => `${ESC}[48;5;${rgb256(r, g, b)}m` : (r, g, b) => `${ESC}[48;2;${r};${g};${b}m`;
112
+ const RESET = `${ESC}[0m`;
113
+
114
+ // ---------------- per-protocol lazy frame builder (build only frames we show) ----------------
115
+ let renderRows = Math.max(4, Math.round(cols / 6));
116
+ const cache = new Map();
117
+ let buildOne;
118
+
119
+ if (proto === 'kitty' || proto === 'iterm') {
120
+ buildOne = async (i) => (await sharp(frameRaw(i), { raw: { width: CW, height: CH, channels: 4 } }).png().toBuffer()).toString('base64');
121
+ } else if (proto === 'sixel') {
122
+ const cellWpx = caps.areaW && out.columns ? caps.areaW / out.columns : 8;
123
+ const Wp = Math.min(1000, Math.max(360, Math.round(cols * cellWpx)));
124
+ const Hp = Math.round(Wp / 3);
125
+ const cellHpx = caps.areaH && out.rows ? caps.areaH / out.rows : 16;
126
+ renderRows = Math.max(4, Math.round(Hp / cellHpx));
127
+ const sixelPx = async (i) => (await sharp(frameRaw(i), { raw: { width: CW, height: CH, channels: 4 } }).resize(Wp, Hp, { fit: 'fill' }).raw().toBuffer());
128
+ // global palette from 2 representative frames (colours are stable across the loop)
129
+ const samp = [...sampleColors(await sixelPx(0), 9), ...sampleColors(await sixelPx((N / 2) | 0), 9)];
130
+ const palette = buildPalette(samp, 255), mapper = makeMapper(palette);
131
+ buildOne = async (i) => encodeSixelFrame(await sixelPx(i), Wp, Hp, palette, mapper);
132
+ } else {
133
+ const [sc, sr] = cellMode === 'sextant' ? [2, 3] : [1, 2];
134
+ const R = Math.max(3, Math.round((cols * sc) * CH / CW / sr)); renderRows = R;
135
+ const W = cols * sc, H = R * sr;
136
+ const glyph = cellMode === 'sextant' ? sextantChar : (v) => GHALF[v];
137
+ buildOne = async (i) => {
138
+ const { data: px } = await sharp(frameRaw(i), { raw: { width: CW, height: CH, channels: 4 } }).resize(W, H, { fit: 'fill', kernel: 'lanczos3' }).sharpen({ sigma: 0.7 }).raw().toBuffer({ resolveWithObject: true });
139
+ let s = '';
140
+ for (let cy = 0; cy < R; cy++) {
141
+ for (let cx = 0; cx < cols; cx++) {
142
+ const sub = []; let anyT = false, anyO = false;
143
+ for (let y = 0; y < sr; y++) for (let x = 0; x < sc; x++) { const o = ((cy * sr + y) * W + (cx * sc + x)) * 4, a = px[o + 3]; sub.push({ r: px[o], g: px[o + 1], b: px[o + 2], a }); if (a < 128) anyT = true; else anyO = true; }
144
+ if (!anyO) { s += RESET + ' '; continue; }
145
+ const op = sub.filter(p => p.a >= 128), lum = p => 0.299 * p.r + 0.587 * p.g + 0.114 * p.b;
146
+ let lo = op[0], hi = op[0];
147
+ for (const p of op) { if (lum(p) < lum(lo)) lo = p; if (lum(p) > lum(hi)) hi = p; }
148
+ const dist = (p, q) => (p.r - q.r) ** 2 + (p.g - q.g) ** 2 + (p.b - q.b) ** 2;
149
+ let fr = 0, fG = 0, fb = 0, fn = 0, br = 0, bG = 0, bb = 0, bn = 0, bits = 0;
150
+ for (let k = 0; k < sub.length; k++) { const p = sub[k]; if (p.a < 128) continue; if (dist(p, hi) <= dist(p, lo)) { bits |= (1 << k); fr += p.r; fG += p.g; fb += p.b; fn++; } else { br += p.r; bG += p.g; bb += p.b; bn++; } }
151
+ const fc = fn ? [Math.round(fr / fn), Math.round(fG / fn), Math.round(fb / fn)] : null;
152
+ const bc = bn ? [Math.round(br / bn), Math.round(bG / bn), Math.round(bb / bn)] : null;
153
+ const full = (1 << sub.length) - 1;
154
+ if (anyT) s += bits === 0 ? RESET + ' ' : RESET + fg(...(fc || bc)) + glyph(bits);
155
+ else if (bits === 0) s += bg(...bc) + ' ';
156
+ else if (bits === full) s += fg(...fc) + glyph(bits);
157
+ else s += bg(...bc) + fg(...fc) + glyph(bits);
158
+ }
159
+ s += RESET; if (cy < R - 1) s += '\r\n';
160
+ }
161
+ return s;
162
+ };
163
+ }
164
+ const getFrame = async (i) => { let v = cache.get(i); if (v === undefined) { v = await buildOne(i); cache.set(i, v); } return v; };
165
+
166
+ // ---------------- emit / animate (bounded) ----------------
167
+ const CHUNK = 4096;
168
+ const kittyFrame = (b64, id) => {
169
+ if (b64.length <= CHUNK) { out.write(`${ESC}_Gf=100,a=T,q=2,c=${cols},C=1,i=${id};${b64}${ST}`); return; }
170
+ for (let i = 0; i < b64.length; i += CHUNK) { const piece = b64.slice(i, i + CHUNK), more = i + CHUNK < b64.length ? 1 : 0; out.write(i === 0 ? `${ESC}_Gf=100,a=T,q=2,c=${cols},C=1,i=${id},m=1;${piece}${ST}` : `${ESC}_Gm=${more};${piece}${ST}`); }
171
+ };
172
+ const kittyDel = (id) => out.write(`${ESC}_Ga=d,d=i,i=${id},q=2${ST}`);
173
+
174
+ await getFrame(0); // build first frame before clearing space (hide latency)
175
+ out.write(ESC + '[?25l');
176
+ // Restore the cursor if interrupted mid-animation (else Ctrl-C leaves it hidden).
177
+ onSig = () => { try { out.write(`${ESC}[0m${ESC}[?25h`); } catch { /* noop */ } process.exit(130); };
178
+ process.once('SIGINT', onSig);
179
+ if (proto === 'blocks' || proto === 'sixel') { for (let r = 0; r < renderRows; r++) out.write('\n'); out.write(`${ESC}[${renderRows}A`); }
180
+ else out.write('\n');
181
+ out.write(ESC + '7');
182
+
183
+ let prevId = 0, flip = 1;
184
+ const start = Date.now();
185
+ let i = 0;
186
+ while (Date.now() - start < maxMs) {
187
+ const frame = await getFrame(i);
188
+ out.write(SYNC_ON + ESC + '8');
189
+ if (proto === 'kitty') { const id = flip; kittyFrame(frame, id); if (prevId) kittyDel(prevId); prevId = id; flip = flip === 1 ? 2 : 1; }
190
+ else if (proto === 'iterm') out.write(`${ESC}]1337;File=inline=1;width=${cols};height=${renderRows};preserveAspectRatio=1:${frame}${BEL}`);
191
+ else out.write(frame);
192
+ out.write(SYNC_OFF);
193
+ await sleep(frameMs);
194
+ i = (i + 1) % N;
195
+ }
196
+ out.write(ESC + '8'); // settle: leave final frame, move below
197
+ out.write('\n'.repeat(renderRows + 1));
198
+ out.write(RESET + ESC + '[?25h');
199
+ if (onSig) process.removeListener('SIGINT', onSig); // hand Ctrl-C back to the caller
200
+ return true;
201
+ } catch (err) {
202
+ if (onSig) process.removeListener('SIGINT', onSig);
203
+ if (env.SS_BANNER_DEBUG) process.stderr.write(`[banner] ${err && err.stack || err}\n`);
204
+ try { out.write(`${ESC}[0m${ESC}[?25h`); } catch { /* noop */ }
205
+ return false;
206
+ }
207
+ }
208
+
209
+ export default showBanner;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Minimal, dependency-free Sixel encoder for the terminal banner.
3
+ *
4
+ * Sixel renders crisp raster images on Windows Terminal (>=1.22), Konsole, xterm,
5
+ * foot, Contour and much of Linux — terminals that lack the Kitty graphics protocol.
6
+ *
7
+ * We build ONE shared palette across all frames (pixel art reuses colours, so a global
8
+ * palette is both faster and lets frames share colour definitions) and emit each frame
9
+ * with run-length-encoded sixel bands. Fully-transparent pixels are left unset so the
10
+ * terminal background shows through (rounded banner corners).
11
+ */
12
+
13
+ const TRANSPARENT = -1;
14
+
15
+ function boxStats(box) {
16
+ let rMin = 255, rMax = 0, gMin = 255, gMax = 0, bMin = 255, bMax = 0;
17
+ for (const [r, g, b] of box) {
18
+ if (r < rMin) rMin = r; if (r > rMax) rMax = r;
19
+ if (g < gMin) gMin = g; if (g > gMax) gMax = g;
20
+ if (b < bMin) bMin = b; if (b > bMax) bMax = b;
21
+ }
22
+ const dr = rMax - rMin, dg = gMax - gMin, db = bMax - bMin;
23
+ const range = Math.max(dr, dg, db);
24
+ const channel = range === dr ? 0 : range === dg ? 1 : 2;
25
+ return { range, channel };
26
+ }
27
+ function avgColor(box) {
28
+ let r = 0, g = 0, b = 0;
29
+ for (const c of box) { r += c[0]; g += c[1]; b += c[2]; }
30
+ const n = box.length || 1;
31
+ return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
32
+ }
33
+
34
+ /** Median-cut quantisation over a sample of opaque colours. Returns up to maxColors [r,g,b]. */
35
+ export function buildPalette(samples, maxColors = 255) {
36
+ if (samples.length === 0) return [[0, 0, 0]];
37
+ let boxes = [samples];
38
+ while (boxes.length < maxColors) {
39
+ let bi = -1, best = -1;
40
+ for (let i = 0; i < boxes.length; i++) {
41
+ if (boxes[i].length < 2) continue;
42
+ const { range } = boxStats(boxes[i]);
43
+ if (range > best) { best = range; bi = i; }
44
+ }
45
+ if (bi < 0) break;
46
+ const box = boxes[bi];
47
+ const { channel } = boxStats(box);
48
+ box.sort((a, b) => a[channel] - b[channel]);
49
+ const mid = box.length >> 1;
50
+ boxes.splice(bi, 1, box.slice(0, mid), box.slice(mid));
51
+ }
52
+ return boxes.map(avgColor);
53
+ }
54
+
55
+ /** Fast rgb->palette-index mapper with a per-colour cache (pixel art has few unique colours). */
56
+ export function makeMapper(palette) {
57
+ const cache = new Map();
58
+ return (r, g, b) => {
59
+ const key = (r << 16) | (g << 8) | b;
60
+ const hit = cache.get(key);
61
+ if (hit !== undefined) return hit;
62
+ let best = 0, bestD = Infinity;
63
+ for (let i = 0; i < palette.length; i++) {
64
+ const p = palette[i];
65
+ const d = (p[0] - r) ** 2 + (p[1] - g) ** 2 + (p[2] - b) ** 2;
66
+ if (d < bestD) { bestD = d; best = i; }
67
+ }
68
+ cache.set(key, best);
69
+ return best;
70
+ };
71
+ }
72
+
73
+ /** Collect a colour sample from a raw RGBA frame (every `step`-th opaque pixel). */
74
+ export function sampleColors(rgba, step = 7) {
75
+ const out = [];
76
+ for (let i = 0; i < rgba.length; i += 4 * step) {
77
+ if (rgba[i + 3] >= 128) out.push([rgba[i], rgba[i + 1], rgba[i + 2]]);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Encode one RGBA frame to a Sixel string using a shared palette + mapper. */
83
+ export function encodeSixelFrame(rgba, width, height, palette, mapper) {
84
+ // index buffer: palette index per pixel, or TRANSPARENT
85
+ const idx = new Int16Array(width * height);
86
+ for (let p = 0, o = 0; p < idx.length; p++, o += 4) {
87
+ idx[p] = rgba[o + 3] < 128 ? TRANSPARENT : mapper(rgba[o], rgba[o + 1], rgba[o + 2]);
88
+ }
89
+
90
+ let s = '\x1bP0;1;0q'; // DCS; P2=1 => 0-bit pixels stay transparent
91
+ s += `"1;1;${width};${height}`; // raster attributes (1:1 aspect)
92
+ for (let i = 0; i < palette.length; i++) {
93
+ const [r, g, b] = palette[i];
94
+ s += `#${i};2;${Math.round(r / 255 * 100)};${Math.round(g / 255 * 100)};${Math.round(b / 255 * 100)}`;
95
+ }
96
+
97
+ const used = [];
98
+ for (let by = 0; by < height; by += 6) {
99
+ const bandH = Math.min(6, height - by);
100
+ // which palette indices appear in this band
101
+ used.length = 0;
102
+ const seen = new Set();
103
+ for (let y = by; y < by + bandH; y++) {
104
+ const row = y * width;
105
+ for (let x = 0; x < width; x++) { const c = idx[row + x]; if (c !== TRANSPARENT && !seen.has(c)) { seen.add(c); used.push(c); } }
106
+ }
107
+ for (let u = 0; u < used.length; u++) {
108
+ const ci = used[u];
109
+ if (u > 0) s += '$'; // return to band start, overlay next colour
110
+ s += `#${ci}`;
111
+ let runChar = -1, runLen = 0, line = '';
112
+ for (let x = 0; x < width; x++) {
113
+ let bits = 0;
114
+ for (let k = 0; k < bandH; k++) if (idx[(by + k) * width + x] === ci) bits |= (1 << k);
115
+ const ch = 63 + bits;
116
+ if (ch === runChar) { runLen++; }
117
+ else { if (runLen) line += runLen > 3 ? `!${runLen}${String.fromCharCode(runChar)}` : String.fromCharCode(runChar).repeat(runLen); runChar = ch; runLen = 1; }
118
+ }
119
+ if (runLen) line += runLen > 3 ? `!${runLen}${String.fromCharCode(runChar)}` : String.fromCharCode(runChar).repeat(runLen);
120
+ s += line;
121
+ }
122
+ s += '-'; // graphics newline
123
+ }
124
+ s += '\x1b\\'; // ST
125
+ return s;
126
+ }
@@ -121,6 +121,11 @@ async function main() {
121
121
  setVerboseMode(true);
122
122
  }
123
123
 
124
+ // Animated banner (best-effort; interactive TTY only, never in CI / quiet / stdin-fed runs).
125
+ if (!quiet && !help && !filesFromStdin && process.stdout.isTTY && !process.env.CI && !process.env.NO_BANNER && !process.env.SWEET_SEARCH_NO_BANNER) {
126
+ try { const { showBanner } = await import('../banner/render-banner.js'); await showBanner(); } catch { /* non-fatal */ }
127
+ }
128
+
124
129
  // Apply late interaction model overrides before any model code runs.
125
130
  // Precedence: --no-late-interaction > --late-interaction-model=… > env
126
131
  // var (already honoured by LATE_INTERACTION_CONFIG.model at module load) >
@@ -650,7 +650,7 @@ export async function buildHNSWIndex(dbPath, dryRun = false) {
650
650
  });
651
651
  fsyncFile(sidecarPath);
652
652
  fsyncDirectory(path.dirname(checkpointPath));
653
- log(` checkpoint: ${added}/${totalVectors} vectors`, 'dim');
653
+ if (process.env.DEBUG) log(` checkpoint: ${added}/${totalVectors} vectors`, 'dim');
654
654
  }
655
655
  lastCheckpointTime = Date.now();
656
656
  vectorsSinceCheckpoint = 0;
@@ -109,32 +109,64 @@ export function isVerboseMode() {
109
109
  return verboseMode;
110
110
  }
111
111
 
112
+ // ---------------------------------------------------------------------------
113
+ // Progress rendering — an in-place "sticky" bar that animates as a phase runs.
114
+ //
115
+ // On a TTY (verbose or not) the bar redraws on a single line via carriage return
116
+ // + erase-to-EOL, with smooth 1/8-block fill. While a bar is active, log() pins it:
117
+ // it clears the bar, prints the log line above, then redraws the bar below — so
118
+ // interleaved diagnostics (e.g. the HNSW "checkpoint:" line) never split the bar.
119
+ // Non-TTY (pipes / CI) falls back to throttled newlines so nothing is swallowed.
120
+ // ---------------------------------------------------------------------------
121
+ const BAR_WIDTH = 30;
122
+ const LABEL_COL = 17; // pad "Label:" to this width so every bar's [ ] aligns
123
+ const SUB_BLOCKS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; // eighth-block partial fills
124
+ const CLEAR_EOL = '\x1b[K';
125
+ let activeBar = null; // last-rendered bar string while a phase is in progress (TTY only)
126
+ let lastLoggedPercent = {};
127
+
128
+ function renderBar(current, total, label) {
129
+ const ratio = total > 0 ? Math.max(0, Math.min(1, current / total)) : 1;
130
+ const eighths = Math.round(ratio * BAR_WIDTH * 8);
131
+ const full = Math.floor(eighths / 8);
132
+ const partial = SUB_BLOCKS[eighths % 8];
133
+ const bar = '█'.repeat(full) + partial;
134
+ const empty = '░'.repeat(Math.max(0, BAR_WIDTH - full - (partial ? 1 : 0)));
135
+ const head = `${label}:`.padEnd(LABEL_COL); // right border aligns across phases
136
+ const pct = (ratio * 100).toFixed(1).padStart(5);
137
+ return `${colors.cyan}${head}[${bar}${empty}] ${pct}% (${current}/${total})${colors.reset}`;
138
+ }
139
+
112
140
  export function log(message, color = 'reset') {
113
141
  if (quietMode) return;
114
- console.log(`${colors[color]}${message}${colors.reset}`);
142
+ const line = `${colors[color]}${message}${colors.reset}`;
143
+ if (activeBar && process.stdout.isTTY) {
144
+ // Pin the bar: clear it, print the log line above, redraw the bar below.
145
+ process.stdout.write(`\r${CLEAR_EOL}${line}\n${activeBar}${CLEAR_EOL}`);
146
+ } else {
147
+ console.log(line);
148
+ }
115
149
  }
116
150
 
117
- let lastLoggedPercent = {};
118
-
119
151
  export function logProgress(current, total, label) {
120
152
  if (quietMode) return;
121
- const percentNum = (current / total) * 100;
122
- const percent = percentNum.toFixed(1);
123
- const bar = '█'.repeat(Math.floor(current / total * 30));
124
- const empty = '░'.repeat(30 - bar.length);
125
- // In verbose mode or non-TTY, use newlines so output isn't swallowed by pipes.
126
- // Throttle to every ~2% to avoid flooding.
127
- if (verboseMode || !process.stdout.isTTY) {
153
+ if (!process.stdout.isTTY) {
154
+ // Pipes / CI: throttle to ~2% and emit newlines so output isn't swallowed.
155
+ const percentNum = total > 0 ? (current / total) * 100 : 100;
128
156
  const lastPct = lastLoggedPercent[label] || 0;
129
- if (percentNum - lastPct >= 2 || current === total || current <= 1) {
157
+ if (percentNum - lastPct >= 2 || current >= total || current <= 1) {
130
158
  lastLoggedPercent[label] = percentNum;
131
- console.log(`${colors.cyan}${label}: [${bar}${empty}] ${percent}% (${current}/${total})${colors.reset}`);
132
- }
133
- } else {
134
- process.stdout.write(`\r${colors.cyan}${label}: [${bar}${empty}] ${percent}% (${current}/${total})${colors.reset}`);
135
- if (current === total) {
136
- process.stdout.write('\n');
159
+ console.log(renderBar(current, total, label));
137
160
  }
161
+ return;
162
+ }
163
+ // Interactive TTY: animate the bar in place.
164
+ activeBar = renderBar(current, total, label);
165
+ process.stdout.write(`\r${activeBar}${CLEAR_EOL}`);
166
+ if (current >= total) {
167
+ process.stdout.write('\n');
168
+ activeBar = null;
169
+ lastLoggedPercent[label] = 0;
138
170
  }
139
171
  }
140
172
 
@@ -72,12 +72,17 @@ async function initWasm() {
72
72
 
73
73
  initDone = true;
74
74
 
75
- if (nativeMaxsim) {
76
- console.error('[MaxSim] Tier 1: Native Rust + Rayon (parallel SIMD)');
77
- } else if (maxsimExports || wasmExports?.maxsim_f32) {
78
- console.error('[MaxSim] Tier 2: WASM SIMD f32x4');
79
- } else {
80
- console.error('[MaxSim] Tier 3: JS fallback');
75
+ // Load-time tier diagnostic — gated behind DEBUG so it doesn't precede the
76
+ // banner / clutter normal output (the active tier is also shown in `init`'s
77
+ // summary as "MaxSim: …"). Set DEBUG=1 to surface it.
78
+ if (process.env.DEBUG) {
79
+ if (nativeMaxsim) {
80
+ console.error('[MaxSim] Tier 1: Native Rust + Rayon (parallel SIMD)');
81
+ } else if (maxsimExports || wasmExports?.maxsim_f32) {
82
+ console.error('[MaxSim] Tier 2: WASM SIMD f32x4');
83
+ } else {
84
+ console.error('[MaxSim] Tier 3: JS fallback');
85
+ }
81
86
  }
82
87
 
83
88
  return true;
@@ -1677,8 +1677,17 @@ function resolveSubMode(format) {
1677
1677
  // space, and small-N entropy is dominated by the 1/log(n) denominator and
1678
1678
  // stops being a reliable distribution-width signal.
1679
1679
 
1680
+ // Preview-tier budget: 3000 (was 4000 until 2026-06-11). The 4-model budget
1681
+ // sweep (DeepSeek/MiMo/GPT-5.5-codex/Opus-CC, 12 dev probes, paired vs 4k)
1682
+ // found 3k keeps every accuracy/usefulness metric flat-to-up with zero
1683
+ // call-compensation, and cuts realized cost −11–15% on the flagship cells.
1684
+ // Below 3k, flagship models re-buy the trimmed context with extra calls
1685
+ // (Opus calls Δ: 3k −0.08 → 2.8k +0.33 → 2.5k +0.67 → 2k +0.83), erasing
1686
+ // the savings — 3k is the floor. SWEET_SEARCH_PREVIEW_BUDGET overrides for
1687
+ // experiments; full/xl escalation tiers are unchanged.
1688
+ const PREVIEW_TIER_BUDGET = Number(process.env.SWEET_SEARCH_PREVIEW_BUDGET || '') || 3000;
1680
1689
  const BUDGET_TIERS = {
1681
- preview: { subMode: 'agent_preview', budget: 4000 },
1690
+ preview: { subMode: 'agent_preview', budget: PREVIEW_TIER_BUDGET },
1682
1691
  full: { subMode: 'agent_full', budget: 8000 },
1683
1692
  xl: { subMode: 'agent_full_xl', budget: 12000 },
1684
1693
  };
@@ -52,7 +52,7 @@ Options:
52
52
  --fusion <type> Legacy: cc or rrf (ignored for hybrid - always uses robust CC fusion)
53
53
  --late-interaction Enable late interaction reranking (if index available)
54
54
  --late-interaction-model=ID Use specific model (lateon-code or lateon-code-edge)
55
- --agent Agent mode: self-contained code blocks. Auto-picks 4k/8k/12k
55
+ --agent Agent mode: self-contained code blocks. Auto-picks 3k/8k/12k
56
56
  tier from score-distribution signals (top-1 dominance,
57
57
  entropy, candidate-pool breadth) — no need to choose a tier.
58
58
  --agent-preview Force the 4k preview tier (rarely needed; --agent auto-picks)
@@ -268,7 +268,7 @@ export async function generateRegexMatches(searcher, regex, searchDir, options =
268
268
  const literalStart = performance.now();
269
269
 
270
270
  const sparseForPrefilter = globs.length === 0 ? ensureSparseGramIndex(searcher, options) : null;
271
- const prefilterFiles = sparseForPrefilter ? getSparseGramAllFiles(sparseForPrefilter) : null;
271
+ const prefilterFiles = sparseForPrefilter ? getSparseGramAllFilesWithOverlay(searcher, sparseForPrefilter, options) : null;
272
272
 
273
273
  if (prefilterFiles && prefilterFiles.length > 0) {
274
274
  const combined = new Set();
@@ -83,7 +83,7 @@ Options:
83
83
  --in <file> Disambiguate symbols by indexed file path
84
84
  --query <hint> Natural-language hint used only for structural ranking
85
85
  --depth <n> Impact depth, 1-4 (default: 3)
86
- --budget <n> Token budget, 1000-16000 (default: adaptive 4k/8k/12k)
86
+ --budget <n> Token budget, 1000-16000 (default: adaptive 3k/8k/12k)
87
87
  --json Output structured JSON
88
88
  --format <fmt> plain (no banner) or json
89
89
  --no-banner Suppress the identity line
@@ -126,6 +126,9 @@ async function cmdFind(args) {
126
126
  process.stderr.write('Usage: ss-find "<query>" --regex "<regex>" [--full|--xl] [-k N]\n');
127
127
  process.exit(2);
128
128
  }
129
+ // Budget-sweep experiment hook: lets the bench pin the response token budget
130
+ // per-process without changing the agent-visible tool surface.
131
+ const envFindBudget = Number(process.env.SS_SMOKE_FIND_BUDGET || '') || null;
129
132
  const effectiveRegex = regex || '';
130
133
  const s = await getSweetSearch();
131
134
  if (!s.hasLateInteractionIndex) {
@@ -136,6 +139,7 @@ async function cmdFind(args) {
136
139
  regex: effectiveRegex || `\\b\\w+\\b`,
137
140
  k,
138
141
  format,
142
+ ...(envFindBudget ? { tokenBudget: envFindBudget } : {}),
139
143
  });
140
144
 
141
145
  // Header (visible to agent)
@@ -212,7 +216,7 @@ async function cmdAgentSearch(args) {
212
216
  // Main sweet-search auto/CatBoost search with token-budgeted agent packaging.
213
217
  //
214
218
  // Usage:
215
- // ss-search "<query>" → format=agent (auto-pick 4k/8k/12k)
219
+ // ss-search "<query>" → format=agent (auto-pick 3k/8k/12k)
216
220
  // ss-search "<query>" --full → force 8k (rarely needed; default auto-picks)
217
221
  // ss-search "<query>" --xl → force 12k (rarely needed; default auto-picks)
218
222
  // ss-search "<query>" -k 5 → top-K results
@@ -240,7 +244,10 @@ async function cmdAgentSearch(args) {
240
244
  process.exit(1);
241
245
  }
242
246
 
243
- const response = await queryServer(query, { topK: k, mode, format });
247
+ // Budget-sweep experiment hook: per-request explicit budget (overrides the
248
+ // auto-tier on the warm server; flows as the `budget` URL param).
249
+ const envSearchBudget = Number(process.env.SS_SMOKE_SEARCH_BUDGET || '') || null;
250
+ const response = await queryServer(query, { topK: k, mode, format, ...(envSearchBudget ? { tokenBudget: envSearchBudget } : {}) });
244
251
  if (response?.error) {
245
252
  process.stderr.write(`[ss-search] server error: ${response.error}\n`);
246
253
  process.exit(1);
@@ -383,7 +390,11 @@ async function cmdSemantic(args) {
383
390
  process.stderr.write('Usage: ss-semantic <file> "<question>" [--max-tokens N]\n');
384
391
  process.exit(2);
385
392
  }
386
- const maxTokens = +parseFlag(args.slice(2), '--max-tokens', 800);
393
+ // Default 600 (was 800) per the 2026-06 budget sweep — scaled with the 3k
394
+ // preview tier. Env hook overrides the default for sweeps; an explicit
395
+ // --max-tokens flag from the agent always wins.
396
+ const maxTokens = +parseFlag(args.slice(2), '--max-tokens',
397
+ Number(process.env.SS_SMOKE_SEMANTIC_MAXTOKENS || '') || 600);
387
398
  const { readSemantic } = await import(path.join(REPO_ROOT, 'core/search/search-read-semantic.js'));
388
399
  const r = await readSemantic({
389
400
  path: file, query, projectRoot: PROJECT_ROOT,
@@ -423,7 +434,9 @@ async function cmdTrace(args) {
423
434
  if (file) opts.filePath = file;
424
435
  if (queryHint) opts.queryHint = queryHint;
425
436
  if (depth != null) opts.maxDepth = +depth;
437
+ // Budget-sweep experiment hook: env sets the default; explicit --budget wins.
426
438
  if (budget != null) opts.tokenBudget = +budget;
439
+ else if (Number(process.env.SS_SMOKE_TRACE_BUDGET || '') > 0) opts.tokenBudget = Number(process.env.SS_SMOKE_TRACE_BUDGET);
427
440
 
428
441
  const response = traceSymbol(symbol, opts);
429
442
  if (json) process.stdout.write(JSON.stringify(response, null, 2) + '\n');
@@ -4,7 +4,7 @@
4
4
  # semantic / hybrid / structural) with auto-tier budget by default.
5
5
  #
6
6
  # Usage:
7
- # ss-search "<query>" # auto-picks 4k / 8k / 12k from signals
7
+ # ss-search "<query>" # auto-picks 3k / 8k / 12k from signals
8
8
  # ss-search "<query>" --full # force 8k (rarely needed; default auto-picks)
9
9
  # ss-search "<query>" --xl # force 12k (rarely needed; default auto-picks)
10
10
  # ss-search "<query>" -k N # top-K (default 5)
package/mcp/server.js CHANGED
@@ -167,7 +167,7 @@ server.registerTool('trace', {
167
167
  maxDepth: z.number().int().min(1).max(4).default(3).optional()
168
168
  .describe('Maximum transitive impact depth (default: 3, capped at 4)'),
169
169
  tokenBudget: z.number().int().min(1000).max(16000).optional()
170
- .describe('Optional token budget. Omit for adaptive 4k/8k/12k selection.'),
170
+ .describe('Optional token budget. Omit for adaptive 3k/8k/12k selection.'),
171
171
  },
172
172
  outputSchema: TraceOutputSchema,
173
173
  annotations: {