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.
@@ -0,0 +1,87 @@
1
+ /** Unix-socket adapter for the daemon-owned agent shown-span ledger. */
2
+
3
+ import http from 'node:http';
4
+ import { projectSocketPath } from './server-identity.js';
5
+ import { resolveAgentSessionId } from './agent-span-ledger.js';
6
+
7
+ const MAX_BODY_BYTES = 64 * 1024;
8
+
9
+ export function buildAgentSpanRequestPayload({
10
+ operation,
11
+ spans = [],
12
+ force = false,
13
+ sessionId,
14
+ query,
15
+ regex,
16
+ } = {}) {
17
+ const resolvedSessionId = resolveAgentSessionId(sessionId);
18
+ if (!resolvedSessionId) return null;
19
+ const boundedQuery = typeof query === 'string' && query.length <= 2000 ? query : undefined;
20
+ const boundedRegex = typeof regex === 'string' && regex.length <= 2000 ? regex : undefined;
21
+ return {
22
+ operation,
23
+ sessionId: resolvedSessionId,
24
+ force: force === true,
25
+ spans: Array.isArray(spans) ? spans.slice(0, 20).map((span) => ({ ...span })) : [],
26
+ ...(boundedQuery ? { query: boundedQuery } : {}),
27
+ ...(boundedRegex ? { regex: boundedRegex } : {}),
28
+ };
29
+ }
30
+
31
+ function serializeBoundedPayload(payload) {
32
+ let body = JSON.stringify(payload);
33
+ if (Buffer.byteLength(body) <= MAX_BODY_BYTES) return body;
34
+
35
+ // Preserve containment evidence for as many receipts as possible. Remove
36
+ // the largest per-line digest packs first until the local request fits.
37
+ const candidates = payload.spans
38
+ .map((span, index) => ({
39
+ index,
40
+ bytes: typeof span?.lineHashes === 'string' ? Buffer.byteLength(span.lineHashes) : 0,
41
+ }))
42
+ .filter(({ bytes }) => bytes > 0)
43
+ .sort((a, b) => b.bytes - a.bytes || a.index - b.index);
44
+ for (const { index } of candidates) {
45
+ delete payload.spans[index].lineHashes;
46
+ body = JSON.stringify(payload);
47
+ if (Buffer.byteLength(body) <= MAX_BODY_BYTES) return body;
48
+ }
49
+ return Buffer.byteLength(body) <= MAX_BODY_BYTES ? body : null;
50
+ }
51
+
52
+ export async function sendAgentSpanOperation(input, { timeoutMs = 500 } = {}) {
53
+ const payload = buildAgentSpanRequestPayload(input);
54
+ if (!payload) return null;
55
+ const body = serializeBoundedPayload(payload);
56
+ if (!body) return null;
57
+
58
+ return new Promise((resolve) => {
59
+ const req = http.request({
60
+ socketPath: projectSocketPath(),
61
+ path: '/agent-spans',
62
+ method: 'POST',
63
+ headers: {
64
+ 'content-type': 'application/json',
65
+ 'content-length': Buffer.byteLength(body),
66
+ },
67
+ }, (res) => {
68
+ let response = '';
69
+ let responseBytes = 0;
70
+ res.on('data', (chunk) => {
71
+ responseBytes += chunk.length;
72
+ if (responseBytes <= MAX_BODY_BYTES) response += chunk;
73
+ });
74
+ res.on('end', () => {
75
+ if (res.statusCode !== 200 || responseBytes > MAX_BODY_BYTES) {
76
+ resolve(null);
77
+ return;
78
+ }
79
+ try { resolve(JSON.parse(response)); }
80
+ catch { resolve(null); }
81
+ });
82
+ });
83
+ req.on('error', () => resolve(null));
84
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
85
+ req.end(body);
86
+ });
87
+ }
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Agent shown-span receipts.
3
+ *
4
+ * The ledger is deliberately ephemeral: one bounded in-memory instance lives
5
+ * in the per-project daemon. Clients without a trustworthy session id or a
6
+ * reachable daemon receive the original full output (fail open).
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import path from 'node:path';
11
+ import { extractQueryEvidence } from './query-sufficiency.js';
12
+
13
+ export const SHOWN_SPAN_TRAILER_ENV = 'SWEET_SEARCH_SHOWN_SPAN_TRAILER';
14
+ export const EXACT_REREAD_OMISSION_ENV = 'SWEET_SEARCH_EXACT_REREAD_OMISSION';
15
+ export const RECEIPT_TTL_CALLS = 30;
16
+ export const OMISSION_WINDOW_CALLS = RECEIPT_TTL_CALLS;
17
+ export const RECEIPTS_PER_SESSION = 64;
18
+ export const MAX_LEDGER_SESSIONS = 32;
19
+ export const QUERY_ANCHORS_PER_SESSION = 128;
20
+ export const QUERY_SUBTOKENS_PER_SESSION = 64;
21
+
22
+ const MAX_SESSION_ID_CHARS = 256;
23
+ const MAX_RECEIPT_PATH_CHARS = 4096;
24
+ const MAX_SPANS_PER_CALL = 20;
25
+ const MAX_LINE_DIGESTS_PER_RECEIPT = 256;
26
+ const LINE_DIGEST_BYTES = 32;
27
+ const SHA256_RE = /^[a-f0-9]{64}$/;
28
+ const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
29
+ const MAX_QUERY_CONTEXT_CHARS = 2000;
30
+
31
+ export function flagEnabled(value) {
32
+ return ['1', 'true', 'on', 'yes'].includes(String(value ?? '').trim().toLowerCase());
33
+ }
34
+
35
+ function flagEnabledByDefault(value) {
36
+ return !['0', 'false', 'off', 'no'].includes(String(value ?? '').trim().toLowerCase());
37
+ }
38
+
39
+ export function shownSpanTrailerEnabled(env = process.env) {
40
+ return flagEnabledByDefault(env?.[SHOWN_SPAN_TRAILER_ENV]);
41
+ }
42
+
43
+ export function exactRereadOmissionEnabled(env = process.env) {
44
+ return flagEnabledByDefault(env?.[EXACT_REREAD_OMISSION_ENV]);
45
+ }
46
+
47
+ export function validAgentSessionId(value) {
48
+ return typeof value === 'string'
49
+ && value.length > 0
50
+ && value.length <= MAX_SESSION_ID_CHARS
51
+ && !/[\u0000-\u001f\u007f]/.test(value);
52
+ }
53
+
54
+ export function resolveAgentSessionId(explicit, env = process.env) {
55
+ const candidates = [
56
+ explicit,
57
+ env?.SWEET_SEARCH_SESSION_ID,
58
+ env?.CODEX_THREAD_ID,
59
+ env?.CLAUDE_SESSION_ID,
60
+ ];
61
+ return candidates.find(validAgentSessionId) || null;
62
+ }
63
+
64
+ function sourceLines(text) {
65
+ if (typeof text !== 'string') return null;
66
+ return text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
67
+ }
68
+
69
+ /** Hash source-line evidence while ignoring only the formatter-added final EOL. */
70
+ export function hashShownText(text) {
71
+ const lines = sourceLines(text);
72
+ if (!lines) return null;
73
+ return createHash('sha256').update(lines.join('\n'), 'utf8').digest('hex');
74
+ }
75
+
76
+ /** SHA-256-per-line evidence for byte-proving a coordinate-contained reread. */
77
+ export function hashShownLines(text) {
78
+ const lines = sourceLines(text);
79
+ if (!lines || lines.length > MAX_LINE_DIGESTS_PER_RECEIPT) return null;
80
+ const digests = lines.map((line) => createHash('sha256').update(line, 'utf8').digest());
81
+ return Buffer.concat(digests).toString('base64');
82
+ }
83
+
84
+ export function normalizeSpanFile(file, projectRoot = process.cwd()) {
85
+ if (typeof file !== 'string' || file.length === 0) return null;
86
+ const root = path.resolve(projectRoot || process.cwd());
87
+ const absolute = path.resolve(root, file);
88
+ const relative = path.relative(root, absolute).replace(/\\/g, '/');
89
+ if (relative && !relative.startsWith('../') && !path.isAbsolute(relative)) return relative;
90
+ if (relative === '') return '.';
91
+ return absolute.replace(/\\/g, '/');
92
+ }
93
+
94
+ function completeAgentResultSpan(result, projectRoot) {
95
+ if (result?.presentation !== 'full' || typeof result.code !== 'string' || !result.code) return null;
96
+ if (result.expansionKind === 'sandwich' || result.sandwich) return null;
97
+ const startLine = Number(result.startLine);
98
+ const endLine = Number(result.endLine);
99
+ if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) return null;
100
+
101
+ // Full packs are sometimes budget-truncated. Only a body with exactly the
102
+ // advertised source-line count is eligible for a receipt.
103
+ const lineCount = result.code.replace(/\r?\n$/, '').split('\n').length;
104
+ if (lineCount !== (endLine - startLine + 1)) return null;
105
+
106
+ const file = normalizeSpanFile(result.file, projectRoot);
107
+ const hash = hashShownText(result.code);
108
+ const lineHashes = hashShownLines(result.code);
109
+ const receipt = file && hash
110
+ ? { file, startLine, endLine, hash, ...(lineHashes ? { lineHashes } : {}) }
111
+ : null;
112
+ return validSpanReceipt(receipt) ? receipt : null;
113
+ }
114
+
115
+ function completeAgentContinuationSpan(result, projectRoot) {
116
+ const continuation = result?.continuation;
117
+ if (continuation?.kind !== 'symbol' || typeof continuation.code !== 'string' || !continuation.code) return null;
118
+ return completeAgentResultSpan({
119
+ file: continuation.file,
120
+ startLine: continuation.startLine,
121
+ endLine: continuation.endLine,
122
+ presentation: 'full',
123
+ code: continuation.code,
124
+ }, projectRoot);
125
+ }
126
+
127
+ export function collectAgentShownSpans(results, { projectRoot } = {}) {
128
+ if (!Array.isArray(results)) return [];
129
+ return results
130
+ .flatMap((result) => [
131
+ completeAgentResultSpan(result, projectRoot),
132
+ completeAgentContinuationSpan(result, projectRoot),
133
+ ])
134
+ .filter(Boolean)
135
+ .slice(0, MAX_SPANS_PER_CALL);
136
+ }
137
+
138
+ export function collectReadShownSpans(results, { projectRoot } = {}) {
139
+ const files = Array.isArray(results?.files) ? results.files : [];
140
+ return files.flatMap((result, resultIndex) => {
141
+ if (!result?.ok || typeof result.text !== 'string') return [];
142
+ const file = normalizeSpanFile(result.absolutePath || result.file, projectRoot);
143
+ const startLine = result.range?.startLine ?? 1;
144
+ const endLine = result.range?.endLine ?? result.totalLines;
145
+ if (!Number.isInteger(startLine)
146
+ || !Number.isInteger(endLine)
147
+ || startLine < 1
148
+ || endLine < startLine) return [];
149
+ const lines = sourceLines(result.text);
150
+ if (!lines || lines.length !== endLine - startLine + 1) return [];
151
+ const hash = hashShownText(result.text);
152
+ const lineHashes = hashShownLines(result.text);
153
+ if (!file || !hash) return [];
154
+ const receipt = {
155
+ file,
156
+ startLine,
157
+ endLine,
158
+ hash,
159
+ resultIndex,
160
+ ...(lineHashes ? { lineHashes } : {}),
161
+ };
162
+ return validSpanReceipt(receipt) ? [receipt] : [];
163
+ }).slice(0, MAX_SPANS_PER_CALL);
164
+ }
165
+
166
+ export function collectSemanticShownSpans(result, { projectRoot } = {}) {
167
+ if (!result?.ok || !Array.isArray(result.spans)) return [];
168
+ const file = normalizeSpanFile(result.file, projectRoot);
169
+ if (!file) return [];
170
+ return result.spans.flatMap((span) => {
171
+ if (span?.truncated === true
172
+ || typeof span?.text !== 'string'
173
+ || !Number.isInteger(span.startLine)
174
+ || !Number.isInteger(span.endLine)
175
+ || span.startLine < 1
176
+ || span.endLine < span.startLine) return [];
177
+ const lines = sourceLines(span.text);
178
+ if (!lines || lines.length !== span.endLine - span.startLine + 1) return [];
179
+ const hash = hashShownText(span?.text);
180
+ const lineHashes = hashShownLines(span?.text);
181
+ if (!hash) return [];
182
+ const receipt = {
183
+ file,
184
+ startLine: span.startLine,
185
+ endLine: span.endLine,
186
+ hash,
187
+ ...(lineHashes ? { lineHashes } : {}),
188
+ };
189
+ return validSpanReceipt(receipt) ? [receipt] : [];
190
+ }).slice(0, MAX_SPANS_PER_CALL);
191
+ }
192
+
193
+ export function renderShownFullTrailer(spans) {
194
+ if (!Array.isArray(spans) || spans.length === 0) return '';
195
+ const grouped = new Map();
196
+ for (const span of spans) {
197
+ if (!span?.file || !Number.isInteger(span.startLine) || !Number.isInteger(span.endLine)) continue;
198
+ if (!grouped.has(span.file)) grouped.set(span.file, []);
199
+ grouped.get(span.file).push(`${span.startLine}-${span.endLine}`);
200
+ }
201
+ if (grouped.size === 0) return '';
202
+ return `shown-full: ${[...grouped].map(([file, ranges]) => `${file}:${ranges.join(',')}`).join('; ')}`;
203
+ }
204
+
205
+ export function renderReadOmission(result, { surface = 'cli' } = {}) {
206
+ const omitted = result?.omitted;
207
+ if (!omitted) return '';
208
+ const shownAt = `${omitted.file}:${omitted.startLine}-${omitted.endLine}`;
209
+ const age = omitted.callsAgo;
210
+ const plural = age === 1 ? '' : 's';
211
+ let override;
212
+ if (surface === 'mcp') {
213
+ override = 'force=true';
214
+ } else {
215
+ override = '--force';
216
+ }
217
+ return `[unchanged reread omitted; these exact source lines were already shown ${age} sweet-search call${plural} ago within ${shownAt}. Continue from that copy. ONLY if the prior copy is unavailable, repeat this same path/range with ${override}; this one-use override applies only to this omission.]`;
218
+ }
219
+
220
+ export function applyReadOmissionDecisions(results, decisions) {
221
+ if (!Array.isArray(results?.files) || !Array.isArray(decisions)) return results;
222
+ for (let i = 0; i < results.files.length; i++) {
223
+ const decision = decisions[i];
224
+ if (!decision?.omit) continue;
225
+ results.files[i].text = '';
226
+ results.files[i].omitted = {
227
+ file: decision.previous.file,
228
+ startLine: decision.previous.startLine,
229
+ endLine: decision.previous.endLine,
230
+ callsAgo: decision.callsAgo,
231
+ };
232
+ }
233
+ return results;
234
+ }
235
+
236
+ export function validSpanReceipt(span) {
237
+ if (!(span
238
+ && typeof span.file === 'string'
239
+ && span.file.length > 0
240
+ && span.file.length <= MAX_RECEIPT_PATH_CHARS
241
+ && Number.isInteger(span.startLine)
242
+ && Number.isInteger(span.endLine)
243
+ && span.startLine >= 1
244
+ && span.endLine >= span.startLine
245
+ && SHA256_RE.test(span.hash))) return false;
246
+ if (span.lineHashes == null) return true;
247
+ if (typeof span.lineHashes !== 'string' || !BASE64_RE.test(span.lineHashes)) return false;
248
+ const bytes = Buffer.from(span.lineHashes, 'base64');
249
+ return bytes.length > 0
250
+ && bytes.length <= MAX_LINE_DIGESTS_PER_RECEIPT * LINE_DIGEST_BYTES
251
+ && bytes.length % LINE_DIGEST_BYTES === 0
252
+ && bytes.length / LINE_DIGEST_BYTES === span.endLine - span.startLine + 1;
253
+ }
254
+
255
+ function receiptKey(span) {
256
+ return `${span.file}\u0000${span.startLine}\u0000${span.endLine}\u0000${span.hash}`;
257
+ }
258
+
259
+ function storedReceipt(span, call) {
260
+ return {
261
+ file: span.file,
262
+ startLine: span.startLine,
263
+ endLine: span.endLine,
264
+ hash: span.hash,
265
+ lineDigests: span.lineHashes ? Buffer.from(span.lineHashes, 'base64') : null,
266
+ shownAtCall: call,
267
+ };
268
+ }
269
+
270
+ function receiptProvesSpan(receipt, span) {
271
+ if (receipt.file !== span.file) return false;
272
+ if (receipt.startLine === span.startLine
273
+ && receipt.endLine === span.endLine
274
+ && receipt.hash === span.hash) return true;
275
+ if (!receipt.lineDigests || !span.lineHashes) return false;
276
+ if (receipt.startLine > span.startLine || receipt.endLine < span.endLine) return false;
277
+
278
+ const requested = Buffer.from(span.lineHashes, 'base64');
279
+ const offset = (span.startLine - receipt.startLine) * LINE_DIGEST_BYTES;
280
+ const end = offset + requested.length;
281
+ return end <= receipt.lineDigests.length
282
+ && receipt.lineDigests.subarray(offset, end).equals(requested);
283
+ }
284
+
285
+ function publicReceipt(receipt) {
286
+ return {
287
+ file: receipt.file,
288
+ startLine: receipt.startLine,
289
+ endLine: receipt.endLine,
290
+ };
291
+ }
292
+
293
+ function emptyQueryContext() {
294
+ return { anchors: new Map(), subtokens: [] };
295
+ }
296
+
297
+ export class AgentSpanLedger {
298
+ constructor({
299
+ ttlCalls = RECEIPT_TTL_CALLS,
300
+ omissionWindowCalls = OMISSION_WINDOW_CALLS,
301
+ receiptsPerSession = RECEIPTS_PER_SESSION,
302
+ maxSessions = MAX_LEDGER_SESSIONS,
303
+ } = {}) {
304
+ this.ttlCalls = ttlCalls;
305
+ this.omissionWindowCalls = Math.min(omissionWindowCalls, ttlCalls);
306
+ this.receiptsPerSession = receiptsPerSession;
307
+ this.maxSessions = maxSessions;
308
+ this.sessions = new Map();
309
+ }
310
+
311
+ _touchSession(sessionId) {
312
+ let session = this.sessions.get(sessionId);
313
+ if (!session) {
314
+ session = {
315
+ call: 0,
316
+ receipts: new Map(),
317
+ overrides: new Map(),
318
+ queryContext: emptyQueryContext(),
319
+ };
320
+ } else {
321
+ if (!session.overrides) session.overrides = new Map();
322
+ if (!session.queryContext) session.queryContext = emptyQueryContext();
323
+ this.sessions.delete(sessionId);
324
+ }
325
+ this.sessions.set(sessionId, session);
326
+ while (this.sessions.size > this.maxSessions) {
327
+ this.sessions.delete(this.sessions.keys().next().value);
328
+ }
329
+ return session;
330
+ }
331
+
332
+ beginCall(sessionId) {
333
+ if (!validAgentSessionId(sessionId)) return null;
334
+ const session = this._touchSession(sessionId);
335
+ session.call += 1;
336
+ for (const [key, receipt] of session.receipts) {
337
+ if (session.call - receipt.shownAtCall > this.ttlCalls) session.receipts.delete(key);
338
+ }
339
+ for (const [key, override] of session.overrides) {
340
+ if (session.call - override.omittedAtCall > this.omissionWindowCalls) session.overrides.delete(key);
341
+ }
342
+ return session.call;
343
+ }
344
+
345
+ rememberQueryAtCall(sessionId, call, query, regex = '') {
346
+ const session = this.sessions.get(sessionId);
347
+ if (!session || !Number.isInteger(call) || call < 1 || call > session.call) return false;
348
+ const boundedQuery = typeof query === 'string' && query.length <= MAX_QUERY_CONTEXT_CHARS
349
+ ? query : '';
350
+ const boundedRegex = typeof regex === 'string' && regex.length <= MAX_QUERY_CONTEXT_CHARS
351
+ ? regex : '';
352
+ if (!boundedQuery && !boundedRegex) return false;
353
+
354
+ const evidence = extractQueryEvidence(boundedQuery, boundedRegex);
355
+ for (const anchor of evidence.anchors) {
356
+ session.queryContext.anchors.delete(anchor);
357
+ session.queryContext.anchors.set(anchor, call);
358
+ while (session.queryContext.anchors.size > QUERY_ANCHORS_PER_SESSION) {
359
+ session.queryContext.anchors.delete(session.queryContext.anchors.keys().next().value);
360
+ }
361
+ }
362
+ session.queryContext.subtokens = [...evidence.subtokens].slice(0, QUERY_SUBTOKENS_PER_SESSION);
363
+ return true;
364
+ }
365
+
366
+ queryEvidence(sessionId) {
367
+ const session = this.sessions.get(sessionId);
368
+ if (!session?.queryContext) return null;
369
+ for (const [anchor, rememberedAtCall] of session.queryContext.anchors) {
370
+ if (session.call - rememberedAtCall > this.ttlCalls) {
371
+ session.queryContext.anchors.delete(anchor);
372
+ }
373
+ }
374
+ const anchors = [...session.queryContext.anchors.keys()];
375
+ const subtokens = [...session.queryContext.subtokens];
376
+ return anchors.length > 0 || subtokens.length > 0 ? { anchors, subtokens } : null;
377
+ }
378
+
379
+ observeAtCall(sessionId, call, spans) {
380
+ const session = this.sessions.get(sessionId);
381
+ if (!session || !Number.isInteger(call) || call < 1 || call > session.call) return false;
382
+ if (session.call - call > this.ttlCalls) return false;
383
+ for (const span of (Array.isArray(spans) ? spans : []).filter(validSpanReceipt).slice(0, MAX_SPANS_PER_CALL)) {
384
+ const key = receiptKey(span);
385
+ const existing = session.receipts.get(key);
386
+ if (existing && existing.shownAtCall > call) continue;
387
+ session.overrides.delete(key);
388
+ session.receipts.delete(key);
389
+ session.receipts.set(key, storedReceipt(span, call));
390
+ while (session.receipts.size > this.receiptsPerSession) {
391
+ session.receipts.delete(session.receipts.keys().next().value);
392
+ }
393
+ }
394
+ return true;
395
+ }
396
+
397
+ decideAndObserveAtCall(sessionId, call, spans, { force = false } = {}) {
398
+ const session = this.sessions.get(sessionId);
399
+ const validSpans = (Array.isArray(spans) ? spans : []).slice(0, MAX_SPANS_PER_CALL);
400
+ if (!session || !Number.isInteger(call) || call < 1 || call > session.call) {
401
+ return validSpans.map(() => ({ omit: false }));
402
+ }
403
+
404
+ const decisions = validSpans.map((span) => {
405
+ if (!validSpanReceipt(span)) return { omit: false };
406
+ const key = receiptKey(span);
407
+ const pendingOverride = session.overrides.get(key);
408
+ if (force && pendingOverride && call - pendingOverride.omittedAtCall <= this.omissionWindowCalls) {
409
+ session.overrides.delete(key);
410
+ return { omit: false };
411
+ }
412
+ let previous = null;
413
+ for (const candidate of [...session.receipts.values()].reverse()) {
414
+ const age = call - candidate.shownAtCall;
415
+ if (age <= 0 || age > this.omissionWindowCalls) continue;
416
+ if (receiptProvesSpan(candidate, span)) {
417
+ previous = candidate;
418
+ break;
419
+ }
420
+ }
421
+ const callsAgo = previous ? call - previous.shownAtCall : null;
422
+ if (!(previous && callsAgo > 0 && callsAgo <= this.omissionWindowCalls)) return { omit: false };
423
+ session.overrides.set(key, { omittedAtCall: call });
424
+ while (session.overrides.size > this.receiptsPerSession) {
425
+ session.overrides.delete(session.overrides.keys().next().value);
426
+ }
427
+ return { omit: true, callsAgo, previous: publicReceipt(previous) };
428
+ });
429
+ this.observeAtCall(sessionId, call, validSpans.filter((_, index) => !decisions[index]?.omit));
430
+ return decisions;
431
+ }
432
+
433
+ reset(sessionId) {
434
+ if (!validAgentSessionId(sessionId)) return false;
435
+ return this.sessions.delete(sessionId);
436
+ }
437
+
438
+ clear() {
439
+ this.sessions.clear();
440
+ }
441
+
442
+ stats() {
443
+ let receipts = 0;
444
+ for (const session of this.sessions.values()) receipts += session.receipts.size;
445
+ return { sessions: this.sessions.size, receipts };
446
+ }
447
+ }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { readFileRange } from './search-pattern-chunks.js';
22
22
  import { computeSufficiencyVerdict } from './query-sufficiency.js';
23
+ import { applyAgentPackCompletion, shownSourceEndLine } from './agent-pack-completion.js';
23
24
  import { statSync } from 'fs';
24
25
  import path from 'path';
25
26
 
@@ -1974,6 +1975,7 @@ export function packageForAgent(rankedResults, searchStats, opts) {
1974
1975
  codeGraphRepo = null,
1975
1976
  locationMap = null,
1976
1977
  projectRoot,
1978
+ _isAgentFormat = false,
1977
1979
  } = opts;
1978
1980
  const ablations = opts.ablations || new Set();
1979
1981
 
@@ -2187,6 +2189,7 @@ export function packageForAgent(rankedResults, searchStats, opts) {
2187
2189
  // Phase 3: Token budget — truncate or compress
2188
2190
  const resultTokenCap = Math.min(allocation.tokenCap, remainingBudget);
2189
2191
  let codeTokens;
2192
+ let boundaryTruncated = false;
2190
2193
  if (resultTokenCap <= 0) {
2191
2194
  code = '';
2192
2195
  codeTokens = 0;
@@ -2209,6 +2212,7 @@ export function packageForAgent(rankedResults, searchStats, opts) {
2209
2212
  const truncResult = truncateToTokenCap(code, resultTokenCap);
2210
2213
  code = truncResult.code;
2211
2214
  codeTokens = estimateTokens(code);
2215
+ boundaryTruncated = truncResult.truncated;
2212
2216
  } else {
2213
2217
  // Preview mode — compress to signature + snippet
2214
2218
  code = compressToPreview(code, resultTokenCap);
@@ -2264,6 +2268,15 @@ export function packageForAgent(rankedResults, searchStats, opts) {
2264
2268
  indexedAt,
2265
2269
  code,
2266
2270
  codeTokens,
2271
+ ...(_isAgentFormat === true
2272
+ && allocation.presentation === 'full'
2273
+ && expansion.kind !== 'sandwich'
2274
+ ? {
2275
+ shownStartLine: expansion.startLine,
2276
+ shownEndLine: shownSourceEndLine(expansion.startLine, code, boundaryTruncated),
2277
+ ...(boundaryTruncated ? { boundaryTruncated: true } : {}),
2278
+ }
2279
+ : {}),
2267
2280
  };
2268
2281
 
2269
2282
  // Phase 4: Header context (top-1 only). Skipped by 'no-header' ablation.
@@ -2428,6 +2441,22 @@ export function packageForAgent(rankedResults, searchStats, opts) {
2428
2441
  }
2429
2442
  }
2430
2443
 
2444
+ if (_isAgentFormat === true) {
2445
+ const completion = applyAgentPackCompletion({
2446
+ results: agentResults,
2447
+ query,
2448
+ regex,
2449
+ codeGraphRepo,
2450
+ fileCache,
2451
+ projectRoot,
2452
+ tokensUsed,
2453
+ tokenBudget,
2454
+ estimateTokens,
2455
+ isAgentFormat: _isAgentFormat,
2456
+ });
2457
+ tokensUsed = completion.tokensUsed;
2458
+ }
2459
+
2431
2460
  return {
2432
2461
  query,
2433
2462
  regex,
@@ -191,3 +191,31 @@ export function renderGrepBody(kept, fileSummary, k) {
191
191
  hiddenLine,
192
192
  };
193
193
  }
194
+
195
+ /**
196
+ * Replace the lowest-ranked complete grep lines with an indexed family
197
+ * manifest, but only when those lines fully fund the manifest's estimated
198
+ * tokens. The input is never mutated and an underfunded manifest is omitted.
199
+ */
200
+ export function reallocateGrepTailForManifest(lines, manifest, estimateTokens = (text) => (
201
+ text ? Math.ceil(text.length / 3.5) : 0
202
+ )) {
203
+ if (!Array.isArray(lines) || !manifest?.rendered || lines.length === 0) {
204
+ return { lines, familyManifest: null, removedLineCount: 0 };
205
+ }
206
+ const required = estimateTokens(`${manifest.rendered}\n`);
207
+ let reclaimed = 0;
208
+ let keep = lines.length;
209
+ while (keep > 0 && reclaimed < required) {
210
+ keep--;
211
+ reclaimed += estimateTokens(`${lines[keep]}\n`);
212
+ }
213
+ if (reclaimed < required) {
214
+ return { lines, familyManifest: null, removedLineCount: 0 };
215
+ }
216
+ return {
217
+ lines: lines.slice(0, keep),
218
+ familyManifest: { ...manifest, tokens: required },
219
+ removedLineCount: lines.length - keep,
220
+ };
221
+ }