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.
- package/README.md +7 -3
- package/core/infrastructure/code-graph-repository.js +58 -0
- package/core/infrastructure/model-fetcher.js +7 -0
- package/core/search/agent-pack-completion.js +493 -0
- package/core/search/agent-span-client.js +87 -0
- package/core/search/agent-span-ledger.js +447 -0
- package/core/search/context-expander.js +29 -0
- package/core/search/grep-output-shaping.js +28 -0
- package/core/search/regex-dialect.js +328 -0
- package/core/search/search-format.js +96 -0
- package/core/search/search-pattern.js +62 -10
- package/core/search/search-read.js +57 -10
- package/core/search/search-server.js +375 -7
- package/core/search/session-daemon-prewarm.mjs +75 -0
- package/core/search/sweet-search.js +7 -1
- package/core/search/unread-symbol-ranking.js +90 -0
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +242 -43
- package/mcp/read-tool.js +34 -3
- package/mcp/server.js +55 -9
- package/mcp/tool-handlers.js +60 -7
- package/package.json +9 -9
- package/scripts/init.js +30 -5
- 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 {
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
'
|
|
272
|
-
async function cmdRead(
|
|
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
|
|
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, {
|
|
322
|
-
|
|
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
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
470
|
-
//
|
|
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
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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: [{
|
|
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,
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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, {
|
|
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) =>
|
|
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
|