sweet-search 2.6.16 → 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.
Files changed (33) hide show
  1. package/README.md +7 -3
  2. package/core/graph/structural-context-format.js +5 -0
  3. package/core/graph/structural-context.js +13 -3
  4. package/core/infrastructure/code-graph-repository.js +58 -0
  5. package/core/infrastructure/language-patterns/registry-core.js +14 -0
  6. package/core/infrastructure/model-fetcher.js +7 -0
  7. package/core/infrastructure/structural-context-repository.js +73 -7
  8. package/core/infrastructure/structural-context-utils.js +11 -0
  9. package/core/infrastructure/structural-qualified-resolution.js +29 -0
  10. package/core/infrastructure/tree-sitter-provider.js +77 -3
  11. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt-mcp.md +12 -6
  12. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +4 -2
  13. package/core/search/agent-pack-completion.js +493 -0
  14. package/core/search/agent-span-client.js +87 -0
  15. package/core/search/agent-span-ledger.js +447 -0
  16. package/core/search/context-expander.js +29 -0
  17. package/core/search/grep-output-shaping.js +28 -0
  18. package/core/search/regex-dialect.js +328 -0
  19. package/core/search/search-format.js +96 -0
  20. package/core/search/search-pattern.js +62 -10
  21. package/core/search/search-read.js +57 -10
  22. package/core/search/search-server.js +375 -7
  23. package/core/search/session-daemon-prewarm.mjs +75 -0
  24. package/core/search/sweet-search.js +7 -1
  25. package/core/search/unread-symbol-ranking.js +90 -0
  26. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +249 -45
  27. package/mcp/read-tool.js +34 -3
  28. package/mcp/server.js +55 -9
  29. package/mcp/tool-handlers.js +60 -7
  30. package/package.json +9 -9
  31. package/scripts/init.js +30 -5
  32. package/scripts/inject-agent-instructions.js +4 -2
  33. package/scripts/uninstall.js +21 -19
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Agent-only pack completion.
3
+ *
4
+ * This is a post-ranking presentation policy: it never changes retrieval
5
+ * scores or asks the model to search again. A compact continuation or indexed
6
+ * family manifest is paid for by removing a lower-ranked code body (or the
7
+ * older same-file hint), so the configured and realized pack ceilings stay
8
+ * unchanged.
9
+ */
10
+
11
+ import path from 'node:path';
12
+ import { readFileRange } from './search-pattern-chunks.js';
13
+ import { containsToken, extractQueryEvidence, informativeSubtokens } from './query-sufficiency.js';
14
+
15
+ const MAX_BOUNDARY_GAP_LINES = 12;
16
+ const MAX_IMMEDIATE_GAP_LINES = 3;
17
+ const MAX_REFERENCED_SIBLING_GAP_LINES = 160;
18
+ const MAX_FAMILY_SEEDS = 24;
19
+ const MAX_FAMILY_STEMS = 3;
20
+ const MAX_FAMILY_CANDIDATES = 64;
21
+ const BODY_REFERENCE_GENERIC_TOKENS = new Set(['get', 'set', 'has', 'can', 'could', 'should', 'needed', 'result', 'value', 'data', 'info']);
22
+ const IDENTIFIER_RE = /\b[A-Za-z_$][A-Za-z0-9_$]{2,79}\b/g;
23
+ const TRUNCATION_MARKER_RE = /^\s*\/\/ \.\.\. \(\d+ (?:more lines|lines elided)\)(?: \.\.\.)?\s*$/;
24
+
25
+ function defaultEstimateTokens(text) {
26
+ return text ? Math.ceil(text.length / 3.5) : 0;
27
+ }
28
+
29
+ function normalizedLines(text) {
30
+ if (typeof text !== 'string' || !text) return [];
31
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
32
+ if (lines.at(-1) === '') lines.pop();
33
+ return lines;
34
+ }
35
+
36
+ /** Return the last contiguous source coordinate actually rendered. */
37
+ export function shownSourceEndLine(startLine, code, truncated = false) {
38
+ if (!Number.isInteger(startLine) || startLine < 1) return null;
39
+ const lines = normalizedLines(code);
40
+ if (lines.length === 0) return startLine - 1;
41
+ const marker = truncated ? lines.findIndex((line) => TRUNCATION_MARKER_RE.test(line)) : -1;
42
+ const sourceLineCount = marker >= 0 ? marker : lines.length;
43
+ return startLine + Math.max(0, sourceLineCount - 1);
44
+ }
45
+
46
+ function identifierParts(name) {
47
+ return String(name || '').match(/[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|[A-Z]+|\d+/g) || [];
48
+ }
49
+
50
+ function familyStem(name) {
51
+ const alpha = identifierParts(name).filter((part) => /^[A-Za-z]{3,}$/.test(part));
52
+ if (alpha.length === 0) return null;
53
+ alpha.sort((a, b) => b.length - a.length || a.localeCompare(b));
54
+ return alpha[0].toLowerCase();
55
+ }
56
+
57
+ function groupSortKey(group) {
58
+ const width = Number(group.prefix.match(/\d+/)?.[0] || 0);
59
+ const alpha = group.prefix.replace(/\d+/g, '').toLowerCase();
60
+ return { width, alpha, prefix: group.prefix.toLowerCase() };
61
+ }
62
+
63
+ function compareNumericLabels(a, b) {
64
+ const normalizedA = a.replace(/^0+(?=\d)/, '');
65
+ const normalizedB = b.replace(/^0+(?=\d)/, '');
66
+ return normalizedA.length - normalizedB.length
67
+ || normalizedA.localeCompare(normalizedB)
68
+ || a.length - b.length
69
+ || a.localeCompare(b);
70
+ }
71
+
72
+ /**
73
+ * Compact exact indexed names by their final numeric slot. No Cartesian
74
+ * products are generated: every rendered member came from `candidates`.
75
+ */
76
+ export function buildIndexedFamilyManifest(candidates, { seedNames = [] } = {}) {
77
+ if (!Array.isArray(candidates) || candidates.length < 2) return null;
78
+ const seedStems = new Set(seedNames.map(familyStem).filter(Boolean));
79
+ const byName = new Map();
80
+ for (const candidate of candidates) {
81
+ const name = candidate?.name;
82
+ if (typeof name !== 'string' || !/[A-Za-z]/.test(name) || !/\d/.test(name)) continue;
83
+ const stem = familyStem(name);
84
+ if (!stem || (seedStems.size > 0 && !seedStems.has(stem))) continue;
85
+ if (!byName.has(name)) byName.set(name, candidate);
86
+ }
87
+
88
+ const groups = new Map();
89
+ for (const [name, candidate] of byName) {
90
+ const match = name.match(/^(.*?)(\d+)([^\d]*)$/);
91
+ if (!match) continue;
92
+ const [, prefix, numeric, suffix] = match;
93
+ if (!prefix || !/[A-Za-z]/.test(prefix)) continue;
94
+ const key = `${prefix}\u0000${suffix}`;
95
+ if (!groups.has(key)) groups.set(key, { prefix, suffix, values: new Map() });
96
+ groups.get(key).values.set(numeric, { candidate, numeric });
97
+ }
98
+
99
+ const compact = [...groups.values()]
100
+ .filter((group) => group.values.size >= 2)
101
+ .sort((a, b) => {
102
+ const ka = groupSortKey(a);
103
+ const kb = groupSortKey(b);
104
+ return ka.width - kb.width || ka.alpha.localeCompare(kb.alpha)
105
+ || ka.prefix.localeCompare(kb.prefix);
106
+ });
107
+ if (compact.length === 0) return null;
108
+
109
+ const labels = [];
110
+ const members = [];
111
+ for (const group of compact) {
112
+ const values = [...group.values.keys()].sort(compareNumericLabels);
113
+ labels.push(`${group.prefix}{${values.join(',')}}${group.suffix}`);
114
+ for (const value of values) members.push(group.values.get(value).candidate);
115
+ }
116
+ const rendered = `# indexed family: ${labels.join(' · ')}`;
117
+ return {
118
+ rendered,
119
+ tokens: defaultEstimateTokens(rendered),
120
+ groups: labels,
121
+ members,
122
+ };
123
+ }
124
+
125
+ function overlapsShown(candidate, results) {
126
+ return results.some((result) => {
127
+ if (!result?.code || result.file !== candidate.file) return false;
128
+ const start = result.shownStartLine ?? result.startLine;
129
+ const end = result.shownEndLine ?? result.endLine;
130
+ return Number.isFinite(start) && Number.isFinite(end)
131
+ && candidate.startLine <= end && candidate.endLine >= start;
132
+ });
133
+ }
134
+
135
+ function boundaryScore(entity, queryEvidence, parentClass, gap, allowImmediate = false) {
136
+ const name = String(entity?.name || '');
137
+ if (!name) return 0;
138
+ let exact = 0;
139
+ for (const anchor of queryEvidence.anchors) {
140
+ const caseSensitive = /[A-Z]/.test(anchor);
141
+ if (containsToken(name, anchor, { caseSensitive })) exact = Math.max(exact, 100);
142
+ else if (name.toLowerCase().includes(anchor.toLowerCase())) exact = Math.max(exact, 60);
143
+ }
144
+ const nameTokens = informativeSubtokens(name);
145
+ let matched = 0;
146
+ for (const token of queryEvidence.subtokens) if (nameTokens.has(token)) matched++;
147
+ if (exact === 0 && matched === 0) {
148
+ // The top-ranked result already establishes query relevance. Do not let a
149
+ // budget cutoff land immediately before its next complete sibling merely
150
+ // because the model phrased the behavior rather than the sibling's name.
151
+ return allowImmediate && gap <= MAX_IMMEDIATE_GAP_LINES
152
+ ? MAX_IMMEDIATE_GAP_LINES - gap + 1 : 0;
153
+ }
154
+ const sameParent = parentClass && entity.parentClass === parentClass ? 5 : 0;
155
+ return exact + matched * 10 + sameParent + (MAX_BOUNDARY_GAP_LINES - gap) / 100;
156
+ }
157
+
158
+ function bodySiblingScore(entity, code) {
159
+ const candidateTokens = [...informativeSubtokens(entity?.name)]
160
+ .filter((token) => !BODY_REFERENCE_GENERIC_TOKENS.has(token));
161
+ if (candidateTokens.length < 2 || !code) return 0;
162
+ let inspected = 0;
163
+ for (const [identifier] of String(code).matchAll(IDENTIFIER_RE)) {
164
+ if (++inspected > 256) break;
165
+ const referenceTokens = informativeSubtokens(identifier);
166
+ let overlap = 0;
167
+ for (const token of candidateTokens) if (referenceTokens.has(token)) overlap++;
168
+ if (overlap >= 2) return 40 + overlap * 10;
169
+ }
170
+ return 0;
171
+ }
172
+
173
+ function findBoundaryContinuation(results, query, regex, codeGraphRepo) {
174
+ if (!codeGraphRepo || typeof codeGraphRepo.findAdjacentEntities !== 'function') return null;
175
+ const evidence = extractQueryEvidence(query, regex);
176
+ if (evidence.anchors.length === 0 && evidence.subtokens.size === 0) return null;
177
+
178
+ for (const result of results) {
179
+ if (!result?.code || result.presentation !== 'full'
180
+ || !Number.isInteger(result.shownStartLine)
181
+ || !Number.isInteger(result.shownEndLine)) continue;
182
+ let adjacent;
183
+ try {
184
+ adjacent = codeGraphRepo.findAdjacentEntities(
185
+ result.file,
186
+ result.shownStartLine,
187
+ result.shownEndLine,
188
+ { perSide: 8 },
189
+ );
190
+ } catch {
191
+ continue;
192
+ }
193
+ let parentClass = result.parentClass || null;
194
+ if (!parentClass && result.entityId && typeof codeGraphRepo.getEntityById === 'function') {
195
+ try { parentClass = codeGraphRepo.getEntityById(result.entityId)?.parentClass || null; }
196
+ catch { parentClass = null; }
197
+ }
198
+ const candidates = (adjacent?.below || []).flatMap((entity) => {
199
+ const gap = entity.startLine - result.shownEndLine;
200
+ if (!Number.isInteger(entity.startLine) || !Number.isInteger(entity.endLine)
201
+ || gap < 1 || gap > (result.rank === 1
202
+ ? MAX_REFERENCED_SIBLING_GAP_LINES : MAX_BOUNDARY_GAP_LINES)) return [];
203
+ const candidate = { ...entity, file: result.file };
204
+ if (overlapsShown(candidate, results)) return [];
205
+ const namedScore = gap <= MAX_BOUNDARY_GAP_LINES
206
+ ? boundaryScore(entity, evidence, parentClass, gap, result.rank === 1) : 0;
207
+ const referencedScore = result.rank === 1 ? bodySiblingScore(entity, result.code) : 0;
208
+ const score = Math.max(namedScore, referencedScore);
209
+ return score > 0 ? [{ trigger: result, entity, score, gap }] : [];
210
+ }).sort((a, b) => b.score - a.score || a.gap - b.gap || a.entity.startLine - b.entity.startLine);
211
+ if (candidates.length > 0) return candidates[0];
212
+ }
213
+ return null;
214
+ }
215
+
216
+ function identifierMatchesQuery(name, evidence) {
217
+ for (const anchor of evidence.anchors) {
218
+ const caseSensitive = /[A-Z]/.test(anchor);
219
+ if (containsToken(name, anchor, { caseSensitive })
220
+ || name.toLowerCase().includes(anchor.toLowerCase())) return true;
221
+ }
222
+ const nameTokens = informativeSubtokens(name);
223
+ for (const token of evidence.subtokens) if (nameTokens.has(token)) return true;
224
+ return false;
225
+ }
226
+
227
+ function extractSeedNames(results, query, regex) {
228
+ const out = [];
229
+ const seen = new Set();
230
+ const evidence = extractQueryEvidence(query, regex);
231
+ const push = (name) => {
232
+ if (out.length >= MAX_FAMILY_SEEDS || typeof name !== 'string'
233
+ || !/[A-Za-z]/.test(name) || !/\d/.test(name) || seen.has(name)) return;
234
+ seen.add(name);
235
+ out.push(name);
236
+ };
237
+ for (const result of results) {
238
+ // A selected summary row is still indexed evidence. Generated-family
239
+ // searches often return the template/output map with code while concrete
240
+ // members (IVec2/UVec2, etc.) survive only as lower summary symbols.
241
+ push(result?.symbol);
242
+ if (result?.code) {
243
+ for (const name of result.code.match(IDENTIFIER_RE) || []) {
244
+ if (identifierMatchesQuery(name, evidence)) push(name);
245
+ }
246
+ }
247
+ if (out.length >= MAX_FAMILY_SEEDS) break;
248
+ }
249
+ return out;
250
+ }
251
+
252
+ function commonDirectory(filePaths) {
253
+ const dirs = filePaths
254
+ .filter((file) => typeof file === 'string' && file.length > 0)
255
+ .map((file) => path.posix.dirname(file.replace(/\\/g, '/')));
256
+ if (dirs.length === 0) return null;
257
+ const parts = dirs.map((dir) => dir.split('/').filter(Boolean));
258
+ const common = [];
259
+ for (let i = 0; i < Math.min(...parts.map((entry) => entry.length)); i++) {
260
+ if (!parts.every((entry) => entry[i] === parts[0][i])) break;
261
+ common.push(parts[0][i]);
262
+ }
263
+ let prefix = common.join('/');
264
+ if (!prefix || prefix === '.') return null;
265
+ if (new Set(dirs).size === 1 && /\d/.test(path.posix.basename(prefix))) {
266
+ const parent = path.posix.dirname(prefix);
267
+ if (parent && parent !== '.') prefix = parent;
268
+ }
269
+ return prefix;
270
+ }
271
+
272
+ function findIndexedFamily(indexedSeeds, codeGraphRepo) {
273
+ const stems = [...new Set(indexedSeeds.map((seed) => familyStem(seed.name)).filter(Boolean))]
274
+ .slice(0, MAX_FAMILY_STEMS);
275
+ let best = null;
276
+ for (const stem of stems) {
277
+ const seeds = indexedSeeds.filter((seed) => familyStem(seed.name) === stem);
278
+ const filePrefix = commonDirectory(seeds.map((seed) => seed.filePath));
279
+ if (!filePrefix) continue;
280
+ const types = [...new Set(seeds.map((seed) => seed.type).filter(Boolean))];
281
+ let candidates;
282
+ try {
283
+ candidates = codeGraphRepo.findFamilyCandidates(stem, {
284
+ filePrefix, types, limit: MAX_FAMILY_CANDIDATES,
285
+ });
286
+ } catch {
287
+ continue;
288
+ }
289
+ const manifest = buildIndexedFamilyManifest(candidates, {
290
+ seedNames: seeds.map((seed) => seed.name),
291
+ });
292
+ if (manifest && (!best || manifest.members.length > best.manifest.members.length)) {
293
+ best = { manifest, seeds };
294
+ }
295
+ }
296
+ return best;
297
+ }
298
+
299
+ /** Build grep family closure only from symbols indexed at exact match lines. */
300
+ export function buildIndexedGrepFamilyManifest(results, codeGraphRepo) {
301
+ if (!Array.isArray(results) || results.length < 2
302
+ || typeof codeGraphRepo?.findEntitiesInRange !== 'function'
303
+ || typeof codeGraphRepo?.findFamilyCandidates !== 'function') return null;
304
+ const seeds = [];
305
+ const seen = new Set();
306
+ for (const result of results.slice(0, MAX_FAMILY_SEEDS)) {
307
+ let entities = [];
308
+ try { entities = codeGraphRepo.findEntitiesInRange(result.file, result.line, result.line) || []; }
309
+ catch { entities = []; }
310
+ if (entities.length === 0 && typeof codeGraphRepo.findEnclosingEntity === 'function') {
311
+ try { entities = [codeGraphRepo.findEnclosingEntity(result.file, result.line, result.line)].filter(Boolean); }
312
+ catch { entities = []; }
313
+ }
314
+ for (const entity of entities) {
315
+ if (typeof entity?.name !== 'string' || !/\d/.test(entity.name) || seen.has(entity.name)) continue;
316
+ seen.add(entity.name);
317
+ seeds.push({ ...entity, filePath: result.file });
318
+ }
319
+ }
320
+ if (seeds.length < 2) return null;
321
+ return findIndexedFamily(seeds, codeGraphRepo)?.manifest || null;
322
+ }
323
+
324
+ function discoverFamily(results, codeGraphRepo, query, regex) {
325
+ if (!codeGraphRepo
326
+ || typeof codeGraphRepo.findEntitiesByAnyName !== 'function'
327
+ || typeof codeGraphRepo.findFamilyCandidates !== 'function') return null;
328
+ const seedNames = extractSeedNames(results, query, regex);
329
+ if (seedNames.length === 0) return null;
330
+ let indexedSeeds;
331
+ try {
332
+ indexedSeeds = codeGraphRepo.findEntitiesByAnyName(seedNames, {
333
+ limit: MAX_FAMILY_SEEDS * 2,
334
+ });
335
+ } catch {
336
+ return null;
337
+ }
338
+ if (!Array.isArray(indexedSeeds) || indexedSeeds.length === 0) return null;
339
+ const family = findIndexedFamily(indexedSeeds, codeGraphRepo);
340
+ if (!family) return null;
341
+ const owner = results.find((result) => result?.code && (
342
+ family.manifest.members.some((member) => member.name === result.symbol)
343
+ || family.seeds.some((seed) => result.code.includes(seed.name))
344
+ )) || results.find((result) => result?.code);
345
+ return owner ? { manifest: family.manifest, owner } : null;
346
+ }
347
+
348
+ function summaryFor(result) {
349
+ const symbol = result.symbol || 'code block';
350
+ const type = result.symbolType ? ` (${result.symbolType})` : '';
351
+ return `${result.file}:${result.startLine} — ${symbol}${type}`;
352
+ }
353
+
354
+ function demoteDonor(result) {
355
+ result.summary ||= summaryFor(result);
356
+ result.presentation = 'summary';
357
+ result.code = null;
358
+ result.codeTokens = 0;
359
+ result.expanded = false;
360
+ delete result.shownStartLine;
361
+ delete result.shownEndLine;
362
+ delete result.boundaryTruncated;
363
+ }
364
+
365
+ function completeContinuation(boundary, fileCache, projectRoot, estimateTokens) {
366
+ if (!boundary) return null;
367
+ const { trigger, entity } = boundary;
368
+ const header = `# continues at ${trigger.file}:${entity.startLine} ${entity.name}`;
369
+ const code = readFileRange(fileCache, trigger.file, entity.startLine, entity.endLine, projectRoot);
370
+ const expectedLines = entity.endLine - entity.startLine + 1;
371
+ if (code && normalizedLines(code).length === expectedLines) {
372
+ return {
373
+ kind: 'symbol',
374
+ file: trigger.file,
375
+ startLine: entity.startLine,
376
+ endLine: entity.endLine,
377
+ symbol: entity.name,
378
+ symbolType: entity.type || null,
379
+ code,
380
+ rendered: header,
381
+ tokens: estimateTokens(`${header}\n${code}`),
382
+ };
383
+ }
384
+ return null;
385
+ }
386
+
387
+ function trailerContinuation(boundary, estimateTokens) {
388
+ if (!boundary) return null;
389
+ const { trigger, entity } = boundary;
390
+ const rendered = `# continues at ${trigger.file}:${entity.startLine} ${entity.name}`;
391
+ return {
392
+ kind: 'trailer',
393
+ file: trigger.file,
394
+ startLine: entity.startLine,
395
+ endLine: entity.endLine,
396
+ symbol: entity.name,
397
+ symbolType: entity.type || null,
398
+ code: null,
399
+ rendered,
400
+ tokens: estimateTokens(rendered),
401
+ };
402
+ }
403
+
404
+ /** Apply token-neutral completion in place and return updated accounting. */
405
+ export function applyAgentPackCompletion({
406
+ results,
407
+ query,
408
+ regex,
409
+ codeGraphRepo,
410
+ fileCache,
411
+ projectRoot,
412
+ tokensUsed,
413
+ tokenBudget,
414
+ estimateTokens = defaultEstimateTokens,
415
+ isAgentFormat = false,
416
+ }) {
417
+ if (isAgentFormat !== true || !Array.isArray(results) || results.length === 0) {
418
+ return { tokensUsed, changed: false };
419
+ }
420
+ const originalTokens = Math.max(0, Number(tokensUsed) || 0);
421
+ const boundary = findBoundaryContinuation(results, query, regex, codeGraphRepo);
422
+ const family = discoverFamily(results, codeGraphRepo, query, regex);
423
+ if (!boundary && !family) return { tokensUsed: originalTokens, changed: false };
424
+
425
+ // Reallocate only a lower-ranked tail. The top result and the rows that own
426
+ // the new evidence must remain code-bearing; otherwise a family-only pack
427
+ // can demote its sole useful hit and attach an invisible manifest to a
428
+ // summary row.
429
+ const donor = results.slice(1).reverse().find((result) => (
430
+ result !== boundary?.trigger
431
+ && result !== family?.owner
432
+ && result?.code
433
+ && Number(result.codeTokens) > 0
434
+ ));
435
+ const donorTokens = donor ? Number(donor.codeTokens) : 0;
436
+ const mapTokens = boundary?.trigger?.sameFile?.tokens || 0;
437
+ let available = donorTokens + mapTokens;
438
+ if (available <= 0) return { tokensUsed: originalTokens, changed: false };
439
+
440
+ let spent = 0;
441
+ let continuation = null;
442
+ if (boundary) {
443
+ const complete = completeContinuation(boundary, fileCache, projectRoot, estimateTokens);
444
+ const trailer = trailerContinuation(boundary, estimateTokens);
445
+ if (complete && complete.tokens <= available) continuation = complete;
446
+ else if (trailer.tokens <= available) continuation = trailer;
447
+ if (continuation) {
448
+ spent += continuation.tokens;
449
+ available -= continuation.tokens;
450
+ } else {
451
+ // The old same-file line cannot fund another feature unless it is
452
+ // actually replaced by a continuation.
453
+ available = donorTokens;
454
+ }
455
+ }
456
+
457
+ let familyManifest = null;
458
+ if (family) {
459
+ const familyTokens = estimateTokens(family.manifest.rendered);
460
+ if (familyTokens <= available) {
461
+ familyManifest = { ...family.manifest, tokens: familyTokens };
462
+ spent += familyTokens;
463
+ available -= familyTokens;
464
+ }
465
+ }
466
+ if (!continuation && !familyManifest) {
467
+ return { tokensUsed: originalTokens, changed: false };
468
+ }
469
+
470
+ const reclaimMap = !!continuation && mapTokens > 0;
471
+ const mapReclaimed = reclaimMap ? mapTokens : 0;
472
+ const needsDonor = spent > mapReclaimed;
473
+ if (needsDonor && !donor) return { tokensUsed: originalTokens, changed: false };
474
+
475
+ const reclaimed = mapReclaimed + (needsDonor ? donorTokens : 0);
476
+ const nextTokens = Math.max(0, originalTokens - reclaimed + spent);
477
+ const numericBudget = Number(tokenBudget);
478
+ const effectiveBudget = Number.isFinite(numericBudget) && numericBudget >= 0
479
+ ? numericBudget : originalTokens;
480
+ if (nextTokens > originalTokens || nextTokens > effectiveBudget) {
481
+ return { tokensUsed: originalTokens, changed: false };
482
+ }
483
+
484
+ if (reclaimMap) delete boundary.trigger.sameFile;
485
+ if (needsDonor) demoteDonor(donor);
486
+ if (continuation) boundary.trigger.continuation = continuation;
487
+ if (familyManifest) family.owner.familyManifest = familyManifest;
488
+
489
+ return {
490
+ tokensUsed: nextTokens,
491
+ changed: true,
492
+ };
493
+ }
@@ -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
+ }