sweet-search 2.6.12 → 2.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/search/context-expander.js +67 -18
- package/core/search/grep-output-shaping.js +193 -0
- package/core/search/query-sufficiency.js +341 -0
- package/core/search/search-pattern.js +17 -0
- package/eval/agent-read-workflows/bin/_ss-argparse.mjs +15 -0
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +49 -21
- package/mcp/tool-handlers.js +4 -0
- package/package.json +8 -8
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { readFileRange } from './search-pattern-chunks.js';
|
|
22
|
+
import { computeSufficiencyVerdict } from './query-sufficiency.js';
|
|
22
23
|
import { statSync } from 'fs';
|
|
23
24
|
import path from 'path';
|
|
24
25
|
|
|
@@ -1130,15 +1131,34 @@ function unresolvedExternalRefs(code, ownSymbolName, headerContext, neighborsRen
|
|
|
1130
1131
|
* or in the surfaced neighbours list
|
|
1131
1132
|
* - high_confidence : score gap puts top-1 well ahead of top-2
|
|
1132
1133
|
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1134
|
+
* Query-conditioned rule (July 2026 — full200 bench forensics showed the
|
|
1135
|
+
* structural rule mislabels systemically: `confidence=low sufficient=YES`
|
|
1136
|
+
* was the modal trailer, 156×, and reasons were structural in ~all cases):
|
|
1137
|
+
* the verdict is now 3-valued and REQUIRES positive query-match evidence
|
|
1138
|
+
* in top-1 (see core/search/query-sufficiency.js):
|
|
1139
|
+
*
|
|
1140
|
+
* yes := strong query evidence (exact anchor hit or high subtoken
|
|
1141
|
+
* overlap) AND (confidence=high OR (confidence=medium AND
|
|
1142
|
+
* structural resolution))
|
|
1143
|
+
* no := no results, or a query with real anchors finding nothing
|
|
1144
|
+
* in top-1 (confirmed absence)
|
|
1145
|
+
* unknown := everything ambiguous (incl. the old structural-only YES,
|
|
1146
|
+
* now reported as reason=well_formed_only)
|
|
1147
|
+
*
|
|
1148
|
+
* Structural facts (complete_symbol / header_resolved / neighbors_present /
|
|
1149
|
+
* self_contained_strict) are kept as diagnostic reasons and as the
|
|
1150
|
+
* resolution gate for medium-confidence YES — they can no longer produce
|
|
1151
|
+
* YES on their own. `sufficient` (boolean) stays = (verdict === 'yes') for
|
|
1152
|
+
* every structured consumer (MCP zod schema, bench counters).
|
|
1136
1153
|
*
|
|
1137
1154
|
* @param {object} topResult
|
|
1138
1155
|
* @param {{ confidence: string }} confidenceInfo
|
|
1139
|
-
* @
|
|
1156
|
+
* @param {{ query?: string, regex?: string, lowerResults?: object[] }} [queryContext]
|
|
1157
|
+
* @returns {{ sufficient: boolean, verdict: 'yes'|'no'|'unknown',
|
|
1158
|
+
* sufficiencyReason: string, reasons: string[],
|
|
1159
|
+
* unresolvedExternalCount: number, evidence: object|null }}
|
|
1140
1160
|
*/
|
|
1141
|
-
export function computeSufficiency(topResult, confidenceInfo) {
|
|
1161
|
+
export function computeSufficiency(topResult, confidenceInfo, queryContext = {}) {
|
|
1142
1162
|
const reasons = [];
|
|
1143
1163
|
|
|
1144
1164
|
const isComplete = !!(topResult.symbol &&
|
|
@@ -1168,16 +1188,31 @@ export function computeSufficiency(topResult, confidenceInfo) {
|
|
|
1168
1188
|
|
|
1169
1189
|
if (confidenceInfo?.confidence === 'high') reasons.push('high_confidence');
|
|
1170
1190
|
|
|
1171
|
-
// Tightened rule: complete symbol is necessary but NOT sufficient.
|
|
1172
|
-
// We require at least one resolution reason AND at least one specificity
|
|
1173
|
-
// reason. This stops a bare "complete + high_confidence" from claiming
|
|
1174
|
-
// sufficient when the body still references a dozen helpers we never
|
|
1175
|
-
// surfaced (the validation-pipeline failure mode).
|
|
1176
1191
|
const hasResolution = hasHeader || hasNeighbors || reasons.includes('self_contained_strict');
|
|
1177
|
-
const hasSpecificity = confidenceInfo?.confidence === 'high' || hasHeader || hasNeighbors;
|
|
1178
|
-
const sufficient = isComplete && hasResolution && hasSpecificity;
|
|
1179
1192
|
|
|
1180
|
-
|
|
1193
|
+
// Query-conditioned verdict: structural facts are the resolution gate,
|
|
1194
|
+
// never the whole answer. See query-sufficiency.js for the fusion rule.
|
|
1195
|
+
const { verdict, reason: sufficiencyReason, evidence } = computeSufficiencyVerdict({
|
|
1196
|
+
topResult,
|
|
1197
|
+
confidenceInfo,
|
|
1198
|
+
query: queryContext.query || '',
|
|
1199
|
+
regex: queryContext.regex || '',
|
|
1200
|
+
structural: { isComplete, hasResolution },
|
|
1201
|
+
lowerResults: queryContext.lowerResults || [],
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
if (evidence?.exactHit) reasons.push('query_literal_matched');
|
|
1205
|
+
else if (evidence?.strength === 'strong') reasons.push('query_token_overlap');
|
|
1206
|
+
else if (evidence?.strength === 'none') reasons.push('no_query_evidence');
|
|
1207
|
+
|
|
1208
|
+
return {
|
|
1209
|
+
sufficient: verdict === 'yes',
|
|
1210
|
+
verdict,
|
|
1211
|
+
sufficiencyReason,
|
|
1212
|
+
reasons,
|
|
1213
|
+
unresolvedExternalCount: unresolvedCount,
|
|
1214
|
+
evidence,
|
|
1215
|
+
};
|
|
1181
1216
|
}
|
|
1182
1217
|
|
|
1183
1218
|
// =============================================================================
|
|
@@ -2287,15 +2322,27 @@ export function packageForAgent(rankedResults, searchStats, opts) {
|
|
|
2287
2322
|
|
|
2288
2323
|
const packagingMs = Math.round(performance.now() - start);
|
|
2289
2324
|
|
|
2290
|
-
// Sufficiency signal for top-1.
|
|
2291
|
-
//
|
|
2292
|
-
//
|
|
2325
|
+
// Sufficiency signal for top-1. Query-conditioned since 2026-07: the
|
|
2326
|
+
// 3-valued verdict requires positive query-match evidence; structural
|
|
2327
|
+
// packaging facts remain as reasons + the medium-confidence resolution
|
|
2328
|
+
// gate. Runs for summary-only top-1 too (verdict can be 'no'/'unknown'
|
|
2329
|
+
// there; never 'yes' without code).
|
|
2293
2330
|
let sufficient = false;
|
|
2331
|
+
let sufficiencyVerdict = 'no';
|
|
2332
|
+
let sufficiencyReason = 'no_results';
|
|
2294
2333
|
let sufficiencyReasons = [];
|
|
2295
2334
|
let unresolvedExternalCount = 0;
|
|
2296
|
-
if (agentResults.length > 0
|
|
2297
|
-
const sufficiency = computeSufficiency(agentResults[0], confidenceInfo
|
|
2335
|
+
if (agentResults.length > 0) {
|
|
2336
|
+
const sufficiency = computeSufficiency(agentResults[0], confidenceInfo, {
|
|
2337
|
+
query,
|
|
2338
|
+
regex,
|
|
2339
|
+
// Code-bearing lower ranks (full/preview) — soften a false 'no' when
|
|
2340
|
+
// the pack's answer sits below top-1; never used to grant YES.
|
|
2341
|
+
lowerResults: agentResults.slice(1, 4).filter(r => r.code),
|
|
2342
|
+
});
|
|
2298
2343
|
sufficient = sufficiency.sufficient;
|
|
2344
|
+
sufficiencyVerdict = sufficiency.verdict;
|
|
2345
|
+
sufficiencyReason = sufficiency.sufficiencyReason;
|
|
2299
2346
|
sufficiencyReasons = sufficiency.reasons;
|
|
2300
2347
|
unresolvedExternalCount = sufficiency.unresolvedExternalCount || 0;
|
|
2301
2348
|
}
|
|
@@ -2317,6 +2364,8 @@ export function packageForAgent(rankedResults, searchStats, opts) {
|
|
|
2317
2364
|
confidence: confidenceInfo.confidence,
|
|
2318
2365
|
confidenceReason: confidenceInfo.confidenceReason,
|
|
2319
2366
|
sufficient,
|
|
2367
|
+
sufficiencyVerdict,
|
|
2368
|
+
sufficiencyReason,
|
|
2320
2369
|
sufficiencyReasons,
|
|
2321
2370
|
unresolvedExternalCount,
|
|
2322
2371
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-conditioned sufficiency evidence (2026-07).
|
|
3
|
+
*
|
|
4
|
+
* Answers ONE question, cheaply: does the top-1 packaged hit contain
|
|
5
|
+
* positive evidence that it matches what the QUERY asked for? This is the
|
|
6
|
+
* signal the structural sufficiency rule (complete_symbol / header_resolved /
|
|
7
|
+
* neighbors_present) never looked at — a perfectly well-formed function that
|
|
8
|
+
* has nothing to do with the query must not earn `sufficient=YES`.
|
|
9
|
+
*
|
|
10
|
+
* Literature anchor: Sufficient Context (Joren et al., ICLR 2025) — a naive
|
|
11
|
+
* lexical answer-presence check alone reaches F1 0.81 / precision 0.87 on
|
|
12
|
+
* sufficiency; fusing it (AND, not weighted sum) with an independent
|
|
13
|
+
* confidence signal is the high-precision pattern. See
|
|
14
|
+
* eval/task-completion-bench/analysis/sufficiency-redesign-2026-07-06.md.
|
|
15
|
+
*
|
|
16
|
+
* Constraints honoured here:
|
|
17
|
+
* - engine-local, query-time only: pure string work over the already-
|
|
18
|
+
* retrieved top-1 (haystack capped), no I/O, no LLM, sub-millisecond;
|
|
19
|
+
* - only ever invoked from packageForAgent (agent formats) — NL/GCSN
|
|
20
|
+
* ranking paths never reach this module;
|
|
21
|
+
* - identifier-SHAPE heuristics, not capture-filtering stopword lists
|
|
22
|
+
* (CLAUDE.md rule): the only stopword set below is a stable ~30-entry
|
|
23
|
+
* English query-tokenization list.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Query-tokenization stopwords (standard English IR practice — the "OK to
|
|
27
|
+
// keep" category). Filters function words from the subtoken-overlap signal;
|
|
28
|
+
// never used to filter identifier captures.
|
|
29
|
+
const EVIDENCE_STOPWORDS = new Set([
|
|
30
|
+
'a', 'an', 'and', 'are', 'can', 'could', 'did', 'do', 'does', 'for',
|
|
31
|
+
'from', 'how', 'in', 'into', 'is', 'it', 'its', 'not', 'of', 'on', 'or',
|
|
32
|
+
'should', 'that', 'the', 'this', 'to', 'was', 'were', 'what', 'when',
|
|
33
|
+
'where', 'which', 'with',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function envFloat(name, fallback) {
|
|
37
|
+
const raw = process.env[name];
|
|
38
|
+
if (raw == null || raw === '') return fallback;
|
|
39
|
+
const parsed = Number.parseFloat(raw);
|
|
40
|
+
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Thresholds are parameterized (env-overridable for offline calibration
|
|
44
|
+
// sweeps) — tuned on the task-level dev split of the full200 replay, never
|
|
45
|
+
// hand-picked against held-out. See the design note for the discipline.
|
|
46
|
+
export function sufficiencyThresholds() {
|
|
47
|
+
return {
|
|
48
|
+
overlapStrong: envFloat('SS_SUFF_OVERLAP_STRONG', 0.6),
|
|
49
|
+
overlapNone: envFloat('SS_SUFF_OVERLAP_NONE', 0.2),
|
|
50
|
+
minInformativeSubtokens: 2,
|
|
51
|
+
haystackCap: 32768,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const WORD_CHAR = /[A-Za-z0-9_$]/;
|
|
56
|
+
|
|
57
|
+
function isWordChar(ch) {
|
|
58
|
+
return ch != null && WORD_CHAR.test(ch);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Word-boundary substring check without compiling a regex per token.
|
|
63
|
+
* A match counts only when the char before and after the occurrence are
|
|
64
|
+
* not identifier chars ("Chunker" must not match inside "ChunkerParams"
|
|
65
|
+
* unless the query token IS ChunkerParams).
|
|
66
|
+
*/
|
|
67
|
+
export function containsToken(haystack, token, { caseSensitive = true } = {}) {
|
|
68
|
+
if (!haystack || !token) return false;
|
|
69
|
+
const hay = caseSensitive ? haystack : haystack.toLowerCase();
|
|
70
|
+
const needle = caseSensitive ? token : token.toLowerCase();
|
|
71
|
+
let from = 0;
|
|
72
|
+
while (from <= hay.length - needle.length) {
|
|
73
|
+
const idx = hay.indexOf(needle, from);
|
|
74
|
+
if (idx === -1) return false;
|
|
75
|
+
const before = idx > 0 ? hay[idx - 1] : null;
|
|
76
|
+
const after = idx + needle.length < hay.length ? hay[idx + needle.length] : null;
|
|
77
|
+
if (!isWordChar(before) && !isWordChar(after)) return true;
|
|
78
|
+
from = idx + 1;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Identifier-shape heuristic (shape, not a word list): a token reads as a
|
|
85
|
+
* code identifier when it carries structure natural language words don't —
|
|
86
|
+
* camelCase humps, underscores/$, digits mixed into letters, dotted/pathy
|
|
87
|
+
* compounds, or ALL_CAPS constants. Pure lowercase single words ("parse",
|
|
88
|
+
* "config") are NOT identifier-shaped; they flow into the softer
|
|
89
|
+
* subtoken-overlap signal instead.
|
|
90
|
+
*/
|
|
91
|
+
export function looksLikeIdentifierToken(token) {
|
|
92
|
+
if (!token || token.length < 3) return false;
|
|
93
|
+
if (/[_$]/.test(token)) return true;
|
|
94
|
+
if (/[a-z][A-Z]/.test(token)) return true; // camelCase hump
|
|
95
|
+
if (/[A-Za-z]\d|\d[A-Za-z]/.test(token)) return true; // letters+digits
|
|
96
|
+
if (/[.:/\\-]/.test(token) && /[A-Za-z]{2,}/.test(token)) return true; // dotted/pathy
|
|
97
|
+
if (/^[A-Z][A-Z0-9]{2,}$/.test(token)) return true; // SCREAMING caps
|
|
98
|
+
if (/^[A-Z][a-z]+[A-Z]/.test(token)) return true; // PascalCase
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pull literal runs out of a user regex (ss-find / ss-grep patterns):
|
|
104
|
+
* unescape metachar escapes, drop anchors, split on regex syntax, keep
|
|
105
|
+
* word-ish runs of length ≥ 3. "\\bChunkerParams\\b" → ["ChunkerParams"].
|
|
106
|
+
*/
|
|
107
|
+
export function regexLiteralRuns(regexSource) {
|
|
108
|
+
if (!regexSource) return [];
|
|
109
|
+
const unescaped = String(regexSource)
|
|
110
|
+
.replace(/\\[bBdDsSwWAZz]/g, ' ') // char-class/anchor escapes → separator
|
|
111
|
+
.replace(/\\([^A-Za-z0-9])/g, '$1'); // \. \( … → literal char
|
|
112
|
+
const runs = unescaped.split(/[\^$.|?*+()[\]{}<>=!,\s]+/);
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const run of runs) {
|
|
115
|
+
const trimmed = run.replace(/^[-:/\\]+|[-:/\\]+$/g, '');
|
|
116
|
+
if (trimmed.length >= 3 && /[A-Za-z]/.test(trimmed)) out.push(trimmed);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Split text into normalized informative subtokens: identifier chunks are
|
|
123
|
+
* split on case humps / non-alnum, lowercased, stopword- and length-filtered.
|
|
124
|
+
* Single-pass character scanner — this runs over the top-1 haystack on every
|
|
125
|
+
* agent-format search, so it must stay well under a millisecond at 32KB.
|
|
126
|
+
*/
|
|
127
|
+
export function informativeSubtokens(text) {
|
|
128
|
+
const out = new Set();
|
|
129
|
+
if (!text) return out;
|
|
130
|
+
const s = String(text);
|
|
131
|
+
const n = s.length;
|
|
132
|
+
let start = -1; // current subtoken start
|
|
133
|
+
let hasAlpha = false; // subtoken contains a letter (pure digits dropped)
|
|
134
|
+
const flush = (end) => {
|
|
135
|
+
if (start === -1) return;
|
|
136
|
+
if (hasAlpha && end - start >= 3) {
|
|
137
|
+
const norm = s.slice(start, end).toLowerCase();
|
|
138
|
+
if (!EVIDENCE_STOPWORDS.has(norm)) out.add(norm);
|
|
139
|
+
}
|
|
140
|
+
start = -1;
|
|
141
|
+
hasAlpha = false;
|
|
142
|
+
};
|
|
143
|
+
let prevLower = false; // previous char was a lowercase letter or digit
|
|
144
|
+
let prevUpper = false; // previous char was uppercase
|
|
145
|
+
for (let i = 0; i < n; i++) {
|
|
146
|
+
const c = s.charCodeAt(i);
|
|
147
|
+
const isLower = c >= 97 && c <= 122;
|
|
148
|
+
const isUpper = c >= 65 && c <= 90;
|
|
149
|
+
const isDigit = c >= 48 && c <= 57;
|
|
150
|
+
if (isLower || isUpper || isDigit) {
|
|
151
|
+
// camelCase hump: aB starts a new subtoken; ABc splits before the 'Bc'.
|
|
152
|
+
if (start !== -1 && isUpper && prevLower) {
|
|
153
|
+
flush(i);
|
|
154
|
+
} else if (start !== -1 && isLower && prevUpper && i - start >= 2) {
|
|
155
|
+
const split = i - 1; // "HTTPServer" → "http" + "server"
|
|
156
|
+
if (split - start >= 3) {
|
|
157
|
+
const norm = s.slice(start, split).toLowerCase();
|
|
158
|
+
if (!EVIDENCE_STOPWORDS.has(norm)) out.add(norm);
|
|
159
|
+
}
|
|
160
|
+
start = split;
|
|
161
|
+
hasAlpha = true;
|
|
162
|
+
}
|
|
163
|
+
if (start === -1) { start = i; hasAlpha = false; }
|
|
164
|
+
if (isLower || isUpper) hasAlpha = true;
|
|
165
|
+
prevLower = isLower || isDigit;
|
|
166
|
+
prevUpper = isUpper;
|
|
167
|
+
} else {
|
|
168
|
+
flush(i);
|
|
169
|
+
prevLower = false;
|
|
170
|
+
prevUpper = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
flush(n);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract the query's evidence anchors.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} query natural-language or symbol query
|
|
181
|
+
* @param {string} [regex] optional user regex (ss-find)
|
|
182
|
+
* @returns {{ anchors: string[], subtokens: Set<string> }}
|
|
183
|
+
* anchors: exact-match candidates (quoted literals, identifier-shaped
|
|
184
|
+
* tokens, regex literal runs) — checked verbatim with word boundaries;
|
|
185
|
+
* subtokens: normalized informative subtokens for the overlap signal.
|
|
186
|
+
*/
|
|
187
|
+
export function extractQueryEvidence(query, regex) {
|
|
188
|
+
const q = String(query || '');
|
|
189
|
+
const anchors = [];
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const push = (tok) => {
|
|
192
|
+
if (tok && tok.length >= 3 && !seen.has(tok)) { seen.add(tok); anchors.push(tok); }
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Quoted literals (error strings, config keys) — strongest anchors.
|
|
196
|
+
const quoteRe = /"([^"\n]{3,120})"|'([^'\n]{3,120})'|`([^`\n]{3,120})`/g;
|
|
197
|
+
let m;
|
|
198
|
+
while ((m = quoteRe.exec(q)) !== null) push(m[1] || m[2] || m[3]);
|
|
199
|
+
|
|
200
|
+
// Identifier-shaped tokens from the query text.
|
|
201
|
+
for (const raw of q.split(/[\s,;()"'`]+/)) {
|
|
202
|
+
const tok = raw.replace(/^[^A-Za-z0-9_$]+|[^A-Za-z0-9_$)]+$/g, '');
|
|
203
|
+
if (looksLikeIdentifierToken(tok)) push(tok);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Literal runs from the regex (the exact thing the caller greps for).
|
|
207
|
+
for (const run of regexLiteralRuns(regex)) push(run);
|
|
208
|
+
|
|
209
|
+
return { anchors, subtokens: informativeSubtokens(q) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Assess query-match evidence in the top-1 packaged result.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} query
|
|
216
|
+
* @param {string} regex
|
|
217
|
+
* @param {object} topResult packaged agent result (symbol, file, code,
|
|
218
|
+
* headerContext, summary)
|
|
219
|
+
* @returns {{ strength: 'strong'|'partial'|'none', exactHit: boolean,
|
|
220
|
+
* matchedAnchor: string|null, overlap: number,
|
|
221
|
+
* informativeCount: number }}
|
|
222
|
+
*/
|
|
223
|
+
export function assessQueryEvidence(query, regex, topResult) {
|
|
224
|
+
const t = sufficiencyThresholds();
|
|
225
|
+
const { anchors, subtokens } = extractQueryEvidence(query, regex);
|
|
226
|
+
|
|
227
|
+
const haystackParts = [
|
|
228
|
+
topResult?.symbol || '',
|
|
229
|
+
topResult?.file || '',
|
|
230
|
+
topResult?.headerContext || '',
|
|
231
|
+
topResult?.summary || '',
|
|
232
|
+
topResult?.code || '',
|
|
233
|
+
];
|
|
234
|
+
const haystack = haystackParts.join('\n').slice(0, t.haystackCap);
|
|
235
|
+
|
|
236
|
+
let exactHit = false;
|
|
237
|
+
let matchedAnchor = null;
|
|
238
|
+
for (const anchor of anchors) {
|
|
239
|
+
// Case-sensitive when the anchor carries case information; boundary-
|
|
240
|
+
// checked either way so "Chunker" can't match inside "ChunkerParams".
|
|
241
|
+
const caseSensitive = /[A-Z]/.test(anchor);
|
|
242
|
+
if (containsToken(haystack, anchor, { caseSensitive })) {
|
|
243
|
+
exactHit = true;
|
|
244
|
+
matchedAnchor = anchor;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const informativeCount = subtokens.size;
|
|
250
|
+
let overlap = 0;
|
|
251
|
+
if (informativeCount > 0) {
|
|
252
|
+
const hayTokens = informativeSubtokens(haystack);
|
|
253
|
+
let matched = 0;
|
|
254
|
+
for (const tok of subtokens) if (hayTokens.has(tok)) matched++;
|
|
255
|
+
overlap = matched / informativeCount;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let strength = 'partial';
|
|
259
|
+
if (exactHit || (overlap >= t.overlapStrong && informativeCount >= t.minInformativeSubtokens)) {
|
|
260
|
+
strength = 'strong';
|
|
261
|
+
} else if (informativeCount >= t.minInformativeSubtokens && overlap <= t.overlapNone) {
|
|
262
|
+
// Only a query with enough informative tokens can CONFIRM absence —
|
|
263
|
+
// a 1-token NL query never earns a discouraging 'none'.
|
|
264
|
+
strength = 'none';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { strength, exactHit, matchedAnchor, overlap, informativeCount };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fuse query-match evidence with the engine's confidence bucket and the
|
|
272
|
+
* structural packaging facts into a 3-valued verdict.
|
|
273
|
+
*
|
|
274
|
+
* Rule (design note §2; stricter-YES direction):
|
|
275
|
+
* - YES requires strong evidence AND (confidence=high, or confidence=medium
|
|
276
|
+
* with resolved structural context). Packaging alone can never say YES;
|
|
277
|
+
* confidence=low can never say YES.
|
|
278
|
+
* - 'no' is reserved for confirmed absence (no results, or a query with
|
|
279
|
+
* real anchors/subtokens finding nothing in top-1). The borg-style
|
|
280
|
+
* false-no — literal present with a clear margin — becomes YES.
|
|
281
|
+
* - everything ambiguous is 'unknown', never a false binary.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} args
|
|
284
|
+
* @param {object|null} args.topResult
|
|
285
|
+
* @param {{confidence: string}|null} args.confidenceInfo
|
|
286
|
+
* @param {string} args.query
|
|
287
|
+
* @param {string} [args.regex]
|
|
288
|
+
* @param {{isComplete: boolean, hasResolution: boolean}} args.structural
|
|
289
|
+
* @param {object[]} [args.lowerResults] code-bearing results below top-1
|
|
290
|
+
* (full/preview tiers) — consulted ONLY to soften a would-be 'no': when
|
|
291
|
+
* the pack's answer sits at rank 2-3, a flat 'no' would falsely push the
|
|
292
|
+
* agent away from a pack that contains it. YES stays top-1-strict.
|
|
293
|
+
* @returns {{ verdict: 'yes'|'no'|'unknown', reason: string,
|
|
294
|
+
* evidence: object|null }}
|
|
295
|
+
*/
|
|
296
|
+
export function computeSufficiencyVerdict({ topResult, confidenceInfo, query, regex, structural, lowerResults = [] }) {
|
|
297
|
+
if (!topResult) {
|
|
298
|
+
return { verdict: 'no', reason: 'no_results', evidence: null };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const evidence = assessQueryEvidence(query, regex, topResult);
|
|
302
|
+
const conf = confidenceInfo?.confidence || 'low';
|
|
303
|
+
const structuralOk = !!(structural?.isComplete && structural?.hasResolution);
|
|
304
|
+
|
|
305
|
+
if (evidence.strength === 'none') {
|
|
306
|
+
for (const r of lowerResults) {
|
|
307
|
+
if (!r || (!r.code && !r.symbol)) continue;
|
|
308
|
+
const lower = assessQueryEvidence(query, regex, r);
|
|
309
|
+
if (lower.strength === 'strong') {
|
|
310
|
+
return { verdict: 'unknown', reason: 'evidence_below_top1', evidence };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { verdict: 'no', reason: 'no_query_evidence', evidence };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!topResult.code) {
|
|
317
|
+
// Summary-only top-1: the agent holds no code to answer from.
|
|
318
|
+
return { verdict: 'unknown', reason: 'top_summary_only', evidence };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (evidence.strength === 'strong') {
|
|
322
|
+
if (conf === 'high') {
|
|
323
|
+
return { verdict: 'yes', reason: evidence.exactHit ? 'query_evidence_clear_margin' : 'query_overlap_clear_margin', evidence };
|
|
324
|
+
}
|
|
325
|
+
if (conf === 'medium') {
|
|
326
|
+
// Dev-split calibration (full200 replay): gating medium on structural
|
|
327
|
+
// resolution cost recall (0.35 vs 0.41) for no precision gain (0.706
|
|
328
|
+
// vs 0.700) — strong evidence + a medium margin is enough. Low never
|
|
329
|
+
// reaches yes: re-admitting low+exactHit dropped precision to 0.61.
|
|
330
|
+
return { verdict: 'yes', reason: 'query_evidence_moderate_margin', evidence };
|
|
331
|
+
}
|
|
332
|
+
return { verdict: 'unknown', reason: 'evidence_without_margin', evidence };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Partial evidence — the old structural-YES shape lands here.
|
|
336
|
+
return {
|
|
337
|
+
verdict: 'unknown',
|
|
338
|
+
reason: structuralOk ? 'well_formed_only' : 'partial_query_evidence',
|
|
339
|
+
evidence,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -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,
|
|
@@ -192,3 +192,18 @@ export function extractPositional(args) {
|
|
|
192
192
|
if (bad) return { pattern: undefined, unknownFlag: bad };
|
|
193
193
|
return { pattern: args[0], unknownFlag: null };
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
// --- trailer rendering --------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
// Sufficiency trailer segment: 3-valued verdict (YES / no / unknown) + a
|
|
199
|
+
// compact why-token, e.g. ` sufficient=YES (query_evidence_clear_margin)` vs
|
|
200
|
+
// ` sufficient=unknown (well_formed_only)`. Falls back to the legacy boolean
|
|
201
|
+
// when the engine predates sufficiencyVerdict. The full line shape stays
|
|
202
|
+
// `# confidence=<bucket> (<reason>) sufficient=<verdict> (<why>)`.
|
|
203
|
+
export function renderSufficiency(response) {
|
|
204
|
+
const verdict = response.sufficiencyVerdict
|
|
205
|
+
? (response.sufficiencyVerdict === 'yes' ? 'YES' : response.sufficiencyVerdict)
|
|
206
|
+
: (response.sufficient ? 'YES' : 'no');
|
|
207
|
+
const why = response.sufficiencyReason ? ` (${response.sufficiencyReason})` : '';
|
|
208
|
+
return ` sufficient=${verdict}${why}`;
|
|
209
|
+
}
|
|
@@ -16,8 +16,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import {
|
|
17
17
|
parseBoolFlag, parseValueFlag, parsePositiveIntFlag,
|
|
18
18
|
buildGrepPattern, stripInertFlags, normalizeArgs, extractPositional,
|
|
19
|
-
parseLineRange, looksLikeOption,
|
|
19
|
+
parseLineRange, looksLikeOption, renderSufficiency,
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
if (
|
|
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
|
-
|
|
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
|
|
|
@@ -196,7 +222,7 @@ async function cmdFind(rawArgs) {
|
|
|
196
222
|
` budget=${response.tokenBudget} used=${response.tokensUsed} subMode=${response.subMode ?? format}\n`);
|
|
197
223
|
if (response.confidence) {
|
|
198
224
|
process.stdout.write(`# confidence=${response.confidence}${response.confidenceReason ? ' (' + response.confidenceReason + ')' : ''}` +
|
|
199
|
-
`${response
|
|
225
|
+
`${renderSufficiency(response)}\n`);
|
|
200
226
|
}
|
|
201
227
|
|
|
202
228
|
// Per-result blocks — identical shape to ss-search's agent packaging.
|
|
@@ -391,7 +417,7 @@ async function cmdAgentSearch(rawArgs) {
|
|
|
391
417
|
` results=${response.results.length} subMode=${response.subMode}\n`);
|
|
392
418
|
if (response.confidence) {
|
|
393
419
|
process.stdout.write(`# confidence=${response.confidence}${response.confidenceReason ? ' (' + response.confidenceReason + ')' : ''}` +
|
|
394
|
-
`${response
|
|
420
|
+
`${renderSufficiency(response)}\n`);
|
|
395
421
|
}
|
|
396
422
|
|
|
397
423
|
// Per-result blocks
|
|
@@ -448,6 +474,8 @@ async function cmdAgentSearch(rawArgs) {
|
|
|
448
474
|
headerCount,
|
|
449
475
|
confidence: response.confidence || null,
|
|
450
476
|
sufficient: response.sufficient ?? null,
|
|
477
|
+
sufficiencyVerdict: response.sufficiencyVerdict ?? null,
|
|
478
|
+
sufficiencyReason: response.sufficiencyReason ?? null,
|
|
451
479
|
sufficiencyReasons: Array.isArray(response.sufficiencyReasons) ? response.sufficiencyReasons : null,
|
|
452
480
|
unresolvedExternalCount: typeof response.unresolvedExternalCount === 'number'
|
|
453
481
|
? response.unresolvedExternalCount : null,
|
package/mcp/tool-handlers.js
CHANGED
|
@@ -42,6 +42,8 @@ export const SearchOutputSchema = z.object({
|
|
|
42
42
|
confidence: z.enum(['high', 'medium', 'low']).optional(),
|
|
43
43
|
confidenceReason: z.string().optional(),
|
|
44
44
|
sufficient: z.boolean().optional(),
|
|
45
|
+
sufficiencyVerdict: z.enum(['yes', 'no', 'unknown']).optional(),
|
|
46
|
+
sufficiencyReason: z.string().optional(),
|
|
45
47
|
sufficiencyReasons: z.array(z.string()).optional(),
|
|
46
48
|
packagingMs: z.number().optional(),
|
|
47
49
|
latencyMs: z.number().optional(),
|
|
@@ -166,6 +168,8 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
|
|
|
166
168
|
confidence: searchResult.confidence,
|
|
167
169
|
confidenceReason: searchResult.confidenceReason,
|
|
168
170
|
sufficient: searchResult.sufficient,
|
|
171
|
+
...(searchResult.sufficiencyVerdict ? { sufficiencyVerdict: searchResult.sufficiencyVerdict } : {}),
|
|
172
|
+
...(searchResult.sufficiencyReason ? { sufficiencyReason: searchResult.sufficiencyReason } : {}),
|
|
169
173
|
sufficiencyReasons: searchResult.sufficiencyReasons || [],
|
|
170
174
|
packagingMs: searchResult.packagingMs,
|
|
171
175
|
latencyMs: searchResult.latencyMs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sweet-search",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.14",
|
|
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.
|
|
171
|
-
"@sweet-search/native-darwin-x64": "2.6.
|
|
172
|
-
"@sweet-search/native-linux-arm64-gnu": "2.6.
|
|
173
|
-
"@sweet-search/native-linux-arm64-gnu-cuda": "2.6.
|
|
174
|
-
"@sweet-search/native-linux-x64-gnu": "2.6.
|
|
175
|
-
"@sweet-search/native-linux-x64-gnu-cuda": "2.6.
|
|
176
|
-
"@sweet-search/bg-priority": "2.6.
|
|
170
|
+
"@sweet-search/native-darwin-arm64": "2.6.14",
|
|
171
|
+
"@sweet-search/native-darwin-x64": "2.6.14",
|
|
172
|
+
"@sweet-search/native-linux-arm64-gnu": "2.6.14",
|
|
173
|
+
"@sweet-search/native-linux-arm64-gnu-cuda": "2.6.14",
|
|
174
|
+
"@sweet-search/native-linux-x64-gnu": "2.6.14",
|
|
175
|
+
"@sweet-search/native-linux-x64-gnu-cuda": "2.6.14",
|
|
176
|
+
"@sweet-search/bg-priority": "2.6.14"
|
|
177
177
|
},
|
|
178
178
|
"engines": {
|
|
179
179
|
"node": ">=18.0.0"
|