sweet-search 2.6.17 → 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.
@@ -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,14 +231,31 @@ 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
260
  // Sibling-surface signal (E6, 2026-07-08 trace audit): when a symbol/stem
170
261
  // matches in more than one file, say so unconditionally in the header —
@@ -176,9 +267,11 @@ async function cmdGrep(rawArgs) {
176
267
  process.stdout.write(`# (+N more in this file)=truncated — ` +
177
268
  `see the rest: ss-grep "<regex>" --in <file>\n`);
178
269
  }
179
- 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`);
180
272
  if (body.hiddenLine) process.stdout.write(body.hiddenLine + '\n');
181
273
  if (body.shownMatches === 0) process.stdout.write('(no matches)\n');
274
+ writeRegexDialectHint(result.stats);
182
275
  process.exit(0);
183
276
  }
184
277
 
@@ -210,16 +303,33 @@ async function cmdFind(rawArgs) {
210
303
  const envFindBudget = Number(process.env.SS_SMOKE_FIND_BUDGET || '') || null;
211
304
  // Pattern flags apply to the regex candidate generator; the NL query is untouched.
212
305
  const effectiveRegex = buildGrepPattern(regex || '', { ignoreCase, wordBound, fixedString });
213
- const s = await getSweetSearch();
214
- if (!s.hasLateInteractionIndex) {
215
- process.stderr.write(`[ss-find] no late-interaction index — falling back to ss-grep\n`);
216
- 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
+ });
217
326
  }
218
- const response = await s.patternSearch(query, null, {
219
- regex: effectiveRegex || `\\b\\w+\\b`,
220
- k,
221
- format,
222
- ...(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,
223
333
  });
224
334
 
225
335
  // Header (visible to agent)
@@ -252,12 +362,22 @@ async function cmdFind(rawArgs) {
252
362
  if (r.sameFile && r.sameFile.rendered) {
253
363
  process.stdout.write(`${r.sameFile.rendered}\n`);
254
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`);
255
372
  }
256
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`);
257
377
  process.exit(0);
258
378
  }
259
379
 
260
- // 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
261
381
  // "start-end" / "start:end" / "start,end" range token). Unlike ss-grep, a stray
262
382
  // flag here can never silently corrupt the result: the line slots are validated
263
383
  // as numbers, so a misuse is already a loud error. These hints exist only to
@@ -268,8 +388,10 @@ const READ_USAGE =
268
388
  ' ss-read <file> <start> # ONE line\n' +
269
389
  ' ss-read <file> <start> <end>\n' +
270
390
  ' ss-read <file> 10-20 # range (also 10:20, 10,20)\n' +
271
- 'Note: ss-read has no flags (no -n/--limit/-r); line selection is positional.';
272
- 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']);
273
395
  const file = args[0];
274
396
  if (!file) {
275
397
  process.stderr.write(READ_USAGE + '\n');
@@ -307,19 +429,59 @@ async function cmdRead(args) {
307
429
  }
308
430
  }
309
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
+
310
452
  const { readFile, renderUnreadBelow } = await import(path.join(REPO_ROOT, 'core/search/search-read.js'));
311
453
  const r = await readFile({ path: file, projectRoot: PROJECT_ROOT, startLine: start ?? undefined, endLine: end ?? undefined });
312
454
  if (!r.ok) {
313
455
  process.stderr.write(`[ss-read] error: ${r.error}\n`);
314
456
  process.exit(1);
315
457
  }
316
- 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)`;
317
474
  const fence = r.language ? '```' + r.language : '```';
318
475
  // "What remains" trailer: on a range read that stops before EOF, one final
319
476
  // line names the symbols in the unread remainder + the exact continue
320
477
  // command (last line for recency — the actionable form of truncation).
321
- const remainder = renderUnreadBelow(r, { command: 'ss-read' });
322
- 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`);
323
485
  process.exit(0);
324
486
  }
325
487
 
@@ -336,9 +498,9 @@ async function cmdAgentSearch(rawArgs) {
336
498
  // ss-search "<query>" --mode hybrid → force a mode (default: auto/CatBoost)
337
499
  //
338
500
  // Output is agent-readable: a meta header with routed mode + budget,
339
- // followed by per-result blocks with file/line + fenced code. A trailing
340
- // structured marker line `<<SS_ROUTE_META>>{...json...}` lets the bench
341
- // 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.
342
504
  let format = 'agent';
343
505
  if (args.includes('--full')) { format = 'agent_full'; args.splice(args.indexOf('--full'), 1); }
344
506
  if (args.includes('--xl')) { format = 'agent_full_xl'; args.splice(args.indexOf('--xl'), 1); }
@@ -350,7 +512,6 @@ async function cmdAgentSearch(rawArgs) {
350
512
  process.exit(2);
351
513
  }
352
514
 
353
- const { queryServer } = await import(path.join(REPO_ROOT, 'core/search/search-server.js'));
354
515
  const serverUsed = await ensureWarmServerReady();
355
516
  if (!serverUsed) {
356
517
  process.stderr.write('[ss-search] warm server is not ready; refusing cold direct search in benchmark wrapper\n');
@@ -360,7 +521,11 @@ async function cmdAgentSearch(rawArgs) {
360
521
  // Budget-sweep experiment hook: per-request explicit budget (overrides the
361
522
  // auto-tier on the warm server; flows as the `budget` URL param).
362
523
  const envSearchBudget = Number(process.env.SS_SMOKE_SEARCH_BUDGET || '') || null;
363
- 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
+ });
364
529
  if (response?.error) {
365
530
  process.stderr.write(`[ss-search] server error: ${response.error}\n`);
366
531
  process.exit(1);
@@ -380,7 +545,7 @@ async function cmdAgentSearch(rawArgs) {
380
545
  `but server reports serverProjectRoot=${serverProjectRoot ?? '<null>'}. ` +
381
546
  `Refusing to surface cross-repo results.\n`
382
547
  );
383
- // 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.
384
549
  const failMeta = {
385
550
  query,
386
551
  queryHash: shortQueryHash(query),
@@ -397,9 +562,15 @@ async function cmdAgentSearch(rawArgs) {
397
562
  repoMatches: false,
398
563
  error: 'repo-isolation-mismatch',
399
564
  };
400
- 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`);
401
569
  process.exit(3);
402
570
  }
571
+ const shownSpans = SPAN_POLICY_ENABLED
572
+ ? collectAgentShownSpans(response.results, { projectRoot: PROJECT_ROOT }) : [];
573
+ await recordAgentToolCall({ spans: shownSpans, query });
403
574
 
404
575
  // The packaged response shape comes from packageForAgent (or pattern's own
405
576
  // packager when CatBoost routes to pattern). Both include:
@@ -460,16 +631,23 @@ async function cmdAgentSearch(rawArgs) {
460
631
  if (r.sameFile && r.sameFile.rendered) {
461
632
  process.stdout.write(`${r.sameFile.rendered}\n`);
462
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`);
463
641
  }
464
642
 
465
643
  if (!response.results || response.results.length === 0) {
466
644
  process.stdout.write('(no matches)\n');
467
645
  }
646
+ const shownTrailer = SHOWN_SPAN_TRAILER ? renderShownFullTrailer(shownSpans) : '';
647
+ if (shownTrailer) process.stdout.write(`\n${shownTrailer}\n`);
468
648
 
469
- // Structured trailer for bench post-processing (audit/summariseRun can parse).
470
- // Route attribution fields (queryHash, routeMethod, routerLatency_us, query)
471
- // let downstream analysis link a routing decision to its query and
472
- // 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.
473
651
  const meta = {
474
652
  query, // exact query text (already bounded by SEARCH_SERVER_MAX_QUERY_LENGTH)
475
653
  queryHash: shortQueryHash(query),
@@ -501,7 +679,10 @@ async function cmdAgentSearch(rawArgs) {
501
679
  sameFileMapTokens: response.results?.[0]?.sameFile?.tokens ?? null,
502
680
  sameFileNeighborCount: response.results?.[0]?.sameFile?.neighbors?.length ?? null,
503
681
  };
504
- 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`);
505
686
  process.exit(0);
506
687
  }
507
688
 
@@ -520,21 +701,36 @@ async function cmdSemantic(rawArgs) {
520
701
  process.stderr.write(SEMANTIC_USAGE + '\n');
521
702
  process.exit(2);
522
703
  }
523
- const { readSemantic } = await import(path.join(REPO_ROOT, 'core/search/search-read-semantic.js'));
524
- const r = await readSemantic({
525
- path: file, query, projectRoot: PROJECT_ROOT,
526
- maxChars: maxTokens * 4, verbose: false,
527
- });
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
+ }
528
719
  if (!r.ok) {
529
720
  process.stderr.write(`[ss-semantic] error: ${r.reason || 'unknown'}\n`);
530
721
  process.exit(1);
531
722
  }
723
+ const shownSpans = SPAN_POLICY_ENABLED
724
+ ? collectSemanticShownSpans(r, { projectRoot: PROJECT_ROOT }) : [];
725
+ await recordAgentToolCall({ spans: shownSpans, query });
532
726
  process.stdout.write(`# ss-semantic ${r.file} | "${query}" | spans=${r.spans?.length ?? 0} | ~tokens=${r.approxTokensReturned}${r.fellBack ? ' [FALLBACK]' : ''}\n`);
533
727
  for (const span of r.spans || []) {
534
728
  const fence = r.language ? '```' + r.language : '```';
535
729
  const sym = span.symbols?.length ? ` [${span.symbols.join(', ')}]` : '';
536
730
  process.stdout.write(`### ${r.file}:${span.startLine}-${span.endLine}${sym}\n${fence}\n${span.text}\n\`\`\`\n`);
537
731
  }
732
+ const shownTrailer = SHOWN_SPAN_TRAILER ? renderShownFullTrailer(shownSpans) : '';
733
+ if (shownTrailer) process.stdout.write(`${shownTrailer}\n`);
538
734
  process.exit(0);
539
735
  }
540
736
 
@@ -566,6 +762,9 @@ async function cmdTrace(rawArgs) {
566
762
  else if (Number(process.env.SS_SMOKE_TRACE_BUDGET || '') > 0) opts.tokenBudget = Number(process.env.SS_SMOKE_TRACE_BUDGET);
567
763
 
568
764
  const response = traceSymbol(symbol, opts);
765
+ await recordAgentToolCall({
766
+ query: json ? undefined : `${symbol} ${queryHint}`.trim(),
767
+ });
569
768
  if (json) process.stdout.write(JSON.stringify(response, null, 2) + '\n');
570
769
  else process.stdout.write(formatStructuralContext(response) + '\n');
571
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) {
package/mcp/server.js CHANGED
@@ -12,6 +12,12 @@ import { existsSync, statSync, readFileSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { launchMaintainer } from '../core/indexing/maintainer-launcher.mjs';
15
+ import {
16
+ collectSemanticShownSpans,
17
+ exactRereadOmissionEnabled,
18
+ validAgentSessionId,
19
+ } from '../core/search/agent-span-ledger.js';
20
+ import { sendAgentSpanOperation } from '../core/search/agent-span-client.js';
15
21
 
16
22
  import {
17
23
  SearchOutputSchema,
@@ -104,13 +110,26 @@ async function getConfig() {
104
110
 
105
111
  const coreDir = path.join(__dirname, '..', 'core');
106
112
 
107
- const searchDeps = { getSearcher };
113
+ const searchDeps = { getSearcher, PROJECT_ROOT };
108
114
  const traceDeps = { PROJECT_ROOT };
109
115
  const indexDeps = { PROJECT_ROOT, coreDir };
110
116
  const healthDeps = { getConfig, PROJECT_ROOT };
111
117
  const repoMapDeps = { coreDir, PROJECT_ROOT };
112
118
  const vocabDeps = { coreDir };
113
119
 
120
+ function requestAgentSessionId(extra) {
121
+ // A long-lived MCP process does not receive later shell-environment rotation
122
+ // after clear/compact. Never fall back to inherited process session ids.
123
+ return validAgentSessionId(extra?.sessionId) ? extra.sessionId : null;
124
+ }
125
+
126
+ async function touchAgentCall(extra, spans = [], { query, regex } = {}) {
127
+ if (!exactRereadOmissionEnabled()) return;
128
+ const sessionId = requestAgentSessionId(extra);
129
+ if (!sessionId) return;
130
+ await sendAgentSpanOperation({ operation: 'observe', sessionId, spans, query, regex });
131
+ }
132
+
114
133
  // ---------------------------------------------------------------------------
115
134
  // MCP Server
116
135
  // ---------------------------------------------------------------------------
@@ -171,7 +190,10 @@ server.registerTool('search', {
171
190
  idempotentHint: true,
172
191
  openWorldHint: false,
173
192
  },
174
- }, async (args) => handleSearch(args, searchDeps));
193
+ }, async (args, extra) => handleSearch(args, {
194
+ ...searchDeps,
195
+ agentSessionId: requestAgentSessionId(extra),
196
+ }));
175
197
 
176
198
  server.registerTool('trace', {
177
199
  description: 'Trace callers, callees, and transitive impact paths for one specific symbol — returns a single structural-context package adapted to the token budget. USE WHEN the question is "who calls X", "what does X depend on", or "what would break if I changed X". For general code discovery use `search` instead; for navigation around an unfamiliar repo use `repo-map` first.',
@@ -194,7 +216,12 @@ server.registerTool('trace', {
194
216
  idempotentHint: true,
195
217
  openWorldHint: false,
196
218
  },
197
- }, async (args) => handleTrace(args, traceDeps));
219
+ }, async (args, extra) => {
220
+ await touchAgentCall(extra, [], {
221
+ query: `${args.symbol} ${args.query || ''}`.trim(),
222
+ });
223
+ return handleTrace(args, traceDeps);
224
+ });
198
225
 
199
226
  server.registerTool('index', {
200
227
  description: 'Index or re-index the codebase. USE BEFORE first search if the project has not been indexed yet, or after large source changes (`mode="full"`). The Claude Code SessionStart hook installed by `sweet-search init` keeps the incremental index fresh during normal sessions, so manual re-indexing is rarely needed.',
@@ -209,7 +236,10 @@ server.registerTool('index', {
209
236
  idempotentHint: false,
210
237
  openWorldHint: false,
211
238
  },
212
- }, async (args) => handleIndex(args, indexDeps));
239
+ }, async (args, extra) => {
240
+ await touchAgentCall(extra);
241
+ return handleIndex(args, indexDeps);
242
+ });
213
243
 
214
244
  server.registerTool('health', {
215
245
  description: 'Check health of every sweet-search subsystem (index, embedding model, late-interaction reranker, structural graph, daemon). USE WHEN searches return empty unexpectedly, results look stale, or latency is unusual — diagnoses missing index, model load failures, daemon issues. Read-only, fast.',
@@ -220,7 +250,8 @@ server.registerTool('health', {
220
250
  idempotentHint: true,
221
251
  openWorldHint: false,
222
252
  },
223
- }, async () => {
253
+ }, async (_args, extra) => {
254
+ await touchAgentCall(extra);
224
255
  const structured = await checkHealth(healthDeps);
225
256
 
226
257
  const statusLines = Object.entries(structured.subsystems).map(
@@ -251,7 +282,10 @@ server.registerTool('repo-map', {
251
282
  idempotentHint: true,
252
283
  openWorldHint: false,
253
284
  },
254
- }, async (args) => handleRepoMap(args, repoMapDeps));
285
+ }, async (args, extra) => {
286
+ await touchAgentCall(extra);
287
+ return handleRepoMap(args, repoMapDeps);
288
+ });
255
289
 
256
290
  server.registerTool('vocab-prewarm', {
257
291
  description: 'Pre-warm sweet-search caches by mining the codebase for project-specific vocabulary across lexical / semantic / hybrid modes. USE ONCE after a fresh index to make the first batch of searches faster; generally not needed for one-off queries because the daemon-prewarm hook handles cold-start warmup automatically.',
@@ -272,7 +306,10 @@ server.registerTool('vocab-prewarm', {
272
306
  idempotentHint: true,
273
307
  openWorldHint: true,
274
308
  },
275
- }, async (args) => handleVocabPrewarm(args, vocabDeps));
309
+ }, async (args, extra) => {
310
+ await touchAgentCall(extra);
311
+ return handleVocabPrewarm(args, vocabDeps);
312
+ });
276
313
 
277
314
  server.registerTool('read', {
278
315
  description: 'Read 1-20 files (with optional line ranges) for exact code understanding. USE INSTEAD OF the native Read tool for code-discovery reads — batches multiple files in one call, attaches symbol-aware chunk metadata when the file is indexed, and returns the exact bytes from disk. Native Read remains fine for files you are about to Edit (Edit needs a prior Read of that exact file).',
@@ -284,10 +321,14 @@ server.registerTool('read', {
284
321
  })).min(1).max(20).describe('Files to read (1-20)'),
285
322
  includeMetadata: z.boolean().default(true).optional()
286
323
  .describe('Attach symbol-aware chunk metadata when the file is indexed'),
324
+ force: z.boolean().default(false).optional(),
287
325
  },
288
326
  outputSchema: ReadOutputSchema,
289
327
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
290
- }, async (args) => handleRead(args, { PROJECT_ROOT }));
328
+ }, async (args, extra) => handleRead(args, {
329
+ PROJECT_ROOT,
330
+ agentSessionId: requestAgentSessionId(extra),
331
+ }));
291
332
 
292
333
  server.registerTool('read-semantic', {
293
334
  description: 'Read only the spans of a file relevant to a question. USE WHEN you know the file but the relevant span is unclear — selects spans via hybrid retrieval (lexical + symbol + ColBERT MaxSim, RRF-fused and LI-reranked), then re-reads exact lines from disk. Returns 1-N small spans instead of the full file. Avoid running this on multiple files unless the task is explicitly multi-file — call `search` with the question instead. Falls back to a plain read when the file is not indexed.',
@@ -309,7 +350,12 @@ server.registerTool('read-semantic', {
309
350
  },
310
351
  outputSchema: ReadSemanticOutputSchema,
311
352
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
312
- }, async (args) => handleReadSemantic(args, { PROJECT_ROOT }));
353
+ }, async (args, extra) => {
354
+ const result = await handleReadSemantic(args, { PROJECT_ROOT });
355
+ const spans = collectSemanticShownSpans(result.structuredContent, { projectRoot: PROJECT_ROOT });
356
+ await touchAgentCall(extra, spans, { query: args.query });
357
+ return result;
358
+ });
313
359
 
314
360
  // ---------------------------------------------------------------------------
315
361
  // Resources