sweet-search 2.6.16 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +7 -3
  2. package/core/graph/structural-context-format.js +5 -0
  3. package/core/graph/structural-context.js +13 -3
  4. package/core/infrastructure/code-graph-repository.js +58 -0
  5. package/core/infrastructure/language-patterns/registry-core.js +14 -0
  6. package/core/infrastructure/model-fetcher.js +7 -0
  7. package/core/infrastructure/structural-context-repository.js +73 -7
  8. package/core/infrastructure/structural-context-utils.js +11 -0
  9. package/core/infrastructure/structural-qualified-resolution.js +29 -0
  10. package/core/infrastructure/tree-sitter-provider.js +77 -3
  11. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt-mcp.md +12 -6
  12. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +4 -2
  13. package/core/search/agent-pack-completion.js +493 -0
  14. package/core/search/agent-span-client.js +87 -0
  15. package/core/search/agent-span-ledger.js +447 -0
  16. package/core/search/context-expander.js +29 -0
  17. package/core/search/grep-output-shaping.js +28 -0
  18. package/core/search/regex-dialect.js +328 -0
  19. package/core/search/search-format.js +96 -0
  20. package/core/search/search-pattern.js +62 -10
  21. package/core/search/search-read.js +57 -10
  22. package/core/search/search-server.js +375 -7
  23. package/core/search/session-daemon-prewarm.mjs +75 -0
  24. package/core/search/sweet-search.js +7 -1
  25. package/core/search/unread-symbol-ranking.js +90 -0
  26. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +249 -45
  27. package/mcp/read-tool.js +34 -3
  28. package/mcp/server.js +55 -9
  29. package/mcp/tool-handlers.js +60 -7
  30. package/package.json +9 -9
  31. package/scripts/init.js +30 -5
  32. package/scripts/inject-agent-instructions.js +4 -2
  33. package/scripts/uninstall.js +21 -19
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Detect grep patterns that may have been written with GNU BRE muscle memory.
3
+ *
4
+ * Sweet Search deliberately keeps Rust-regex semantics for the first search.
5
+ * Agent-format callers may use the translation below for one retry after that
6
+ * exact pattern returns zero matches. Normal users and non-zero searches remain
7
+ * byte-identical.
8
+ */
9
+
10
+ const BRE_OPERATOR_LABELS = Object.freeze({
11
+ '|': '\\|',
12
+ '(': '\\(',
13
+ ')': '\\)',
14
+ '+': '\\+',
15
+ '?': '\\?',
16
+ '{': '\\{m,n\\}',
17
+ });
18
+
19
+ const BRE_RETRY_TRIGGERS = new Set(['|', '(', '+', '{']);
20
+ const BARE_BRE_DIVERGENT_METACHARS = new Set(['(', ')', '+', '?', '{', '|']);
21
+ const ZERO_WIDTH_ESCAPES = new Set(['A', 'b', 'B', 'z']);
22
+ const ADDITIVE_RETRY_STATS = [
23
+ 'candidateGenTime_ms',
24
+ 'grepTime_ms',
25
+ 'literalFilterTime_ms',
26
+ 'gramLookupTime_ms',
27
+ ];
28
+
29
+ function isEscapedDoublePipe(pattern, slashStart, escapedIndex) {
30
+ const previous = pattern.slice(Math.max(0, slashStart - 2), slashStart) === '\\|';
31
+ const next = pattern.slice(escapedIndex + 1, escapedIndex + 3) === '\\|';
32
+ return previous || next;
33
+ }
34
+
35
+ function inspectBreDialect(pattern, { fixedString = false } = {}) {
36
+ if (fixedString || typeof pattern !== 'string' || pattern.length < 2) return null;
37
+
38
+ const operators = new Set();
39
+ let retryable = false;
40
+ let translated = '';
41
+ let inClass = false;
42
+ let hasBareDivergentMetachar = false;
43
+ let hasEscapedQuestion = false;
44
+ let hasAmbiguousEscapeRun = false;
45
+ let hasEmptyAlternationArm = false;
46
+ const armStack = [{ hasContent: false, sawAlternation: false }];
47
+
48
+ const currentArm = () => armStack[armStack.length - 1];
49
+ const markContent = () => { currentArm().hasContent = true; };
50
+ const closeAlternationContext = () => {
51
+ const arm = currentArm();
52
+ if (arm.sawAlternation && !arm.hasContent) hasEmptyAlternationArm = true;
53
+ return arm;
54
+ };
55
+
56
+ for (let i = 0; i < pattern.length; i++) {
57
+ const ch = pattern[i];
58
+ if (inClass) {
59
+ translated += ch;
60
+ if (ch === '\\') {
61
+ let slashCount = 1;
62
+ while (pattern[i + slashCount] === '\\') slashCount++;
63
+ translated += '\\'.repeat(slashCount - 1);
64
+ i += slashCount - 1;
65
+ if (slashCount % 2 === 1 && i + 1 < pattern.length) {
66
+ translated += pattern[++i];
67
+ }
68
+ } else if (ch === ']') {
69
+ inClass = false;
70
+ }
71
+ continue;
72
+ }
73
+ if (ch === '[') {
74
+ inClass = true;
75
+ translated += ch;
76
+ markContent();
77
+ continue;
78
+ }
79
+ if (ch !== '\\') {
80
+ translated += ch;
81
+ if (BARE_BRE_DIVERGENT_METACHARS.has(ch)) {
82
+ hasBareDivergentMetachar = true;
83
+ markContent();
84
+ } else if (ch !== '^' && ch !== '$' && ch !== '*') {
85
+ markContent();
86
+ }
87
+ continue;
88
+ }
89
+
90
+ const slashStart = i;
91
+ let slashCount = 1;
92
+ while (pattern[i + slashCount] === '\\') slashCount++;
93
+ const escapedIndex = slashStart + slashCount;
94
+ const escaped = pattern[escapedIndex];
95
+ // Multiple backslashes are ambiguous (for example a literal backslash
96
+ // followed by an escaped operator). Preserve the run, then re-scan the
97
+ // following character so `\\[...\|...]` still enters the character class.
98
+ if (slashCount !== 1 || !escaped) {
99
+ translated += '\\'.repeat(slashCount);
100
+ if (slashCount !== 1) {
101
+ hasAmbiguousEscapeRun = true;
102
+ markContent();
103
+ }
104
+ i = slashStart + slashCount - 1;
105
+ continue;
106
+ }
107
+
108
+ const rawEscape = pattern.slice(slashStart, escapedIndex + 1);
109
+ if (escaped === '{') {
110
+ const interval = pattern.slice(slashStart).match(/^\\\{(\d+(?:,\d*)?)\\\}/);
111
+ if (interval) {
112
+ operators.add('{');
113
+ retryable = true;
114
+ translated += `{${interval[1]}}`;
115
+ i = slashStart + interval[0].length - 1;
116
+ continue;
117
+ }
118
+ }
119
+
120
+ let operator = null;
121
+ if (escaped === '|' && !isEscapedDoublePipe(pattern, slashStart, escapedIndex)) operator = '|';
122
+ else if (escaped === '(' || escaped === ')' || escaped === '+' || escaped === '?') operator = escaped;
123
+
124
+ if (operator) {
125
+ operators.add(operator);
126
+ if (BRE_RETRY_TRIGGERS.has(operator)) retryable = true;
127
+ if (operator === '?') hasEscapedQuestion = true;
128
+
129
+ if (operator === '|') {
130
+ const arm = currentArm();
131
+ if (!arm.hasContent) hasEmptyAlternationArm = true;
132
+ arm.sawAlternation = true;
133
+ arm.hasContent = false;
134
+ } else if (operator === '(') {
135
+ armStack.push({ hasContent: false, sawAlternation: false });
136
+ } else if (operator === ')') {
137
+ const closed = closeAlternationContext();
138
+ if (armStack.length > 1) {
139
+ armStack.pop();
140
+ if (closed.hasContent) markContent();
141
+ }
142
+ }
143
+
144
+ translated += operator === '?' ? rawEscape : operator;
145
+ } else {
146
+ translated += rawEscape;
147
+ if (!ZERO_WIDTH_ESCAPES.has(escaped)) markContent();
148
+ }
149
+ i = escapedIndex;
150
+ }
151
+
152
+ for (const arm of armStack) {
153
+ if (arm.sawAlternation && !arm.hasContent) hasEmptyAlternationArm = true;
154
+ }
155
+ if (operators.size === 0) return null;
156
+ return {
157
+ operators: [...operators].map(op => BRE_OPERATOR_LABELS[op]),
158
+ retryable,
159
+ translated,
160
+ translationAmbiguous: hasBareDivergentMetachar
161
+ || hasAmbiguousEscapeRun
162
+ || hasEmptyAlternationArm
163
+ || (hasEscapedQuestion && retryable),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * @param {string} pattern
169
+ * @param {{ fixedString?: boolean }} [options]
170
+ * @returns {{ operators: string[] } | null}
171
+ */
172
+ export function detectBreDialectHint(pattern, { fixedString = false } = {}) {
173
+ const inspection = inspectBreDialect(pattern, { fixedString });
174
+ return inspection ? { operators: inspection.operators } : null;
175
+ }
176
+
177
+ /**
178
+ * Translate only unambiguous GNU BRE operator spellings used by agents.
179
+ * Closing group/interval escapes are translated with their opening operator.
180
+ * `\?` remains diagnostic-only because it is too commonly used literally.
181
+ *
182
+ * @param {string} pattern
183
+ * @param {{ fixedString?: boolean }} [options]
184
+ * @returns {{ pattern: string, operators: string[] } | null}
185
+ */
186
+ export function translateBreToRustRegex(pattern, { fixedString = false } = {}) {
187
+ const inspection = inspectBreDialect(pattern, { fixedString });
188
+ if (!inspection?.retryable || inspection.translationAmbiguous || inspection.translated === pattern) {
189
+ return null;
190
+ }
191
+ try {
192
+ if (new RegExp(inspection.translated).test('')) return null;
193
+ } catch { /* JavaScript cannot parse every Rust-valid regex; structural guards still apply. */ }
194
+ return { pattern: inspection.translated, operators: inspection.operators };
195
+ }
196
+
197
+ function collectCandidateMatches(candidateResult) {
198
+ return [
199
+ ...(candidateResult?.indexedMatches || []),
200
+ ...(candidateResult?.overlayMatches || []),
201
+ ];
202
+ }
203
+
204
+ function sumNumericFields(original = {}, retried = {}, selected = {}) {
205
+ const merged = { ...selected };
206
+ for (const key of ADDITIVE_RETRY_STATS) {
207
+ const left = Number.isFinite(original[key]) ? original[key] : 0;
208
+ const right = Number.isFinite(retried[key]) ? retried[key] : 0;
209
+ if (Number.isFinite(original[key]) || Number.isFinite(retried[key])) merged[key] = left + right;
210
+ }
211
+
212
+ const stageKeys = new Set([
213
+ ...Object.keys(original.stageTiming || {}),
214
+ ...Object.keys(retried.stageTiming || {}),
215
+ ]);
216
+ if (stageKeys.size > 0) {
217
+ merged.stageTiming = { ...(selected.stageTiming || {}) };
218
+ for (const key of stageKeys) {
219
+ const left = Number.isFinite(original.stageTiming?.[key]) ? original.stageTiming[key] : 0;
220
+ const right = Number.isFinite(retried.stageTiming?.[key]) ? retried.stageTiming[key] : 0;
221
+ merged.stageTiming[key] = left + right;
222
+ }
223
+ }
224
+ return merged;
225
+ }
226
+
227
+ function withMergedRetryStats(selected, original, retried) {
228
+ if (!selected?.stats && !original?.stats && !retried?.stats) return selected;
229
+ return {
230
+ ...selected,
231
+ stats: sumNumericFields(original?.stats, retried?.stats, selected?.stats),
232
+ };
233
+ }
234
+
235
+ function isRegexSyntaxError(error) {
236
+ const messages = [];
237
+ for (let current = error; current && messages.length < 3; current = current.cause) {
238
+ messages.push(current instanceof Error ? current.message : String(current));
239
+ }
240
+ return /invalid regex|regex parse error|error parsing regex|regex syntax|repetition operator|unclosed (?:group|character class)|unmatched (?:closing|parenthesis)/i
241
+ .test(messages.join('\n'));
242
+ }
243
+
244
+ /**
245
+ * Run one agent-only BRE respelling retry after the caller's final result
246
+ * shaping reports zero matches. The supplied shaper is reused for the retry so
247
+ * hint counts and adoption always describe the matches the caller can return.
248
+ *
249
+ * @param {{
250
+ * pattern: string,
251
+ * fixedString?: boolean,
252
+ * agentFormat?: boolean,
253
+ * originalResult: object,
254
+ * shapeResult?: (candidateResult: object) => Array,
255
+ * retry: (translatedPattern: string) => Promise<object>,
256
+ * }} options
257
+ * @returns {Promise<{
258
+ * candidateResult: object,
259
+ * matches: Array,
260
+ * regexDialectHint: object|null,
261
+ * }>}
262
+ */
263
+ export async function retryBreDialectAfterZero({
264
+ pattern,
265
+ fixedString = false,
266
+ agentFormat = false,
267
+ originalResult,
268
+ shapeResult = collectCandidateMatches,
269
+ retry,
270
+ }) {
271
+ const originalMatches = shapeResult(originalResult);
272
+ if (!agentFormat || originalMatches.length > 0) {
273
+ return { candidateResult: originalResult, matches: originalMatches, regexDialectHint: null };
274
+ }
275
+
276
+ const fallbackHint = detectBreDialectHint(pattern, { fixedString });
277
+ const translation = translateBreToRustRegex(pattern, { fixedString });
278
+ if (!translation) {
279
+ return { candidateResult: originalResult, matches: originalMatches, regexDialectHint: fallbackHint };
280
+ }
281
+
282
+ try {
283
+ const retried = await retry(translation.pattern);
284
+ const retriedMatches = shapeResult(retried);
285
+ const retryMatched = retriedMatches.length > 0;
286
+ const selected = retryMatched ? retried : originalResult;
287
+ return {
288
+ candidateResult: withMergedRetryStats(selected, originalResult, retried),
289
+ matches: retryMatched ? retriedMatches : originalMatches,
290
+ regexDialectHint: {
291
+ operators: translation.operators,
292
+ retryAttempted: true,
293
+ retryMatched,
294
+ retryMatches: retriedMatches.length,
295
+ translatedPattern: translation.pattern,
296
+ },
297
+ };
298
+ } catch (error) {
299
+ if (!isRegexSyntaxError(error)) throw error;
300
+ return { candidateResult: originalResult, matches: originalMatches, regexDialectHint: fallbackHint };
301
+ }
302
+ }
303
+
304
+ /**
305
+ * @param {{
306
+ * operators?: string[], retryAttempted?: boolean, retryMatched?: boolean,
307
+ * retryMatches?: number, translatedPattern?: string,
308
+ * } | null | undefined} hint
309
+ * @returns {string}
310
+ */
311
+ export function renderRegexDialectHint(hint) {
312
+ const operators = Array.isArray(hint?.operators) ? hint.operators : [];
313
+ if (operators.length === 0) return '';
314
+ if (hint.retryAttempted) {
315
+ if (hint.retryMatched) {
316
+ const count = Number.isInteger(hint.retryMatches) ? hint.retryMatches : 0;
317
+ return `regex note: zero-hit GNU BRE operators were retried with unescaped Rust operators; ` +
318
+ `showing ${count} retry match${count === 1 ? '' : 'es'}.`;
319
+ }
320
+ return 'regex note: GNU BRE operators were retried with unescaped Rust operators; both forms returned 0 matches.';
321
+ }
322
+ if (operators.includes('\\|')) {
323
+ return 'regex note: Rust syntax treats \\| as a literal pipe; use | for alternation ' +
324
+ '(or -F for a literal search). The original pattern was used unchanged.';
325
+ }
326
+ return `regex note: Rust syntax uses unescaped operators (${operators.join(', ')}); ` +
327
+ 'escaped forms match punctuation. The original pattern was used unchanged.';
328
+ }
@@ -13,6 +13,102 @@
13
13
  // Result formatting
14
14
  // =============================================================================
15
15
 
16
+ const ROUTE_META_MARKER = '<<SS_ROUTE_META>>';
17
+ const COMPACT_ROUTE_RE = /^route=([A-Za-z0-9_.:-]+) confidence=([A-Za-z0-9_.:-]+) sufficient=(YES|no|unknown) reason=([A-Za-z0-9_.:-]+) repo=(ok|mismatch|unknown) results=(\d+)\s*$/gm;
18
+ const AGENT_HEADER_RE = /^#\s*ss-search:\s*routed=([A-Za-z0-9_.:-]+)(?:\s+conf=([-+]?(?:\d+(?:\.\d+)?|\.\d+)))?\s+budget=(\d+)\s+used=(\d+)\s+results=(\d+)\s+subMode=([A-Za-z0-9_.:-]+)\s*$/m;
19
+
20
+ function routeAtom(value, fallback = 'unknown') {
21
+ if (value == null || value === '') return fallback;
22
+ const atom = String(value).trim().replace(/[^A-Za-z0-9_.:-]+/g, '_');
23
+ return atom.slice(0, 96) || fallback;
24
+ }
25
+
26
+ function routeSufficiency(meta) {
27
+ if (meta.sufficiencyVerdict === 'yes') return 'YES';
28
+ if (meta.sufficiencyVerdict === 'no' || meta.sufficiencyVerdict === 'unknown') {
29
+ return meta.sufficiencyVerdict;
30
+ }
31
+ if (meta.sufficient === true) return 'YES';
32
+ if (meta.sufficient === false) return 'no';
33
+ return 'unknown';
34
+ }
35
+
36
+ /**
37
+ * Serialize route metadata for an output surface.
38
+ *
39
+ * Agent output receives only fields that can change the next action. Debug
40
+ * and non-agent callers retain the complete machine-readable object.
41
+ */
42
+ export function formatRouteMetadata(meta = {}, opts = {}) {
43
+ const routeMeta = meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : {};
44
+ if (!opts._isAgentFormat || opts.debug === true) {
45
+ return `${ROUTE_META_MARKER}${JSON.stringify(routeMeta)}`;
46
+ }
47
+
48
+ const repo = routeMeta.repoMatches === true
49
+ ? 'ok'
50
+ : routeMeta.repoMatches === false ? 'mismatch' : 'unknown';
51
+ const count = Number.isInteger(routeMeta.resultCount) && routeMeta.resultCount >= 0
52
+ ? routeMeta.resultCount
53
+ : 0;
54
+ const reason = routeMeta.sufficiencyReason || routeMeta.error;
55
+
56
+ return [
57
+ `route=${routeAtom(routeMeta.routedMode ?? routeMeta.mode)}`,
58
+ `confidence=${routeAtom(routeMeta.confidence)}`,
59
+ `sufficient=${routeSufficiency(routeMeta)}`,
60
+ `reason=${routeAtom(reason)}`,
61
+ `repo=${repo}`,
62
+ `results=${count}`,
63
+ ].join(' ');
64
+ }
65
+
66
+ /**
67
+ * Parse either the complete debug marker or the compact agent contract.
68
+ * Header telemetry is merged into compact metadata without requiring the
69
+ * verbose JSON to be present in agent context.
70
+ */
71
+ export function parseRouteMetadata(text) {
72
+ const source = typeof text === 'string' ? text : '';
73
+ const full = source.match(/<<SS_ROUTE_META>>(\{[^\r\n]*\})/);
74
+ if (full) {
75
+ try {
76
+ const parsed = JSON.parse(full[1]);
77
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // Search result bodies can contain arbitrary source text. Prefer the final
84
+ // trailer-shaped line because the engine appends its metadata after results.
85
+ const compactMatches = [...source.matchAll(COMPACT_ROUTE_RE)];
86
+ const compact = compactMatches.at(-1);
87
+ if (!compact) return null;
88
+
89
+ const verdict = compact[3] === 'YES' ? 'yes' : compact[3];
90
+ const meta = {
91
+ routedMode: compact[1],
92
+ serverUsed: true,
93
+ repoMatches: compact[5] === 'ok' ? true : compact[5] === 'mismatch' ? false : null,
94
+ resultCount: Number.parseInt(compact[6], 10),
95
+ confidence: compact[2],
96
+ sufficient: verdict === 'yes' ? true : verdict === 'no' ? false : null,
97
+ sufficiencyVerdict: verdict,
98
+ sufficiencyReason: compact[4] === 'unknown' ? null : compact[4],
99
+ };
100
+
101
+ const header = source.match(AGENT_HEADER_RE);
102
+ if (header) {
103
+ const routeConfidence = header[2] == null ? null : Number.parseFloat(header[2]);
104
+ if (Number.isFinite(routeConfidence)) meta.routeConfidence = routeConfidence;
105
+ meta.tokenBudget = Number.parseInt(header[3], 10);
106
+ meta.tokensUsed = Number.parseInt(header[4], 10);
107
+ meta.subMode = header[6];
108
+ }
109
+ return meta;
110
+ }
111
+
16
112
  /**
17
113
  * Format structural results for display
18
114
  * Uses `this` — calls this methods (none currently, but kept as regular fn).
@@ -20,6 +20,8 @@ import { applyGrepFileDiversity, matchesGrepFileFilter } from './grep-output-sha
20
20
  import { isRipgrepAvailable, runRipgrepJson } from './search-pattern-ripgrep.js';
21
21
  import { ensureSparseGramIndex } from './search-pattern-prefilter.js';
22
22
  import { packageForAgent } from './context-expander.js';
23
+ import { retryBreDialectAfterZero } from './regex-dialect.js';
24
+ import { buildIndexedGrepFamilyManifest } from './agent-pack-completion.js';
23
25
  import { applyFileKindRanking, applyResultDemotions } from '../ranking/file-kind-ranking.js';
24
26
 
25
27
  // =============================================================================
@@ -126,6 +128,24 @@ async function ensureGrepEngineAvailable(searcher, options, label) {
126
128
  );
127
129
  }
128
130
 
131
+ const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full', 'agent_full_xl']);
132
+
133
+ function isAgentFormat(options) {
134
+ return options?._isAgentFormat === true || AGENT_FORMATS.has(options?.format);
135
+ }
136
+
137
+ function shapeBareGrepMatches(candidateResult, symbolType, searcher, fileFilter) {
138
+ let matches = [
139
+ ...(candidateResult?.indexedMatches || []),
140
+ ...(candidateResult?.overlayMatches || []),
141
+ ];
142
+ matches = filterMatchesBySymbolType(matches, symbolType, searcher);
143
+ if (fileFilter) {
144
+ matches = matches.filter(match => matchesGrepFileFilter(match.file, fileFilter));
145
+ }
146
+ return matches;
147
+ }
148
+
129
149
  // =============================================================================
130
150
  // Bare grep (wired onto SweetSearch.prototype)
131
151
  // =============================================================================
@@ -150,14 +170,24 @@ export async function bareGrep(query, routing, options = {}) {
150
170
  await ensureGrepEngineAvailable(this, options, 'Bare grep');
151
171
 
152
172
  // Disable chunk gram for bare grep — bare grep uses file:line matches, not chunk IDs.
153
- const candidateResult = await generateRegexMatches(this || {}, regex, searchDir, options);
154
- let matches = [...candidateResult.indexedMatches, ...candidateResult.overlayMatches];
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
- }
173
+ let candidateResult = await generateRegexMatches(this || {}, regex, searchDir, options);
174
+ const shapeResult = result => shapeBareGrepMatches(
175
+ result, symbolType, this, options.fileFilter,
176
+ );
177
+ const dialectRetry = await retryBreDialectAfterZero({
178
+ pattern: regex,
179
+ fixedString: options.fixedString,
180
+ agentFormat: isAgentFormat(options),
181
+ originalResult: candidateResult,
182
+ shapeResult,
183
+ retry: translatedPattern => generateRegexMatches(
184
+ this || {}, translatedPattern, searchDir, options,
185
+ ),
186
+ });
187
+ candidateResult = dialectRetry.candidateResult;
188
+ // Symbol and --in filtering happen before retry adoption and before sort/cap,
189
+ // so hint counts always describe the matches this call can actually return.
190
+ let matches = dialectRetry.matches;
161
191
  matches.sort((a, b) =>
162
192
  a.file.localeCompare(b.file) ||
163
193
  a.line - b.line ||
@@ -165,6 +195,7 @@ export async function bareGrep(query, routing, options = {}) {
165
195
  );
166
196
 
167
197
  const totalMatches = matches.length;
198
+ const regexDialectHint = dialectRetry.regexDialectHint;
168
199
  // Agent-only k-budget file diversity (option-gated; absent → byte-identical
169
200
  // output). Streaming per-file cap: matches beyond the cap are counted, not
170
201
  // stored, so memory is bounded by perFileCap*maxFiles, never total matches.
@@ -183,10 +214,14 @@ export async function bareGrep(query, routing, options = {}) {
183
214
  projectRoot: searchDir,
184
215
  contextLines: options.contextLines ?? 0,
185
216
  });
217
+ const familyManifest = options._isAgentFormat === true && !options.fileFilter
218
+ ? buildIndexedGrepFamilyManifest(results, this?.codeGraphRepo)
219
+ : null;
186
220
 
187
221
  return {
188
222
  results,
189
223
  ...(fileSummary ? { fileSummary } : {}),
224
+ ...(familyManifest ? { familyManifest } : {}),
190
225
  stats: {
191
226
  path: 'grep',
192
227
  regex,
@@ -214,6 +249,7 @@ export async function bareGrep(query, routing, options = {}) {
214
249
  plannerRoute: candidateResult.stats.plannerRoute,
215
250
  gramSelectivity: candidateResult.stats.gramSelectivity,
216
251
  nativeGrepUsed: candidateResult.stats.nativeGrepUsed,
252
+ ...(regexDialectHint && { regexDialectHint }),
217
253
  symbolType,
218
254
  total_ms: Math.round(performance.now() - start),
219
255
  stageTiming: candidateResult.stats.stageTiming || null,
@@ -276,7 +312,7 @@ export async function patternSearch(query, routing, options = {}) {
276
312
  log(`Query: "${effectiveQuery}"`);
277
313
 
278
314
  const parallelStart = performance.now();
279
- const [candidateResult, encodedQuery] = await Promise.all([
315
+ let [candidateResult, encodedQuery] = await Promise.all([
280
316
  generateRegexMatches(this, regex, searchDir, { ...options, lightweightParse: true }),
281
317
  (async () => {
282
318
  const encodeStart = performance.now();
@@ -288,11 +324,23 @@ export async function patternSearch(query, routing, options = {}) {
288
324
  })(),
289
325
  ]);
290
326
  const parallelTime = performance.now() - parallelStart;
327
+ const retryOptions = { ...options, lightweightParse: true };
328
+ const dialectRetry = await retryBreDialectAfterZero({
329
+ pattern: regex,
330
+ fixedString: options.fixedString,
331
+ agentFormat: isAgentFormat(options),
332
+ originalResult: candidateResult,
333
+ retry: translatedPattern => generateRegexMatches(
334
+ this, translatedPattern, searchDir, retryOptions,
335
+ ),
336
+ });
337
+ candidateResult = dialectRetry.candidateResult;
291
338
  const grepMatches = candidateResult.indexedMatches;
292
339
  const overlayMatches = candidateResult.overlayMatches;
293
340
  const queryTokens = encodedQuery.tokens;
294
341
  const encodeTime = encodedQuery.encodeTime;
295
342
  const totalRawMatches = grepMatches.length + overlayMatches.length;
343
+ const regexDialectHint = dialectRetry.regexDialectHint;
296
344
  log(
297
345
  `Parallel phase: ${totalRawMatches} grep matches ` +
298
346
  `(${grepMatches.length} indexed, ${overlayMatches.length} overlay), ` +
@@ -328,13 +376,15 @@ export async function patternSearch(query, routing, options = {}) {
328
376
  prefilterDiscardedCount: candidateResult.stats.prefilterDiscardedCount,
329
377
  grepStrategy: candidateResult.stats.grepStrategy,
330
378
  parallelTime_ms: Math.round(parallelTime),
379
+ ...(regexDialectHint && { regexDialectHint }),
331
380
  total_ms: Math.round(performance.now() - start),
332
381
  };
333
382
 
334
383
  // Agent mode: return proper agent schema even for zero results
335
384
  if (format === 'agent' || format === 'agent_preview' || format === 'agent_full' || format === 'agent_full_xl') {
336
385
  const agentResponse = packageForAgent([], emptyStats, {
337
- query, regex, mode: 'pattern', format, tokenBudget, ablations, projectRoot: this.projectRoot || PROJECT_ROOT,
386
+ query, regex, mode: 'pattern', format, tokenBudget, ablations,
387
+ projectRoot: this.projectRoot || PROJECT_ROOT, _isAgentFormat: true,
338
388
  });
339
389
  agentResponse.stats = emptyStats;
340
390
  return agentResponse;
@@ -530,6 +580,7 @@ export async function patternSearch(query, routing, options = {}) {
530
580
  grepStrategy: candidateResult.stats.grepStrategy,
531
581
  plannerRoute: candidateResult.stats.plannerRoute,
532
582
  trackerLastIndex: candidateResult.stats.trackerLastIndex,
583
+ ...(regexDialectHint && { regexDialectHint }),
533
584
  total_ms: Math.round(totalTime),
534
585
  allCandidateIds,
535
586
  allMappedChunkIds,
@@ -549,6 +600,7 @@ export async function patternSearch(query, routing, options = {}) {
549
600
  locationMap,
550
601
  projectRoot: searchDir,
551
602
  ablations,
603
+ _isAgentFormat: true,
552
604
  });
553
605
  agentResponse.stats = stats;
554
606
  return agentResponse;