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
|
@@ -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
|
+
}
|