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.
@@ -15,6 +15,17 @@ import { LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
15
15
  import { clearCache } from '../embedding/embedding-cache.js';
16
16
  import { launchMaintainer } from '../indexing/maintainer-launcher.mjs';
17
17
  import { projectSocketPath, projectPidFile, tcpPort, resolveProjectRoot } from './server-identity.js';
18
+ import {
19
+ AgentSpanLedger,
20
+ applyReadOmissionDecisions,
21
+ collectAgentShownSpans,
22
+ collectReadShownSpans,
23
+ collectSemanticShownSpans,
24
+ exactRereadOmissionEnabled,
25
+ flagEnabled,
26
+ resolveAgentSessionId,
27
+ validAgentSessionId,
28
+ } from './agent-span-ledger.js';
18
29
  import {
19
30
  upsertSelf as registryUpsertSelf,
20
31
  touchSelf as registryTouchSelf,
@@ -22,6 +33,7 @@ import {
22
33
  pruneAndList as registryPruneAndList,
23
34
  selectEvictionTargets as registrySelectEvictionTargets,
24
35
  } from './daemon-registry.js';
36
+ import { renderRegexDialectHint } from './regex-dialect.js';
25
37
 
26
38
  // =============================================================================
27
39
  // Server constants
@@ -36,9 +48,19 @@ export const SEARCH_SERVER_TIMEOUT_MS = 30_000;
36
48
  export const SEARCH_SERVER_MAX_URL_LENGTH = 16_384;
37
49
  export const SEARCH_SERVER_MAX_QUERY_LENGTH = 2_000;
38
50
  export const SEARCH_SERVER_MAX_READ_PATH_LENGTH = 8_192;
51
+ const AGENT_SPAN_BODY_MAX_BYTES = 64 * 1024;
39
52
 
40
53
  const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full', 'agent_full_xl']);
41
54
 
55
+ export function resolveAgentSearchRequest(rawFormat, agentRequested = false) {
56
+ const explicit = AGENT_FORMATS.has(rawFormat) ? rawFormat : undefined;
57
+ const requested = agentRequested === true;
58
+ return {
59
+ agentFormat: explicit || (requested ? 'agent' : undefined),
60
+ renderText: requested && rawFormat === 'text' && !explicit,
61
+ };
62
+ }
63
+
42
64
  function canonicalProjectRoot(root) {
43
65
  const resolved = path.resolve(root || process.cwd());
44
66
  try {
@@ -62,6 +84,14 @@ function parseInteger(value, name) {
62
84
  return n;
63
85
  }
64
86
 
87
+ function parseBoundedSearchInteger(value, name, max) {
88
+ if (value == null || value === '') return 0;
89
+ if (!/^\d+$/.test(value)) throw new Error(`${name} must be a non-negative integer`);
90
+ const n = Number(value);
91
+ if (!Number.isSafeInteger(n) || n > max) throw new Error(`${name} must be <= ${max}`);
92
+ return n;
93
+ }
94
+
65
95
  function reusableLateInteractionIndex(searcher) {
66
96
  const idx = searcher?.lateInteractionIndex || null;
67
97
  if (!idx) return null;
@@ -78,6 +108,85 @@ function readSemanticError(status, message, extra = {}) {
78
108
  };
79
109
  }
80
110
 
111
+ export function buildAgentSpanDaemonResponse(payload, {
112
+ isUnixSocket = false,
113
+ ledger = null,
114
+ } = {}) {
115
+ if (!isUnixSocket) return readSemanticError(403, '/agent-spans is only available via Unix socket');
116
+ if (!ledger || !payload || typeof payload !== 'object' || Array.isArray(payload)) {
117
+ return readSemanticError(400, 'Invalid agent-span request');
118
+ }
119
+ const { operation, sessionId, force = false } = payload;
120
+ if (!validAgentSessionId(sessionId)) return readSemanticError(400, 'Invalid session id');
121
+ if (operation === 'reset' || operation === 'drop') {
122
+ ledger.reset(sessionId);
123
+ return { status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) };
124
+ }
125
+ if (operation !== 'observe' && operation !== 'read') {
126
+ return readSemanticError(400, 'Invalid agent-span operation');
127
+ }
128
+ if (!Array.isArray(payload.spans) || payload.spans.length > 20) {
129
+ return readSemanticError(400, 'Invalid agent-span receipts');
130
+ }
131
+ if ((payload.query != null
132
+ && (typeof payload.query !== 'string' || payload.query.length > SEARCH_SERVER_MAX_QUERY_LENGTH))
133
+ || (payload.regex != null
134
+ && (typeof payload.regex !== 'string' || payload.regex.length > SEARCH_SERVER_MAX_QUERY_LENGTH))) {
135
+ return readSemanticError(400, 'Invalid agent-span query context');
136
+ }
137
+
138
+ const call = ledger.beginCall(sessionId);
139
+ if (call == null) return readSemanticError(400, 'Invalid session id');
140
+ ledger.rememberQueryAtCall(sessionId, call, payload.query, payload.regex);
141
+ const decisions = operation === 'read'
142
+ ? ledger.decideAndObserveAtCall(sessionId, call, payload.spans, { force: force === true })
143
+ : (ledger.observeAtCall(sessionId, call, payload.spans), []);
144
+ return {
145
+ status: 200,
146
+ contentType: 'application/json',
147
+ body: JSON.stringify({
148
+ ok: true,
149
+ call,
150
+ decisions,
151
+ queryEvidence: ledger.queryEvidence(sessionId),
152
+ }),
153
+ };
154
+ }
155
+
156
+ async function readBoundedJsonBody(req, maxBytes = AGENT_SPAN_BODY_MAX_BYTES) {
157
+ const declared = Number(req.headers?.['content-length'] || 0);
158
+ if (Number.isFinite(declared) && declared > maxBytes) {
159
+ const err = new Error('Request body too large');
160
+ err.status = 413;
161
+ throw err;
162
+ }
163
+ const chunks = [];
164
+ let bytes = 0;
165
+ for await (const chunk of req) {
166
+ bytes += chunk.length;
167
+ if (bytes > maxBytes) {
168
+ const err = new Error('Request body too large');
169
+ err.status = 413;
170
+ throw err;
171
+ }
172
+ chunks.push(chunk);
173
+ }
174
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8'));
175
+ } catch {
176
+ const err = new Error('Invalid JSON body');
177
+ err.status = 400;
178
+ throw err;
179
+ }
180
+ }
181
+
182
+ function beginAgentSpanUrlCall(url, ledger, { enabled = true } = {}) {
183
+ if (!enabled || !ledger || !flagEnabled(url.searchParams.get('exactRereadOmission'))) return null;
184
+ const sessionId = url.searchParams.get('agentSessionId');
185
+ if (!validAgentSessionId(sessionId)) return null;
186
+ const call = ledger.beginCall(sessionId);
187
+ return call == null ? null : { sessionId, call };
188
+ }
189
+
81
190
  export async function buildReadSemanticDaemonResponse(reqUrl, {
82
191
  isUnixSocket = false,
83
192
  serverReady = false,
@@ -85,6 +194,7 @@ export async function buildReadSemanticDaemonResponse(reqUrl, {
85
194
  searcher = null,
86
195
  readSemanticFn = null,
87
196
  formatReadSemanticResultFn = null,
197
+ agentSpanLedger = null,
88
198
  } = {}) {
89
199
  if (!isUnixSocket) {
90
200
  return readSemanticError(403, '/read-semantic is only available via Unix socket');
@@ -141,6 +251,7 @@ export async function buildReadSemanticDaemonResponse(reqUrl, {
141
251
  return readSemanticError(400, err.message);
142
252
  }
143
253
  const verbose = url.searchParams.get('verbose') === 'true';
254
+ const agentSpanCall = beginAgentSpanUrlCall(url, agentSpanLedger, { enabled: format === 'agent' });
144
255
 
145
256
  try {
146
257
  let readSemantic = readSemanticFn;
@@ -162,6 +273,11 @@ export async function buildReadSemanticDaemonResponse(reqUrl, {
162
273
  verbose,
163
274
  _lateInteractionIndex: reusableLateInteractionIndex(searcher),
164
275
  });
276
+ if (agentSpanCall) {
277
+ const spans = collectSemanticShownSpans(result, { projectRoot: serverRoot });
278
+ agentSpanLedger.rememberQueryAtCall(agentSpanCall.sessionId, agentSpanCall.call, query);
279
+ agentSpanLedger.observeAtCall(agentSpanCall.sessionId, agentSpanCall.call, spans);
280
+ }
165
281
  const body = formatReadSemanticResult(result, format);
166
282
  return {
167
283
  status: result?.ok === false ? 404 : 200,
@@ -183,6 +299,7 @@ export async function buildTraceDaemonResponse(reqUrl, {
183
299
  serverReady = false,
184
300
  initError = null,
185
301
  searcher = null,
302
+ agentSpanLedger = null,
186
303
  } = {}) {
187
304
  if (!isUnixSocket) {
188
305
  return readSemanticError(403, '/trace is only available via Unix socket');
@@ -231,6 +348,7 @@ export async function buildTraceDaemonResponse(reqUrl, {
231
348
  } catch (err) {
232
349
  return readSemanticError(400, err.message);
233
350
  }
351
+ const agentSpanCall = beginAgentSpanUrlCall(url, agentSpanLedger, { enabled: !json });
234
352
 
235
353
  try {
236
354
  const { traceSymbol, formatStructuralContext } = await import('./search-trace.js');
@@ -243,6 +361,14 @@ export async function buildTraceDaemonResponse(reqUrl, {
243
361
  maxDepth: maxDepth ?? 3,
244
362
  tokenBudget: tokenBudget ?? null,
245
363
  });
364
+ if (agentSpanCall) {
365
+ agentSpanLedger.rememberQueryAtCall(
366
+ agentSpanCall.sessionId,
367
+ agentSpanCall.call,
368
+ `${symbol} ${queryHint}`.trim(),
369
+ );
370
+ agentSpanLedger.observeAtCall(agentSpanCall.sessionId, agentSpanCall.call, []);
371
+ }
246
372
  // handleTraceCli writes `console.log(json ? JSON : formatStructuralContext)`,
247
373
  // i.e. body + exactly one trailing newline in BOTH modes.
248
374
  const body = json ? JSON.stringify(result, null, 2) : formatStructuralContext(result);
@@ -266,6 +392,7 @@ export async function buildReadDaemonResponse(reqUrl, {
266
392
  serverReady = false,
267
393
  initError = null,
268
394
  searcher = null,
395
+ agentSpanLedger = null,
269
396
  } = {}) {
270
397
  if (!isUnixSocket) {
271
398
  return readSemanticError(403, '/read is only available via Unix socket');
@@ -291,6 +418,10 @@ export async function buildReadDaemonResponse(reqUrl, {
291
418
  const requestedRoot = url.searchParams.get('projectRoot') || '';
292
419
  const fmtParam = url.searchParams.get('format') || 'agent';
293
420
  const format = (fmtParam === 'json' || fmtParam === 'raw') ? fmtParam : 'agent';
421
+ const exactRereadOmission = format === 'agent'
422
+ && flagEnabled(url.searchParams.get('exactRereadOmission'));
423
+ const agentSessionId = url.searchParams.get('agentSessionId');
424
+ const force = url.searchParams.get('force') === 'true';
294
425
 
295
426
  if (paths.length === 0) return readSemanticError(400, 'Missing path parameter ?path=');
296
427
  if (paths.length > 20) return readSemanticError(413, 'read accepts at most 20 files');
@@ -331,7 +462,21 @@ export async function buildReadDaemonResponse(reqUrl, {
331
462
  try {
332
463
  const { readFiles, formatReadResults } = await import('./search-read.js');
333
464
  const out = await readFiles(files, { projectRoot: serverRoot, includeMetadata });
334
- const body = formatReadResults(out, format);
465
+ let queryEvidence = null;
466
+ if (exactRereadOmission && agentSpanLedger && validAgentSessionId(agentSessionId)) {
467
+ const spans = collectReadShownSpans(out, { projectRoot: serverRoot });
468
+ const agentSpanCall = beginAgentSpanUrlCall(url, agentSpanLedger);
469
+ if (agentSpanCall) {
470
+ const compactDecisions = agentSpanLedger.decideAndObserveAtCall(
471
+ agentSpanCall.sessionId, agentSpanCall.call, spans, { force },
472
+ );
473
+ const decisions = Array.from({ length: out.files.length }, () => ({ omit: false }));
474
+ spans.forEach((span, index) => { decisions[span.resultIndex] = compactDecisions[index]; });
475
+ applyReadOmissionDecisions(out, decisions);
476
+ queryEvidence = agentSpanLedger.queryEvidence(agentSpanCall.sessionId);
477
+ }
478
+ }
479
+ const body = formatReadResults(out, format, { surface: 'cli', queryEvidence });
335
480
  // handleReadCli appends '\n' for non-json output (the extra process.stdout
336
481
  // .write('\n')); json gets no trailing newline. Mirror exactly.
337
482
  const allFailed = out.files.length > 0 && out.files.every(f => !f.ok);
@@ -437,13 +582,54 @@ function buildTextSearchResponse(results, stats, totalTime, { summary = false, m
437
582
  });
438
583
  }
439
584
 
585
+ const regexDialectNote = renderRegexDialectHint(stats?.regexDialectHint);
586
+ if (regexDialectNote) out += `${regexDialectNote}\n`;
587
+
588
+ return out;
589
+ }
590
+
591
+ /** Render a packaged agent response for the native captured-output CLI. */
592
+ export function renderAgentSearchResponse(response) {
593
+ const results = response?.results || [];
594
+ const routing = response?.stats?.routing || {};
595
+ const routedMode = routing.mode || response?.mode || 'auto';
596
+ let out = `# sweet-search: routed=${routedMode} budget=${response?.tokenBudget ?? '?'} used=${response?.tokensUsed ?? '?'} results=${results.length} subMode=${response?.subMode ?? 'agent'}\n`;
597
+ if (response?.confidence) {
598
+ out += `# confidence=${response.confidence}${response.confidenceReason ? ` (${response.confidenceReason})` : ''}`;
599
+ if (response.sufficiencyVerdict) out += ` sufficient=${response.sufficiencyVerdict}`;
600
+ out += '\n';
601
+ }
602
+ for (const result of results) {
603
+ const symbol = result.symbol ? ` [${result.symbolType || 'code'}: ${result.symbol}]` : '';
604
+ const kind = result.expansionKind ? ` kind=${result.expansionKind}` : '';
605
+ const stale = result.stale ? ' STALE' : '';
606
+ out += `\n## #${result.rank} ${result.file}:${result.startLine}-${result.endLine}${symbol} (${result.presentation}${kind}${stale}) score=${(result.score || 0).toFixed(3)}\n`;
607
+ if (result.headerContext) out += `### imports\n\`\`\`\n${result.headerContext}\n\`\`\`\n`;
608
+ if (result.code) out += `\`\`\`\n${result.code}\n\`\`\`\n`;
609
+ else if (result.summary) out += `${result.summary}\n`;
610
+ if (result.neighbors?.rendered) {
611
+ out += `### related (1-hop graph, ~${result.neighbors.tokens} tok)\n${result.neighbors.rendered}\n`;
612
+ }
613
+ if (result.sameFile?.rendered) out += `${result.sameFile.rendered}\n`;
614
+ if (result.continuation?.rendered) {
615
+ out += `${result.continuation.rendered}\n`;
616
+ if (result.continuation.kind === 'symbol' && result.continuation.code) {
617
+ out += `\`\`\`\n${result.continuation.code}\n\`\`\`\n`;
618
+ }
619
+ }
620
+ if (result.familyManifest?.rendered) out += `${result.familyManifest.rendered}\n`;
621
+ }
622
+ if (results.length === 0) out += '(no matches)\n';
623
+ const regexDialectNote = renderRegexDialectHint(response?.stats?.regexDialectHint);
624
+ if (regexDialectNote) out += `${regexDialectNote}\n`;
440
625
  return out;
441
626
  }
442
627
 
443
- function buildJsonSearchResponse(results, stats, totalTime) {
628
+ function buildJsonSearchResponse(results, stats, totalTime, extra = {}) {
444
629
  return JSON.stringify({
445
630
  results,
446
631
  stats: { ...stats, server_ms: totalTime },
632
+ ...extra,
447
633
  });
448
634
  }
449
635
 
@@ -525,6 +711,7 @@ export async function startServer() {
525
711
  // Track request count for periodic cache clearing in long-running sessions.
526
712
  let requestCount = 0;
527
713
  const CACHE_CLEAR_INTERVAL = 1000; // Clear caches every 1000 requests
714
+ let agentSpanLedger = null;
528
715
 
529
716
  let tcpServer;
530
717
  let unixServer;
@@ -632,7 +819,29 @@ export async function startServer() {
632
819
  console.log(`[Server] Cache cleared after ${requestCount} requests`);
633
820
  }
634
821
 
635
- if (req.method === 'GET' && reqUrl.startsWith('/search?')) {
822
+ if (req.method === 'POST' && reqUrl === '/agent-spans') {
823
+ if (req.socket.remoteAddress) {
824
+ const response = readSemanticError(403, '/agent-spans is only available via Unix socket');
825
+ res.writeHead(response.status, { 'Content-Type': response.contentType });
826
+ res.end(response.body);
827
+ return;
828
+ }
829
+ let response;
830
+ try {
831
+ const payload = await readBoundedJsonBody(req);
832
+ response = buildAgentSpanDaemonResponse(payload, {
833
+ isUnixSocket: true,
834
+ ledger: agentSpanLedger ||= new AgentSpanLedger(),
835
+ });
836
+ if (response.status === 200 && (payload.operation === 'read' || payload.operation === 'observe')) {
837
+ lastActivityMs = Date.now();
838
+ }
839
+ } catch (err) {
840
+ response = readSemanticError(err.status || 400, err.message || 'Invalid request body');
841
+ }
842
+ res.writeHead(response.status, { 'Content-Type': response.contentType });
843
+ res.end(response.body);
844
+ } else if (req.method === 'GET' && reqUrl.startsWith('/search?')) {
636
845
  // Real query traffic — reset the idle-TTL clock (NOT /health or /stop).
637
846
  lastActivityMs = Date.now();
638
847
  if (!serverReady) {
@@ -650,9 +859,36 @@ export async function startServer() {
650
859
  return;
651
860
  }
652
861
  const url = new URL(reqUrl, `http://localhost:${SEARCH_SERVER_PORT}`);
862
+ const isUnixSocket = !req.socket.remoteAddress;
863
+ if (isUnixSocket
864
+ && (AGENT_FORMATS.has(url.searchParams.get('format'))
865
+ || url.searchParams.get('agent') === 'true')
866
+ && flagEnabled(url.searchParams.get('exactRereadOmission'))
867
+ && !agentSpanLedger) {
868
+ agentSpanLedger = new AgentSpanLedger();
869
+ }
653
870
  const query = url.searchParams.get('q') || '';
654
871
  const mode = url.searchParams.get('mode') || 'auto';
655
872
  const topK = parseInt(url.searchParams.get('k') || '10', 10);
873
+ const requestedRoot = url.searchParams.get('projectRoot') || '';
874
+ if (requestedRoot.length > SEARCH_SERVER_MAX_READ_PATH_LENGTH) {
875
+ res.writeHead(413, { 'Content-Type': 'application/json' });
876
+ res.end(JSON.stringify({ error: `Project root too long (max ${SEARCH_SERVER_MAX_READ_PATH_LENGTH} chars)` }));
877
+ return;
878
+ }
879
+ if (requestedRoot) {
880
+ const serverRoot = canonicalProjectRoot(searcher.projectRoot || process.cwd());
881
+ const clientRoot = canonicalProjectRoot(requestedRoot);
882
+ if (serverRoot !== clientRoot) {
883
+ res.writeHead(409, { 'Content-Type': 'application/json' });
884
+ res.end(JSON.stringify({
885
+ error: 'Daemon project root mismatch',
886
+ serverProjectRoot: serverRoot,
887
+ requestedProjectRoot: clientRoot,
888
+ }));
889
+ return;
890
+ }
891
+ }
656
892
 
657
893
  // Additional search options
658
894
  const expand = url.searchParams.get('expand') !== 'false';
@@ -680,6 +916,21 @@ export async function startServer() {
680
916
  }
681
917
  const maxMatches = parseInt(url.searchParams.get('maxMatches') || '0', 10);
682
918
  const contextLines = parseInt(url.searchParams.get('contextLines') || '0', 10);
919
+ const fileFilter = url.searchParams.get('fileFilter') || undefined;
920
+ if (fileFilter && fileFilter.length > SEARCH_SERVER_MAX_READ_PATH_LENGTH) {
921
+ res.writeHead(413, { 'Content-Type': 'application/json' });
922
+ res.end(JSON.stringify({ error: `File filter too long (max ${SEARCH_SERVER_MAX_READ_PATH_LENGTH} chars)` }));
923
+ return;
924
+ }
925
+ let perFileCap; let maxFiles;
926
+ try {
927
+ perFileCap = parseBoundedSearchInteger(url.searchParams.get('perFileCap'), 'perFileCap', 1000);
928
+ maxFiles = parseBoundedSearchInteger(url.searchParams.get('maxFiles'), 'maxFiles', 1000);
929
+ } catch (err) {
930
+ res.writeHead(400, { 'Content-Type': 'application/json' });
931
+ res.end(JSON.stringify({ error: err.message }));
932
+ return;
933
+ }
683
934
  const fixedString = url.searchParams.get('fixedString') === 'true';
684
935
  const symbolType = url.searchParams.get('type') || '';
685
936
  const useLiteralFilter = url.searchParams.get('literalFilter') !== 'false';
@@ -688,7 +939,12 @@ export async function startServer() {
688
939
 
689
940
  // Agent mode: context packaging (ColGrep agent format)
690
941
  const rawFormat = url.searchParams.get('format');
691
- const agentFormat = AGENT_FORMATS.has(rawFormat) ? rawFormat : undefined;
942
+ const agentRequested = url.searchParams.get('agent') === 'true';
943
+ const { agentFormat, renderText: renderAgentText } = resolveAgentSearchRequest(
944
+ rawFormat,
945
+ agentRequested,
946
+ );
947
+ const isAgentFormat = Boolean(agentFormat);
692
948
  const tokenBudget = url.searchParams.has('budget')
693
949
  ? parseInt(url.searchParams.get('budget'), 10)
694
950
  : undefined;
@@ -703,6 +959,10 @@ export async function startServer() {
703
959
  res.end(JSON.stringify({ error: `Query too long (max ${SEARCH_SERVER_MAX_QUERY_LENGTH} chars)` }));
704
960
  return;
705
961
  }
962
+ const agentSpanCall = beginAgentSpanUrlCall(url, agentSpanLedger, {
963
+ enabled: isUnixSocket && Boolean(agentFormat)
964
+ && url.searchParams.get('trackAgentSpans') !== 'false',
965
+ });
706
966
 
707
967
  try {
708
968
  const start = Date.now();
@@ -712,6 +972,9 @@ export async function startServer() {
712
972
  regex,
713
973
  maxMatches,
714
974
  contextLines,
975
+ fileFilter,
976
+ perFileCap,
977
+ maxFiles,
715
978
  fixedString,
716
979
  type: symbolType,
717
980
  globs,
@@ -721,6 +984,7 @@ export async function startServer() {
721
984
  rerank,
722
985
  fusion,
723
986
  useLateInteraction,
987
+ _isAgentFormat: isAgentFormat,
724
988
  ...(agentFormat && { format: agentFormat, tokenBudget }),
725
989
  });
726
990
 
@@ -729,11 +993,37 @@ export async function startServer() {
729
993
  // produced these results (defends against multi-repo bench reusing
730
994
  // a stale daemon — see eval/agent-read-workflows/run-bench.js).
731
995
  if (searchResult.format === 'agent') {
996
+ if (agentSpanCall) {
997
+ agentSpanLedger.rememberQueryAtCall(
998
+ agentSpanCall.sessionId,
999
+ agentSpanCall.call,
1000
+ query,
1001
+ regex,
1002
+ );
1003
+ const spans = collectAgentShownSpans(searchResult.results, {
1004
+ projectRoot: searcher.projectRoot || process.cwd(),
1005
+ });
1006
+ agentSpanLedger.observeAtCall(agentSpanCall.sessionId, agentSpanCall.call, spans);
1007
+ }
732
1008
  searchResult.serverProjectRoot = searcher.projectRoot || null;
733
1009
  searchResult.serverPid = process.pid;
734
- res.writeHead(200, { 'Content-Type': 'application/json' });
735
- res.end(JSON.stringify(searchResult));
1010
+ if (renderAgentText) {
1011
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1012
+ res.end(renderAgentSearchResponse(searchResult));
1013
+ } else {
1014
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1015
+ res.end(JSON.stringify(searchResult));
1016
+ }
736
1017
  } else {
1018
+ if (agentSpanCall) {
1019
+ agentSpanLedger.rememberQueryAtCall(
1020
+ agentSpanCall.sessionId,
1021
+ agentSpanCall.call,
1022
+ query,
1023
+ regex,
1024
+ );
1025
+ agentSpanLedger.observeAtCall(agentSpanCall.sessionId, agentSpanCall.call, []);
1026
+ }
737
1027
  let { results, stats } = searchResult;
738
1028
 
739
1029
  // Enrich with summaries if summary mode
@@ -749,7 +1039,10 @@ export async function startServer() {
749
1039
  res.end(out);
750
1040
  } else {
751
1041
  res.writeHead(200, { 'Content-Type': 'application/json' });
752
- res.end(buildJsonSearchResponse(results, stats, totalTime));
1042
+ res.end(buildJsonSearchResponse(results, stats, totalTime, {
1043
+ ...(searchResult.fileSummary ? { fileSummary: searchResult.fileSummary } : {}),
1044
+ ...(searchResult.familyManifest ? { familyManifest: searchResult.familyManifest } : {}),
1045
+ }));
753
1046
  }
754
1047
  }
755
1048
  } catch (err) {
@@ -759,6 +1052,11 @@ export async function startServer() {
759
1052
  } else if (req.method === 'GET' && reqUrl.startsWith('/read-semantic?')) {
760
1053
  // Real query traffic — reset the idle-TTL clock (NOT /health or /stop).
761
1054
  lastActivityMs = Date.now();
1055
+ if (!req.socket.remoteAddress
1056
+ && reqUrl.includes('exactRereadOmission=true')
1057
+ && !agentSpanLedger) {
1058
+ agentSpanLedger = new AgentSpanLedger();
1059
+ }
762
1060
  // read-semantic needs the indexes — wait out the cold-start init race
763
1061
  // (bounded) so a freshly-spawned daemon doesn't 503 the first request.
764
1062
  await waitForServerReady();
@@ -767,12 +1065,18 @@ export async function startServer() {
767
1065
  serverReady,
768
1066
  initError,
769
1067
  searcher,
1068
+ agentSpanLedger,
770
1069
  });
771
1070
  res.writeHead(response.status, { 'Content-Type': response.contentType });
772
1071
  res.end(response.body);
773
1072
  } else if (req.method === 'GET' && reqUrl.startsWith('/trace?')) {
774
1073
  // Real query traffic — reset the idle-TTL clock (NOT /health or /stop).
775
1074
  lastActivityMs = Date.now();
1075
+ if (!req.socket.remoteAddress
1076
+ && reqUrl.includes('exactRereadOmission=true')
1077
+ && !agentSpanLedger) {
1078
+ agentSpanLedger = new AgentSpanLedger();
1079
+ }
776
1080
  // trace needs the code-graph — wait out the cold-start init race (bounded)
777
1081
  // so a freshly-spawned daemon doesn't 503 the first request.
778
1082
  await waitForServerReady();
@@ -781,17 +1085,24 @@ export async function startServer() {
781
1085
  serverReady,
782
1086
  initError,
783
1087
  searcher,
1088
+ agentSpanLedger,
784
1089
  });
785
1090
  res.writeHead(response.status, { 'Content-Type': response.contentType });
786
1091
  res.end(response.body);
787
1092
  } else if (req.method === 'GET' && reqUrl.startsWith('/read?')) {
788
1093
  // Real query traffic — reset the idle-TTL clock (NOT /health or /stop).
789
1094
  lastActivityMs = Date.now();
1095
+ if (!req.socket.remoteAddress
1096
+ && reqUrl.includes('exactRereadOmission=true')
1097
+ && !agentSpanLedger) {
1098
+ agentSpanLedger = new AgentSpanLedger();
1099
+ }
790
1100
  const response = await buildReadDaemonResponse(reqUrl, {
791
1101
  isUnixSocket: !req.socket.remoteAddress,
792
1102
  serverReady,
793
1103
  initError,
794
1104
  searcher,
1105
+ agentSpanLedger,
795
1106
  });
796
1107
  res.writeHead(response.status, { 'Content-Type': response.contentType });
797
1108
  res.end(response.body);
@@ -978,6 +1289,9 @@ export async function queryServer(query, options = {}) {
978
1289
  topK = 10,
979
1290
  maxMatches = 0,
980
1291
  contextLines = 0,
1292
+ fileFilter,
1293
+ perFileCap = 0,
1294
+ maxFiles = 0,
981
1295
  fixedString = false,
982
1296
  type = '',
983
1297
  globs = [],
@@ -991,6 +1305,9 @@ export async function queryServer(query, options = {}) {
991
1305
  mid = false,
992
1306
  format,
993
1307
  tokenBudget,
1308
+ projectRoot,
1309
+ trackAgentSpans = true,
1310
+ _isAgentFormat = false,
994
1311
  } = options;
995
1312
 
996
1313
  return new Promise((resolve, reject) => {
@@ -1004,6 +1321,9 @@ export async function queryServer(query, options = {}) {
1004
1321
  if (regex) params.set('regex', regex);
1005
1322
  if (maxMatches > 0) params.set('maxMatches', maxMatches.toString());
1006
1323
  if (contextLines > 0) params.set('contextLines', contextLines.toString());
1324
+ if (fileFilter) params.set('fileFilter', fileFilter);
1325
+ if (perFileCap > 0) params.set('perFileCap', perFileCap.toString());
1326
+ if (maxFiles > 0) params.set('maxFiles', maxFiles.toString());
1007
1327
  if (fixedString) params.set('fixedString', 'true');
1008
1328
  if (type) params.set('type', type);
1009
1329
  if (!literalFilter) params.set('literalFilter', 'false');
@@ -1016,6 +1336,16 @@ export async function queryServer(query, options = {}) {
1016
1336
  if (mid) params.set('mid', 'true');
1017
1337
  if (format && format.startsWith('agent')) params.set('format', format);
1018
1338
  if (tokenBudget) params.set('budget', tokenBudget.toString());
1339
+ if (projectRoot) params.set('projectRoot', projectRoot);
1340
+ if (_isAgentFormat) params.set('agent', 'true');
1341
+ if (!trackAgentSpans) params.set('trackAgentSpans', 'false');
1342
+ if (trackAgentSpans && format?.startsWith('agent') && exactRereadOmissionEnabled()) {
1343
+ const sessionId = resolveAgentSessionId();
1344
+ if (sessionId) {
1345
+ params.set('exactRereadOmission', 'true');
1346
+ params.set('agentSessionId', sessionId);
1347
+ }
1348
+ }
1019
1349
 
1020
1350
  // Per-project Unix socket (C3) — the canonical local transport.
1021
1351
  const req = http.request({
@@ -1038,6 +1368,44 @@ export async function queryServer(query, options = {}) {
1038
1368
  });
1039
1369
  }
1040
1370
 
1371
+ /**
1372
+ * Query the warm daemon's read-semantic route as JSON. The caller may retain
1373
+ * its existing renderer while reusing the daemon's resident model/index.
1374
+ *
1375
+ * @param {{ path: string, query: string, projectRoot: string, maxChars?: number }} request
1376
+ * @returns {Promise<object>}
1377
+ */
1378
+ export async function queryReadSemanticServer({ path: file, query, projectRoot, maxChars } = {}) {
1379
+ if (!file || !query || !projectRoot) {
1380
+ throw new TypeError('path, query, and projectRoot are required');
1381
+ }
1382
+ const http = await import('http');
1383
+ const params = new URLSearchParams({
1384
+ path: file,
1385
+ q: query,
1386
+ projectRoot,
1387
+ format: 'json',
1388
+ });
1389
+ if (maxChars > 0) params.set('maxChars', String(maxChars));
1390
+
1391
+ return new Promise((resolve, reject) => {
1392
+ const req = http.request({
1393
+ socketPath: projectSocketPath(),
1394
+ path: `/read-semantic?${params.toString()}`,
1395
+ method: 'GET',
1396
+ }, (res) => {
1397
+ let data = '';
1398
+ res.on('data', chunk => { data += chunk; });
1399
+ res.on('end', () => {
1400
+ try { resolve(JSON.parse(data)); }
1401
+ catch { reject(new Error('Invalid server response')); }
1402
+ });
1403
+ });
1404
+ req.on('error', reject);
1405
+ req.end();
1406
+ });
1407
+ }
1408
+
1041
1409
  /**
1042
1410
  * Fetch /health from the running daemon. Returns the parsed body, or null if
1043
1411
  * the daemon is unreachable / replies non-200.