sweet-search 2.6.12 → 2.6.13

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.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Agent-facing grep output shaping: k-budget file-level diversity.
3
+ *
4
+ * Root cause (task bench, rstudio-education__gradethis-161): bareGrep returns
5
+ * matches sorted alphabetically by file and the agent wrapper printed the
6
+ * first k grouped by file — so one flooded early-alphabet file could consume
7
+ * the entire k budget and structurally hide every other matching file, with
8
+ * no signal that elision happened.
9
+ *
10
+ * These helpers are pure and option-gated: only the ss-grep agent wrapper
11
+ * enables them. The human-facing grep product shape and all NL ranking paths
12
+ * are untouched (no scoring — file order stays the engine's deterministic
13
+ * sorted order; raw match count is deliberately never used as a sort key).
14
+ */
15
+
16
+ /**
17
+ * True when a match's repo-relative path is the drill-in target. Accepts the
18
+ * exact relative path as printed in grep output, a path with a leading "./",
19
+ * or a suffix (basename or trailing path segments) so agents can paste any
20
+ * reasonable spelling of the file they saw.
21
+ *
22
+ * @param {string} file - repo-relative match path (as emitted by the engine)
23
+ * @param {string} filter - user-supplied --in value
24
+ */
25
+ export function matchesGrepFileFilter(file, filter) {
26
+ if (!file || !filter) return false;
27
+ const f = String(filter).replace(/^\.\//, '').replace(/\\/g, '/');
28
+ const target = String(file).replace(/\\/g, '/');
29
+ return target === f || target.endsWith(`/${f}`);
30
+ }
31
+
32
+ /**
33
+ * Streaming per-file diversification of a (file,line)-sorted match list.
34
+ * Keeps at most `perFileCap` matches per file and at most `maxFiles` distinct
35
+ * files; everything beyond is COUNTED but never stored, so memory is bounded
36
+ * by perFileCap*maxFiles regardless of total match count.
37
+ *
38
+ * PRECONDITION: matches must be grouped by file (bareGrep sorts by
39
+ * (file, line, column) before calling) — the walk uses a single per-file
40
+ * cursor precisely so no map proportional to distinct-file count is built.
41
+ *
42
+ * @param {Array<{file: string}>} matches - sorted by (file, line)
43
+ * @param {{perFileCap: number, maxFiles?: number, hiddenSampleSize?: number}} opts
44
+ * @returns {{kept: Array, fileSummary: {
45
+ * files: Array<{file: string, total: number, kept: number}>,
46
+ * hiddenFileCount: number, hiddenMatchCount: number,
47
+ * hiddenSample: Array<{file: string, total: number}>,
48
+ * }}}
49
+ */
50
+ export function applyGrepFileDiversity(matches, opts = {}) {
51
+ const perFileCap = Math.max(1, opts.perFileCap | 0);
52
+ const maxFiles = opts.maxFiles > 0 ? (opts.maxFiles | 0) : Infinity;
53
+ const hiddenSampleSize = opts.hiddenSampleSize ?? 3;
54
+
55
+ const kept = [];
56
+ const files = []; // [{file, total, kept}] in first-appearance (sorted) order
57
+ const hiddenSample = []; // first few hidden files, name + total
58
+ let hiddenFileCount = 0;
59
+ let hiddenMatchCount = 0;
60
+
61
+ let current = null;
62
+ for (const m of matches) {
63
+ if (!current || current.file !== m.file) {
64
+ if (files.length < maxFiles) {
65
+ current = { file: m.file, total: 0, kept: 0 };
66
+ files.push(current);
67
+ } else {
68
+ current = { file: m.file, total: 0, kept: -1 }; // hidden file: count only
69
+ hiddenFileCount++;
70
+ if (hiddenSample.length < hiddenSampleSize) {
71
+ hiddenSample.push(current);
72
+ }
73
+ }
74
+ }
75
+ current.total++;
76
+ if (current.kept >= 0 && current.kept < perFileCap) {
77
+ kept.push(m);
78
+ current.kept++;
79
+ } else if (current.kept < 0) {
80
+ hiddenMatchCount++;
81
+ }
82
+ }
83
+
84
+ return {
85
+ kept,
86
+ fileSummary: {
87
+ files,
88
+ hiddenFileCount,
89
+ hiddenMatchCount,
90
+ hiddenSample: hiddenSample.map(h => ({ file: h.file, total: h.total })),
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Breadth-first budget allocation: round-robin one line per file per round,
97
+ * in the given (deterministic) file order, until `budget` lines are allocated
98
+ * or every file's available matches are exhausted. Flooding is structurally
99
+ * impossible — file A gets its (r+1)-th line only after every other file got
100
+ * its r-th — while few-files queries keep full depth (deep rounds fill just
101
+ * like the old flat output).
102
+ *
103
+ * @param {number[]} counts - available (fetched) matches per file
104
+ * @param {number} budget - total lines to allocate (k)
105
+ * @returns {number[]} allocation per file, same order as counts
106
+ */
107
+ export function allocateGrepBudget(counts, budget) {
108
+ const alloc = counts.map(() => 0);
109
+ let remaining = Math.max(0, budget | 0);
110
+ let progressed = true;
111
+ while (remaining > 0 && progressed) {
112
+ progressed = false;
113
+ for (let i = 0; i < counts.length && remaining > 0; i++) {
114
+ if (alloc[i] < counts[i]) {
115
+ alloc[i]++;
116
+ remaining--;
117
+ progressed = true;
118
+ }
119
+ }
120
+ }
121
+ return alloc;
122
+ }
123
+
124
+ /**
125
+ * Render the diversified agent grep body: matches grouped per file (research:
126
+ * contiguous per-file blocks read better than interleaving) with allocation
127
+ * decided breadth-first, and a visible inline `(+N more in this file)` marker
128
+ * on the last shown line of every truncated file — elision is never silent
129
+ * and costs no extra lines.
130
+ *
131
+ * @param {Array<{file: string, line: number, matchText?: string}>} kept -
132
+ * diversified matches in engine order (grouped by file)
133
+ * @param {{files: Array<{file, total, kept}>, hiddenFileCount, hiddenMatchCount,
134
+ * hiddenSample: Array<{file, total}>}} fileSummary
135
+ * @param {number} k - body line budget
136
+ * @returns {{lines: string[], shownMatches: number, matchedFileCount: number,
137
+ * truncatedFileCount: number, hiddenLine: string|null}}
138
+ */
139
+ export function renderGrepBody(kept, fileSummary, k) {
140
+ const groups = new Map();
141
+ for (const m of kept) {
142
+ if (!groups.has(m.file)) groups.set(m.file, []);
143
+ groups.get(m.file).push(m);
144
+ }
145
+ const totals = new Map(fileSummary.files.map(f => [f.file, f.total]));
146
+ const ordered = [...groups.entries()];
147
+ const alloc = allocateGrepBudget(ordered.map(([, ms]) => ms.length), k);
148
+
149
+ const lines = [];
150
+ let shownMatches = 0;
151
+ let truncatedFileCount = 0;
152
+ const unallocated = []; // fetched files that got zero budget (more files than k)
153
+ ordered.forEach(([file, ms], i) => {
154
+ if (alloc[i] === 0) {
155
+ unallocated.push({ file, total: totals.get(file) ?? ms.length });
156
+ return;
157
+ }
158
+ const total = totals.get(file) ?? ms.length;
159
+ for (let j = 0; j < alloc[i]; j++) {
160
+ const m = ms[j];
161
+ const text = (m.matchText || '').replace(/\s+/g, ' ').trim().slice(0, 140);
162
+ let line = `${file}:${m.line}: ${text}`;
163
+ if (j === alloc[i] - 1 && total > alloc[i]) {
164
+ line += ` (+${total - alloc[i]} more in this file)`;
165
+ truncatedFileCount++;
166
+ }
167
+ lines.push(line);
168
+ shownMatches++;
169
+ }
170
+ });
171
+
172
+ // Files that matched but got no body line at all (more matching files than
173
+ // budget, or clipped by the engine's maxFiles fetch bound): one honest tail
174
+ // line naming the first few so the agent can jump straight to them.
175
+ const hiddenFiles = unallocated.length + fileSummary.hiddenFileCount;
176
+ const hiddenMatches = unallocated.reduce((a, f) => a + f.total, 0) + fileSummary.hiddenMatchCount;
177
+ let hiddenLine = null;
178
+ if (hiddenFiles > 0) {
179
+ const sample = [...unallocated.map(f => f.file), ...fileSummary.hiddenSample.map(f => f.file)]
180
+ .slice(0, 3);
181
+ hiddenLine = `# +${hiddenFiles} more file(s) with ${hiddenMatches} match(es)` +
182
+ (sample.length ? ` — e.g. ${sample.join(', ')}` : '') +
183
+ `; narrow the regex, raise -k, or drill in with --in <file>`;
184
+ }
185
+
186
+ return {
187
+ lines,
188
+ shownMatches,
189
+ matchedFileCount: fileSummary.files.length + fileSummary.hiddenFileCount,
190
+ truncatedFileCount,
191
+ hiddenLine,
192
+ };
193
+ }
@@ -16,6 +16,7 @@
16
16
  import { PROJECT_ROOT } from '../infrastructure/config/index.js';
17
17
  import { generateRegexMatches } from './search-pattern-planner.js';
18
18
  import { buildBareGrepResults, filterMatchesBySymbolType, resolveSearchSymbolFilter, mapMatchesToChunks, readFileRange } from './search-pattern-chunks.js';
19
+ import { applyGrepFileDiversity, matchesGrepFileFilter } from './grep-output-shaping.js';
19
20
  import { isRipgrepAvailable, runRipgrepJson } from './search-pattern-ripgrep.js';
20
21
  import { ensureSparseGramIndex } from './search-pattern-prefilter.js';
21
22
  import { packageForAgent } from './context-expander.js';
@@ -152,6 +153,11 @@ export async function bareGrep(query, routing, options = {}) {
152
153
  const candidateResult = await generateRegexMatches(this || {}, regex, searchDir, options);
153
154
  let matches = [...candidateResult.indexedMatches, ...candidateResult.overlayMatches];
154
155
  matches = filterMatchesBySymbolType(matches, symbolType, this);
156
+ // Agent drill-in scope (--in <file>): applied BEFORE sort/cap so a
157
+ // late-alphabet file's matches can never be pre-clipped by maxMatches.
158
+ if (options.fileFilter) {
159
+ matches = matches.filter(m => matchesGrepFileFilter(m.file, options.fileFilter));
160
+ }
155
161
  matches.sort((a, b) =>
156
162
  a.file.localeCompare(b.file) ||
157
163
  a.line - b.line ||
@@ -159,6 +165,16 @@ export async function bareGrep(query, routing, options = {}) {
159
165
  );
160
166
 
161
167
  const totalMatches = matches.length;
168
+ // Agent-only k-budget file diversity (option-gated; absent → byte-identical
169
+ // output). Streaming per-file cap: matches beyond the cap are counted, not
170
+ // stored, so memory is bounded by perFileCap*maxFiles, never total matches.
171
+ let fileSummary = null;
172
+ if (options.perFileCap > 0) {
173
+ ({ kept: matches, fileSummary } = applyGrepFileDiversity(matches, {
174
+ perFileCap: options.perFileCap,
175
+ maxFiles: options.maxFiles,
176
+ }));
177
+ }
162
178
  if (maxMatches > 0) {
163
179
  matches = matches.slice(0, maxMatches);
164
180
  }
@@ -170,6 +186,7 @@ export async function bareGrep(query, routing, options = {}) {
170
186
 
171
187
  return {
172
188
  results,
189
+ ...(fileSummary ? { fileSummary } : {}),
173
190
  stats: {
174
191
  path: 'grep',
175
192
  regex,
@@ -18,6 +18,7 @@ import {
18
18
  buildGrepPattern, stripInertFlags, normalizeArgs, extractPositional,
19
19
  parseLineRange, looksLikeOption,
20
20
  } from './_ss-argparse.mjs';
21
+ import { renderGrepBody } from '../../../core/search/grep-output-shaping.js';
21
22
 
22
23
  // Diagnostic-log isolation (agent-facing tools). The Sweet Search engine emits
23
24
  // model/index load banners via console.log → stdout ("LateInteraction: Loaded…",
@@ -115,13 +116,16 @@ async function ensureWarmServerReady({ timeoutMs = 60000, intervalMs = 500 } = {
115
116
 
116
117
  // --- subcommands ----------------------------------------------------------
117
118
 
118
- const GREP_USAGE = 'Usage: ss-grep <regex> [-i|--ignore-case] [-w|--word-regexp] [-F|--fixed-strings] [-k N]';
119
+ const GREP_USAGE = 'Usage: ss-grep <regex> [-i|--ignore-case] [-w|--word-regexp] [-F|--fixed-strings] [--in <file>] [-k N]';
119
120
  async function cmdGrep(rawArgs) {
120
121
  const args = normalizeArgs(rawArgs);
121
122
  const ignoreCase = parseBoolFlag(args, ['-i', '--ignore-case']);
122
123
  const wordBound = parseBoolFlag(args, ['-w', '--word-regexp']);
123
124
  const fixedString = parseBoolFlag(args, ['-F', '--fixed-strings']);
124
125
  const k = readPositiveIntFlag(args, ['-k', '--top'], 20, GREP_USAGE);
126
+ // Drill-in scope: show matches from ONE file (the recovery affordance the
127
+ // diversified output advertises when it truncates a flooded file).
128
+ const inFile = readValueFlag(args, '--in', null, GREP_USAGE);
125
129
  stripInertFlags(args);
126
130
  const regex = buildGrepPattern(resolvePositional(args, GREP_USAGE), { ignoreCase, wordBound, fixedString });
127
131
  if (!regex) {
@@ -129,25 +133,47 @@ async function cmdGrep(rawArgs) {
129
133
  process.exit(2);
130
134
  }
131
135
  const s = await getSweetSearch();
132
- const result = await s.bareGrep(regex, null, { regex, maxMatches: k * 5, contextLines: 0 });
133
- // Group by file, take first k matches across all files (ordered as bareGrep returns).
134
- const grouped = new Map();
135
- for (const r of result.results.slice(0, k * 5)) {
136
- if (!grouped.has(r.file)) grouped.set(r.file, []);
137
- grouped.get(r.file).push(r);
138
- }
139
- let printed = 0;
140
- process.stdout.write(`# ss-grep: ${result.results.length} total match(es) for /${regex}/\n`);
141
- for (const [file, lines] of grouped) {
142
- for (const r of lines) {
136
+
137
+ if (inFile) {
138
+ // Single-file scope: flat output, depth up to k within that file.
139
+ const result = await s.bareGrep(regex, null, {
140
+ regex, maxMatches: k, contextLines: 0, fileFilter: inFile,
141
+ });
142
+ const total = result.stats?.totalMatches ?? result.results.length;
143
+ process.stdout.write(`# ss-grep: ${total} total match(es) for /${regex}/ (scope: --in ${inFile})\n`);
144
+ result.results.forEach((r, i) => {
143
145
  const text = (r.matchText || '').replace(/\s+/g, ' ').trim().slice(0, 140);
144
- process.stdout.write(`${file}:${r.line}: ${text}\n`);
145
- printed++;
146
- if (printed >= k) break;
147
- }
148
- if (printed >= k) break;
146
+ const marker = (i === result.results.length - 1 && total > result.results.length)
147
+ ? ` (+${total - result.results.length} more — raise -k)` : '';
148
+ process.stdout.write(`${r.file}:${r.line}: ${text}${marker}\n`);
149
+ });
150
+ if (result.results.length === 0) process.stdout.write('(no matches)\n');
151
+ process.exit(0);
152
+ }
153
+
154
+ // k-budget file diversity: fetch at most min(k,100) matches per file across
155
+ // at most k files (a file can never show more than k lines, and more than k
156
+ // files can never fit), then allocate the k body lines breadth-first so one
157
+ // flooded file can never hide every other matching file (the gradethis-161
158
+ // failure). Rendering stays grouped per file; truncation is marked inline
159
+ // and drillable via --in.
160
+ const result = await s.bareGrep(regex, null, {
161
+ regex, maxMatches: 0, contextLines: 0,
162
+ perFileCap: Math.min(k, 100), maxFiles: k,
163
+ });
164
+ const total = result.stats?.totalMatches ?? result.results.length;
165
+ const fileSummary = result.fileSummary
166
+ || { files: [], hiddenFileCount: 0, hiddenMatchCount: 0, hiddenSample: [] };
167
+ const body = renderGrepBody(result.results, fileSummary, k);
168
+
169
+ process.stdout.write(`# ss-grep: ${total} total match(es) for /${regex}/\n`);
170
+ if (body.truncatedFileCount > 0 || body.hiddenLine) {
171
+ process.stdout.write(`# ${body.matchedFileCount} file(s) matched; (+N more in this file)=truncated — ` +
172
+ `see the rest: ss-grep "<regex>" --in <file>\n`);
149
173
  }
150
- if (printed === 0) process.stdout.write('(no matches)\n');
174
+ for (const line of body.lines) process.stdout.write(line + '\n');
175
+ if (body.hiddenLine) process.stdout.write(body.hiddenLine + '\n');
176
+ if (body.shownMatches === 0) process.stdout.write('(no matches)\n');
151
177
  process.exit(0);
152
178
  }
153
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sweet-search",
3
- "version": "2.6.12",
3
+ "version": "2.6.13",
4
4
  "description": "Sweet Search - SOTA Hybrid Code Search Engine with WASM CatBoost Query Router, Semantic/Lexical/Structural Search, and Multilingual Support",
5
5
  "type": "module",
6
6
  "main": "core/search/sweet-search.js",
@@ -167,13 +167,13 @@
167
167
  "vitest": "^4.0.16"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@sweet-search/native-darwin-arm64": "2.6.12",
171
- "@sweet-search/native-darwin-x64": "2.6.12",
172
- "@sweet-search/native-linux-arm64-gnu": "2.6.12",
173
- "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.12",
174
- "@sweet-search/native-linux-x64-gnu": "2.6.12",
175
- "@sweet-search/native-linux-x64-gnu-cuda": "2.6.12",
176
- "@sweet-search/bg-priority": "2.6.12"
170
+ "@sweet-search/native-darwin-arm64": "2.6.13",
171
+ "@sweet-search/native-darwin-x64": "2.6.13",
172
+ "@sweet-search/native-linux-arm64-gnu": "2.6.13",
173
+ "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.13",
174
+ "@sweet-search/native-linux-x64-gnu": "2.6.13",
175
+ "@sweet-search/native-linux-x64-gnu-cuda": "2.6.13",
176
+ "@sweet-search/bg-priority": "2.6.13"
177
177
  },
178
178
  "engines": {
179
179
  "node": ">=18.0.0"