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
@@ -18,7 +18,24 @@ import {
18
18
  buildGrepPattern, stripInertFlags, normalizeArgs, extractPositional,
19
19
  parseLineRange, looksLikeOption, renderSufficiency,
20
20
  } from './_ss-argparse.mjs';
21
- import { renderGrepBody } from '../../../core/search/grep-output-shaping.js';
21
+ import {
22
+ reallocateGrepTailForManifest,
23
+ renderGrepBody,
24
+ } from '../../../core/search/grep-output-shaping.js';
25
+ import { formatRouteMetadata } from '../../../core/search/search-format.js';
26
+ import { renderRegexDialectHint } from '../../../core/search/regex-dialect.js';
27
+ import {
28
+ applyReadOmissionDecisions,
29
+ collectAgentShownSpans,
30
+ collectReadShownSpans,
31
+ collectSemanticShownSpans,
32
+ exactRereadOmissionEnabled,
33
+ renderReadOmission,
34
+ renderShownFullTrailer,
35
+ resolveAgentSessionId,
36
+ shownSpanTrailerEnabled,
37
+ } from '../../../core/search/agent-span-ledger.js';
38
+ import { sendAgentSpanOperation } from '../../../core/search/agent-span-client.js';
22
39
 
23
40
  // Diagnostic-log isolation (agent-facing tools). The Sweet Search engine emits
24
41
  // model/index load banners via console.log → stdout ("LateInteraction: Loaded…",
@@ -54,6 +71,29 @@ if (!existsSync(path.join(PROJECT_ROOT, '.sweet-search', 'codebase.db'))) {
54
71
  }
55
72
  process.env.SWEET_SEARCH_PROJECT_ROOT = PROJECT_ROOT;
56
73
 
74
+ const AGENT_SESSION_ID = resolveAgentSessionId();
75
+ const EXACT_REREAD_OMISSION = exactRereadOmissionEnabled();
76
+ const SHOWN_SPAN_TRAILER = shownSpanTrailerEnabled();
77
+ const SPAN_POLICY_ENABLED = EXACT_REREAD_OMISSION || SHOWN_SPAN_TRAILER;
78
+
79
+ async function recordAgentToolCall({
80
+ operation = 'observe',
81
+ spans = [],
82
+ force = false,
83
+ query,
84
+ regex,
85
+ } = {}) {
86
+ if (!EXACT_REREAD_OMISSION || !AGENT_SESSION_ID) return null;
87
+ return sendAgentSpanOperation({
88
+ operation,
89
+ spans,
90
+ force,
91
+ sessionId: AGENT_SESSION_ID,
92
+ query,
93
+ regex,
94
+ });
95
+ }
96
+
57
97
  const subcommand = process.argv[2];
58
98
  const rest = process.argv.slice(3);
59
99
 
@@ -114,6 +154,26 @@ async function ensureWarmServerReady({ timeoutMs = 60000, intervalMs = 500 } = {
114
154
  return false;
115
155
  }
116
156
 
157
+ async function queryWarmSearch(query, options) {
158
+ if (!await ensureWarmServerReady({ timeoutMs: 5000 })) {
159
+ throw new Error('warm server is not ready');
160
+ }
161
+ const { queryServer } = await import(path.join(REPO_ROOT, 'core/search/search-server.js'));
162
+ const response = await queryServer(query, {
163
+ ...options,
164
+ projectRoot: PROJECT_ROOT,
165
+ trackAgentSpans: false,
166
+ _isAgentFormat: options._isAgentFormat ?? true,
167
+ });
168
+ if (response?.error) throw new Error(response.error);
169
+ return response;
170
+ }
171
+
172
+ function writeRegexDialectHint(stats) {
173
+ const note = renderRegexDialectHint(stats?.regexDialectHint);
174
+ if (note) process.stdout.write(`${note}\n`);
175
+ }
176
+
117
177
  // --- subcommands ----------------------------------------------------------
118
178
 
119
179
  const GREP_USAGE = 'Usage: ss-grep <regex> [-i|--ignore-case] [-w|--word-regexp] [-F|--fixed-strings] [--in <file>] [-k N]';
@@ -132,14 +192,27 @@ async function cmdGrep(rawArgs) {
132
192
  process.stderr.write(GREP_USAGE + '\n');
133
193
  process.exit(2);
134
194
  }
135
- const s = await getSweetSearch();
136
-
137
195
  if (inFile) {
138
196
  // 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
- });
197
+ let result;
198
+ try {
199
+ result = await queryWarmSearch(regex, {
200
+ mode: 'grep', regex, maxMatches: k, contextLines: 0,
201
+ fileFilter: inFile, expand: false, rerank: false, useLateInteraction: false,
202
+ _isAgentFormat: !fixedString,
203
+ });
204
+ } catch {
205
+ const s = await getSweetSearch();
206
+ result = await s.bareGrep(regex, null, {
207
+ regex, maxMatches: k, contextLines: 0, fileFilter: inFile,
208
+ _isAgentFormat: !fixedString,
209
+ });
210
+ }
142
211
  const total = result.stats?.totalMatches ?? result.results.length;
212
+ await recordAgentToolCall({
213
+ query: fixedString ? undefined : regex,
214
+ regex: fixedString ? undefined : regex,
215
+ });
143
216
  process.stdout.write(`# ss-grep: ${total} total match(es) for /${regex}/ (scope: --in ${inFile})\n`);
144
217
  result.results.forEach((r, i) => {
145
218
  const text = (r.matchText || '').replace(/\s+/g, ' ').trim().slice(0, 140);
@@ -148,6 +221,7 @@ async function cmdGrep(rawArgs) {
148
221
  process.stdout.write(`${r.file}:${r.line}: ${text}${marker}\n`);
149
222
  });
150
223
  if (result.results.length === 0) process.stdout.write('(no matches)\n');
224
+ writeRegexDialectHint(result.stats);
151
225
  process.exit(0);
152
226
  }
153
227
 
@@ -157,23 +231,47 @@ async function cmdGrep(rawArgs) {
157
231
  // flooded file can never hide every other matching file (the gradethis-161
158
232
  // failure). Rendering stays grouped per file; truncation is marked inline
159
233
  // 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
- });
234
+ let result;
235
+ try {
236
+ result = await queryWarmSearch(regex, {
237
+ mode: 'grep', regex, maxMatches: 0, contextLines: 0,
238
+ perFileCap: Math.min(k, 100), maxFiles: k,
239
+ expand: false, rerank: false, useLateInteraction: false,
240
+ _isAgentFormat: !fixedString,
241
+ });
242
+ } catch {
243
+ const s = await getSweetSearch();
244
+ result = await s.bareGrep(regex, null, {
245
+ regex, maxMatches: 0, contextLines: 0,
246
+ perFileCap: Math.min(k, 100), maxFiles: k,
247
+ _isAgentFormat: !fixedString,
248
+ });
249
+ }
164
250
  const total = result.stats?.totalMatches ?? result.results.length;
165
251
  const fileSummary = result.fileSummary
166
252
  || { files: [], hiddenFileCount: 0, hiddenMatchCount: 0, hiddenSample: [] };
167
253
  const body = renderGrepBody(result.results, fileSummary, k);
254
+ const completed = reallocateGrepTailForManifest(body.lines, result.familyManifest);
255
+ await recordAgentToolCall({
256
+ query: fixedString ? undefined : regex,
257
+ regex: fixedString ? undefined : regex,
258
+ });
168
259
 
169
- process.stdout.write(`# ss-grep: ${total} total match(es) for /${regex}/\n`);
260
+ // Sibling-surface signal (E6, 2026-07-08 trace audit): when a symbol/stem
261
+ // matches in more than one file, say so unconditionally in the header —
262
+ // the file count is the objective "visible siblings" trigger for
263
+ // fix-surface mapping, and it must not depend on truncation having occurred.
264
+ const across = body.matchedFileCount > 1 ? ` across ${body.matchedFileCount} files` : '';
265
+ process.stdout.write(`# ss-grep: ${total} total match(es) for /${regex}/${across}\n`);
170
266
  if (body.truncatedFileCount > 0 || body.hiddenLine) {
171
- process.stdout.write(`# ${body.matchedFileCount} file(s) matched; (+N more in this file)=truncated — ` +
267
+ process.stdout.write(`# (+N more in this file)=truncated — ` +
172
268
  `see the rest: ss-grep "<regex>" --in <file>\n`);
173
269
  }
174
- for (const line of body.lines) process.stdout.write(line + '\n');
270
+ for (const line of completed.lines) process.stdout.write(line + '\n');
271
+ if (completed.familyManifest) process.stdout.write(`${completed.familyManifest.rendered}\n`);
175
272
  if (body.hiddenLine) process.stdout.write(body.hiddenLine + '\n');
176
273
  if (body.shownMatches === 0) process.stdout.write('(no matches)\n');
274
+ writeRegexDialectHint(result.stats);
177
275
  process.exit(0);
178
276
  }
179
277
 
@@ -205,16 +303,33 @@ async function cmdFind(rawArgs) {
205
303
  const envFindBudget = Number(process.env.SS_SMOKE_FIND_BUDGET || '') || null;
206
304
  // Pattern flags apply to the regex candidate generator; the NL query is untouched.
207
305
  const effectiveRegex = buildGrepPattern(regex || '', { ignoreCase, wordBound, fixedString });
208
- const s = await getSweetSearch();
209
- if (!s.hasLateInteractionIndex) {
210
- process.stderr.write(`[ss-find] no late-interaction index — falling back to ss-grep\n`);
211
- return cmdGrep([effectiveRegex || query, '-k', String(k)]);
306
+ let response;
307
+ try {
308
+ response = await queryWarmSearch(query, {
309
+ mode: 'pattern', regex: effectiveRegex || `\\b\\w+\\b`, topK: k, format,
310
+ _isAgentFormat: !fixedString,
311
+ ...(envFindBudget ? { tokenBudget: envFindBudget } : {}),
312
+ });
313
+ } catch {
314
+ const s = await getSweetSearch();
315
+ if (!s.hasLateInteractionIndex) {
316
+ process.stderr.write(`[ss-find] no late-interaction index — falling back to ss-grep\n`);
317
+ return cmdGrep([effectiveRegex || query, '-k', String(k)]);
318
+ }
319
+ response = await s.patternSearch(query, null, {
320
+ regex: effectiveRegex || `\\b\\w+\\b`,
321
+ k,
322
+ format,
323
+ _isAgentFormat: !fixedString,
324
+ ...(envFindBudget ? { tokenBudget: envFindBudget } : {}),
325
+ });
212
326
  }
213
- const response = await s.patternSearch(query, null, {
214
- regex: effectiveRegex || `\\b\\w+\\b`,
215
- k,
216
- format,
217
- ...(envFindBudget ? { tokenBudget: envFindBudget } : {}),
327
+ const shownSpans = SPAN_POLICY_ENABLED
328
+ ? collectAgentShownSpans(response.results, { projectRoot: PROJECT_ROOT }) : [];
329
+ await recordAgentToolCall({
330
+ spans: shownSpans,
331
+ query: fixedString ? undefined : query,
332
+ regex: fixedString ? undefined : effectiveRegex,
218
333
  });
219
334
 
220
335
  // Header (visible to agent)
@@ -247,12 +362,22 @@ async function cmdFind(rawArgs) {
247
362
  if (r.sameFile && r.sameFile.rendered) {
248
363
  process.stdout.write(`${r.sameFile.rendered}\n`);
249
364
  }
365
+ if (r.continuation?.rendered) {
366
+ process.stdout.write(`${r.continuation.rendered}\n`);
367
+ if (r.continuation.kind === 'symbol' && r.continuation.code) {
368
+ process.stdout.write(`\`\`\`\n${r.continuation.code}\n\`\`\`\n`);
369
+ }
370
+ }
371
+ if (r.familyManifest?.rendered) process.stdout.write(`${r.familyManifest.rendered}\n`);
250
372
  }
251
373
  if (!response.results || response.results.length === 0) process.stdout.write('(no matches)\n');
374
+ writeRegexDialectHint(response.stats);
375
+ const shownTrailer = SHOWN_SPAN_TRAILER ? renderShownFullTrailer(shownSpans) : '';
376
+ if (shownTrailer) process.stdout.write(`\n${shownTrailer}\n`);
252
377
  process.exit(0);
253
378
  }
254
379
 
255
- // ss-read takes NO flags only positional <file> [start] [end] (or a single
380
+ // ss-read takes one recovery flag plus positional <file> [start] [end] (or a single
256
381
  // "start-end" / "start:end" / "start,end" range token). Unlike ss-grep, a stray
257
382
  // flag here can never silently corrupt the result: the line slots are validated
258
383
  // as numbers, so a misuse is already a loud error. These hints exist only to
@@ -263,8 +388,10 @@ const READ_USAGE =
263
388
  ' ss-read <file> <start> # ONE line\n' +
264
389
  ' ss-read <file> <start> <end>\n' +
265
390
  ' ss-read <file> 10-20 # range (also 10:20, 10,20)\n' +
266
- 'Note: ss-read has no flags (no -n/--limit/-r); line selection is positional.';
267
- async function cmdRead(args) {
391
+ 'Option: --force shows content again after an unchanged-content omission.';
392
+ async function cmdRead(rawArgs) {
393
+ const args = [...rawArgs];
394
+ const force = parseBoolFlag(args, ['--force']);
268
395
  const file = args[0];
269
396
  if (!file) {
270
397
  process.stderr.write(READ_USAGE + '\n');
@@ -302,19 +429,59 @@ async function cmdRead(args) {
302
429
  }
303
430
  }
304
431
  }
432
+ // L4a (2026-07-09) — default read window. An unbounded whole-file read (no range
433
+ // given) delivers the ENTIRE file, which then sits RESIDENT in the agent's context
434
+ // and is re-sent every subsequent turn. P1 measured this read-mass resident tax at
435
+ // ~$11/200 (whole-file/large reads are the tail: p90=180, max=900 lines). So when
436
+ // the agent gives NO range, cap the default to READ_WINDOW lines and let the
437
+ // existing "what remains" trailer advertise the exact continue command — the agent
438
+ // widens on demand instead of paying for the whole file up front. Only the no-range
439
+ // case is capped: an EXPLICIT range is the agent's deliberate choice and capping it
440
+ // would cause widen-thrash (the RETUNE hazard). GCSN-neutral by construction (reads
441
+ // are never ranked). Gated for the standing ON-vs-OFF A/B: SS_NO_READ_WINDOW=1 → OFF
442
+ // (legacy whole-file); SS_READ_WINDOW=<n> retunes the tier. Prod/human ss-read is a
443
+ // separate wrapper and is untouched (byte-identical).
444
+ // PARKED default-OFF (2026-07-09 smoke): the mechanism works (−39% delivered read
445
+ // tokens vs off) and is accuracy-safe (no resolved→unresolved flips), but the ~$11/200
446
+ // pool is too small to show a net idealCost win at n=2 and one read-thrash instance
447
+ // appeared at window=150. Opt-in via SS_READ_WINDOW=<n> pending a larger-n confirmation.
448
+ const READ_WINDOW = Number(process.env.SS_READ_WINDOW) || 0;
449
+ const cappedDefault = (start === null && end === null && READ_WINDOW > 0);
450
+ if (cappedDefault) { start = 1; end = READ_WINDOW; }
451
+
305
452
  const { readFile, renderUnreadBelow } = await import(path.join(REPO_ROOT, 'core/search/search-read.js'));
306
453
  const r = await readFile({ path: file, projectRoot: PROJECT_ROOT, startLine: start ?? undefined, endLine: end ?? undefined });
307
454
  if (!r.ok) {
308
455
  process.stderr.write(`[ss-read] error: ${r.error}\n`);
309
456
  process.exit(1);
310
457
  }
311
- const range = r.range ? ` (lines ${r.range.startLine}-${r.range.endLine} of ${r.totalLines})` : ` (${r.totalLines} lines)`;
458
+ const readBatch = { files: [r], totalMs: r.timings?.totalMs ?? 0 };
459
+ const shownSpans = EXACT_REREAD_OMISSION
460
+ ? collectReadShownSpans(readBatch, { projectRoot: PROJECT_ROOT }) : [];
461
+ const receiptResponse = await recordAgentToolCall({
462
+ operation: 'read',
463
+ spans: shownSpans,
464
+ force,
465
+ });
466
+ if (receiptResponse?.ok && Array.isArray(receiptResponse.decisions)) {
467
+ applyReadOmissionDecisions(readBatch, receiptResponse.decisions);
468
+ }
469
+ // If the window happened to cover the whole file (file ≤ READ_WINDOW, clamped by
470
+ // readFile), present it EXACTLY like an uncapped whole-file read — no synthetic
471
+ // range, no continue trailer — so small-file reads stay byte-identical to legacy.
472
+ const coveredWholeFile = (start === null && end === null) || (cappedDefault && r.range && r.range.endLine >= r.totalLines);
473
+ const range = (r.range && !coveredWholeFile) ? ` (lines ${r.range.startLine}-${r.range.endLine} of ${r.totalLines})` : ` (${r.totalLines} lines)`;
312
474
  const fence = r.language ? '```' + r.language : '```';
313
475
  // "What remains" trailer: on a range read that stops before EOF, one final
314
476
  // line names the symbols in the unread remainder + the exact continue
315
477
  // command (last line for recency — the actionable form of truncation).
316
- const remainder = renderUnreadBelow(r, { command: 'ss-read' });
317
- process.stdout.write(`# ss-read ${r.file}${range}\n${fence}\n${r.text}\n\`\`\`${remainder ? '\n' + remainder : ''}\n`);
478
+ const remainder = coveredWholeFile ? '' : renderUnreadBelow(r, {
479
+ command: 'ss-read',
480
+ queryEvidence: receiptResponse?.queryEvidence,
481
+ });
482
+ const omitted = renderReadOmission(r, { surface: 'ss-read' });
483
+ if (omitted) process.stdout.write(`# ss-read ${r.file}${range}\n${omitted}\n`);
484
+ else process.stdout.write(`# ss-read ${r.file}${range}\n${fence}\n${r.text}\n\`\`\`${remainder ? '\n' + remainder : ''}\n`);
318
485
  process.exit(0);
319
486
  }
320
487
 
@@ -331,9 +498,9 @@ async function cmdAgentSearch(rawArgs) {
331
498
  // ss-search "<query>" --mode hybrid → force a mode (default: auto/CatBoost)
332
499
  //
333
500
  // Output is agent-readable: a meta header with routed mode + budget,
334
- // followed by per-result blocks with file/line + fenced code. A trailing
335
- // structured marker line `<<SS_ROUTE_META>>{...json...}` lets the bench
336
- // post-process parse routing/budget telemetry without affecting the agent.
501
+ // followed by per-result blocks with file/line + fenced code and one compact
502
+ // actionable route trailer. SWEET_SEARCH_ROUTE_META_DEBUG=1 restores the
503
+ // complete JSON trailer for routing diagnostics.
337
504
  let format = 'agent';
338
505
  if (args.includes('--full')) { format = 'agent_full'; args.splice(args.indexOf('--full'), 1); }
339
506
  if (args.includes('--xl')) { format = 'agent_full_xl'; args.splice(args.indexOf('--xl'), 1); }
@@ -345,7 +512,6 @@ async function cmdAgentSearch(rawArgs) {
345
512
  process.exit(2);
346
513
  }
347
514
 
348
- const { queryServer } = await import(path.join(REPO_ROOT, 'core/search/search-server.js'));
349
515
  const serverUsed = await ensureWarmServerReady();
350
516
  if (!serverUsed) {
351
517
  process.stderr.write('[ss-search] warm server is not ready; refusing cold direct search in benchmark wrapper\n');
@@ -355,7 +521,11 @@ async function cmdAgentSearch(rawArgs) {
355
521
  // Budget-sweep experiment hook: per-request explicit budget (overrides the
356
522
  // auto-tier on the warm server; flows as the `budget` URL param).
357
523
  const envSearchBudget = Number(process.env.SS_SMOKE_SEARCH_BUDGET || '') || null;
358
- const response = await queryServer(query, { topK: k, mode, format, ...(envSearchBudget ? { tokenBudget: envSearchBudget } : {}) });
524
+ const { queryServer } = await import(path.join(REPO_ROOT, 'core/search/search-server.js'));
525
+ const response = await queryServer(query, {
526
+ topK: k, mode, format, projectRoot: PROJECT_ROOT, trackAgentSpans: false,
527
+ ...(envSearchBudget ? { tokenBudget: envSearchBudget } : {}),
528
+ });
359
529
  if (response?.error) {
360
530
  process.stderr.write(`[ss-search] server error: ${response.error}\n`);
361
531
  process.exit(1);
@@ -375,7 +545,7 @@ async function cmdAgentSearch(rawArgs) {
375
545
  `but server reports serverProjectRoot=${serverProjectRoot ?? '<null>'}. ` +
376
546
  `Refusing to surface cross-repo results.\n`
377
547
  );
378
- // Emit a structured trailer anyway so the bench can capture the failure.
548
+ // Emit a route trailer so the mismatch remains explicit in agent output.
379
549
  const failMeta = {
380
550
  query,
381
551
  queryHash: shortQueryHash(query),
@@ -392,9 +562,15 @@ async function cmdAgentSearch(rawArgs) {
392
562
  repoMatches: false,
393
563
  error: 'repo-isolation-mismatch',
394
564
  };
395
- process.stdout.write(`\n<<SS_ROUTE_META>>${JSON.stringify(failMeta)}\n`);
565
+ process.stdout.write(`\n${formatRouteMetadata(failMeta, {
566
+ _isAgentFormat: true,
567
+ debug: process.env.SWEET_SEARCH_ROUTE_META_DEBUG === '1',
568
+ })}\n`);
396
569
  process.exit(3);
397
570
  }
571
+ const shownSpans = SPAN_POLICY_ENABLED
572
+ ? collectAgentShownSpans(response.results, { projectRoot: PROJECT_ROOT }) : [];
573
+ await recordAgentToolCall({ spans: shownSpans, query });
398
574
 
399
575
  // The packaged response shape comes from packageForAgent (or pattern's own
400
576
  // packager when CatBoost routes to pattern). Both include:
@@ -455,16 +631,23 @@ async function cmdAgentSearch(rawArgs) {
455
631
  if (r.sameFile && r.sameFile.rendered) {
456
632
  process.stdout.write(`${r.sameFile.rendered}\n`);
457
633
  }
634
+ if (r.continuation?.rendered) {
635
+ process.stdout.write(`${r.continuation.rendered}\n`);
636
+ if (r.continuation.kind === 'symbol' && r.continuation.code) {
637
+ process.stdout.write(`\`\`\`\n${r.continuation.code}\n\`\`\`\n`);
638
+ }
639
+ }
640
+ if (r.familyManifest?.rendered) process.stdout.write(`${r.familyManifest.rendered}\n`);
458
641
  }
459
642
 
460
643
  if (!response.results || response.results.length === 0) {
461
644
  process.stdout.write('(no matches)\n');
462
645
  }
646
+ const shownTrailer = SHOWN_SPAN_TRAILER ? renderShownFullTrailer(shownSpans) : '';
647
+ if (shownTrailer) process.stdout.write(`\n${shownTrailer}\n`);
463
648
 
464
- // Structured trailer for bench post-processing (audit/summariseRun can parse).
465
- // Route attribution fields (queryHash, routeMethod, routerLatency_us, query)
466
- // let downstream analysis link a routing decision to its query and
467
- // attribute failures to fast-path vs WASM vs fallback.
649
+ // Keep complete metadata available to the debug serializer, while normal
650
+ // agent output receives only the fields that can change its next action.
468
651
  const meta = {
469
652
  query, // exact query text (already bounded by SEARCH_SERVER_MAX_QUERY_LENGTH)
470
653
  queryHash: shortQueryHash(query),
@@ -496,7 +679,10 @@ async function cmdAgentSearch(rawArgs) {
496
679
  sameFileMapTokens: response.results?.[0]?.sameFile?.tokens ?? null,
497
680
  sameFileNeighborCount: response.results?.[0]?.sameFile?.neighbors?.length ?? null,
498
681
  };
499
- process.stdout.write(`\n<<SS_ROUTE_META>>${JSON.stringify(meta)}\n`);
682
+ process.stdout.write(`\n${formatRouteMetadata(meta, {
683
+ _isAgentFormat: true,
684
+ debug: process.env.SWEET_SEARCH_ROUTE_META_DEBUG === '1',
685
+ })}\n`);
500
686
  process.exit(0);
501
687
  }
502
688
 
@@ -515,21 +701,36 @@ async function cmdSemantic(rawArgs) {
515
701
  process.stderr.write(SEMANTIC_USAGE + '\n');
516
702
  process.exit(2);
517
703
  }
518
- const { readSemantic } = await import(path.join(REPO_ROOT, 'core/search/search-read-semantic.js'));
519
- const r = await readSemantic({
520
- path: file, query, projectRoot: PROJECT_ROOT,
521
- maxChars: maxTokens * 4, verbose: false,
522
- });
704
+ let r;
705
+ try {
706
+ if (!await ensureWarmServerReady({ timeoutMs: 5000 })) throw new Error('warm server is not ready');
707
+ const { queryReadSemanticServer } = await import(path.join(REPO_ROOT, 'core/search/search-server.js'));
708
+ r = await queryReadSemanticServer({
709
+ path: file, query, projectRoot: PROJECT_ROOT, maxChars: maxTokens * 4,
710
+ });
711
+ if (r?.error) throw new Error(r.error);
712
+ } catch {
713
+ const { readSemantic } = await import(path.join(REPO_ROOT, 'core/search/search-read-semantic.js'));
714
+ r = await readSemantic({
715
+ path: file, query, projectRoot: PROJECT_ROOT,
716
+ maxChars: maxTokens * 4, verbose: false,
717
+ });
718
+ }
523
719
  if (!r.ok) {
524
720
  process.stderr.write(`[ss-semantic] error: ${r.reason || 'unknown'}\n`);
525
721
  process.exit(1);
526
722
  }
723
+ const shownSpans = SPAN_POLICY_ENABLED
724
+ ? collectSemanticShownSpans(r, { projectRoot: PROJECT_ROOT }) : [];
725
+ await recordAgentToolCall({ spans: shownSpans, query });
527
726
  process.stdout.write(`# ss-semantic ${r.file} | "${query}" | spans=${r.spans?.length ?? 0} | ~tokens=${r.approxTokensReturned}${r.fellBack ? ' [FALLBACK]' : ''}\n`);
528
727
  for (const span of r.spans || []) {
529
728
  const fence = r.language ? '```' + r.language : '```';
530
729
  const sym = span.symbols?.length ? ` [${span.symbols.join(', ')}]` : '';
531
730
  process.stdout.write(`### ${r.file}:${span.startLine}-${span.endLine}${sym}\n${fence}\n${span.text}\n\`\`\`\n`);
532
731
  }
732
+ const shownTrailer = SHOWN_SPAN_TRAILER ? renderShownFullTrailer(shownSpans) : '';
733
+ if (shownTrailer) process.stdout.write(`${shownTrailer}\n`);
533
734
  process.exit(0);
534
735
  }
535
736
 
@@ -561,6 +762,9 @@ async function cmdTrace(rawArgs) {
561
762
  else if (Number(process.env.SS_SMOKE_TRACE_BUDGET || '') > 0) opts.tokenBudget = Number(process.env.SS_SMOKE_TRACE_BUDGET);
562
763
 
563
764
  const response = traceSymbol(symbol, opts);
765
+ await recordAgentToolCall({
766
+ query: json ? undefined : `${symbol} ${queryHint}`.trim(),
767
+ });
564
768
  if (json) process.stdout.write(JSON.stringify(response, null, 2) + '\n');
565
769
  else process.stdout.write(formatStructuralContext(response) + '\n');
566
770
 
package/mcp/read-tool.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { z } from 'zod';
2
+ import {
3
+ applyReadOmissionDecisions,
4
+ collectReadShownSpans,
5
+ exactRereadOmissionEnabled,
6
+ } from '../core/search/agent-span-ledger.js';
7
+ import { sendAgentSpanOperation } from '../core/search/agent-span-client.js';
2
8
 
3
9
  const ReadFileResultSchema = z.object({
4
10
  file: z.string(),
@@ -33,6 +39,12 @@ const ReadFileResultSchema = z.object({
33
39
  })),
34
40
  moreCount: z.number().int(),
35
41
  }).nullable().optional(),
42
+ omitted: z.object({
43
+ file: z.string(),
44
+ startLine: z.number().int(),
45
+ endLine: z.number().int(),
46
+ callsAgo: z.number().int(),
47
+ }).optional(),
36
48
  error: z.string().optional(),
37
49
  timings: z.object({ totalMs: z.number() }).optional(),
38
50
  });
@@ -70,8 +82,8 @@ export const ReadSemanticOutputSchema = z.object({
70
82
  });
71
83
 
72
84
  /**
73
- * @param {{ files: Array<{path: string, startLine?: number, endLine?: number}>, includeMetadata?: boolean }} args
74
- * @param {{ PROJECT_ROOT: string }} deps
85
+ * @param {{ files: Array<{path: string, startLine?: number, endLine?: number}>, includeMetadata?: boolean, force?: boolean }} args
86
+ * @param {{ PROJECT_ROOT: string, agentSessionId?: string }} deps
75
87
  */
76
88
  export async function handleRead(args, deps) {
77
89
  try {
@@ -80,8 +92,27 @@ export async function handleRead(args, deps) {
80
92
  projectRoot: deps.PROJECT_ROOT,
81
93
  includeMetadata: args.includeMetadata !== false,
82
94
  });
95
+ let queryEvidence = null;
96
+ if (exactRereadOmissionEnabled() && deps.agentSessionId) {
97
+ const spans = collectReadShownSpans(result, { projectRoot: deps.PROJECT_ROOT });
98
+ const response = await sendAgentSpanOperation({
99
+ operation: 'read',
100
+ sessionId: deps.agentSessionId,
101
+ spans,
102
+ force: args.force === true,
103
+ });
104
+ if (response?.ok && Array.isArray(response.decisions)) {
105
+ const decisions = Array.from({ length: result.files.length }, () => ({ omit: false }));
106
+ spans.forEach((span, index) => { decisions[span.resultIndex] = response.decisions[index]; });
107
+ applyReadOmissionDecisions(result, decisions);
108
+ }
109
+ queryEvidence = response?.queryEvidence || null;
110
+ }
83
111
  return {
84
- content: [{ type: 'text', text: formatReadResults(result, 'agent') }],
112
+ content: [{
113
+ type: 'text',
114
+ text: formatReadResults(result, 'agent', { surface: 'mcp', queryEvidence }),
115
+ }],
85
116
  structuredContent: result,
86
117
  };
87
118
  } catch (err) {