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
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Every coding agent today reaches for grep + Read by reflex. *sweet-search* chall
|
|
|
23
23
|
## ✨ Highlights
|
|
24
24
|
|
|
25
25
|
- **Hybrid retrieval** — one of the six tools uses BM25F lexical + dense semantic + structural graph signals, fused per query and reranked by late-interaction
|
|
26
|
-
- **Agent-native by design** — token-budgeted output tiers, an optional MCP server (and default zero-overhead CLI), and a GEPA-evolved system prompt
|
|
26
|
+
- **Agent-native by design** — token-budgeted output tiers, an optional MCP server (and default zero-overhead CLI), and a GEPA-evolved system prompt — one `init` installs it into Claude Code by default (Codex, Gemini CLI, and Cursor via flags)
|
|
27
27
|
- **Indexed grep, ~10× faster than ripgrep** — a sparse n-gram prefilter skips the files that provably can't match
|
|
28
28
|
- **ColBERT-style reranking, locally** — per-token MaxSim late interaction on hand-written SIMD kernels
|
|
29
29
|
- **GPU-accelerated indexing** — Apple Metal, CUDA, CoreML Neural Engine, or plain CPU via ORT; same engine, auto-selected
|
|
@@ -263,6 +263,7 @@ We're SOTA in June 2026 on 3/4 attempted benchmarks at HARDER settings (running
|
|
|
263
263
|
- **Reproduction:** result artifacts live in [`eval/results/`](eval/results/); rerun via `eval/run_all.js`. The canonical full-pool loaders are in `eval/download_data.py`.
|
|
264
264
|
- **Full corpus, not distractors.** Published baselines for GCSN- and CoSQA-style benchmarks typically rank the gold against 99 sampled distractors; every number here ranks against the benchmark's *full* corpus (6k–19k candidates) — strictly harder.
|
|
265
265
|
- **Zero-shot + docstring-stripped.** We never fine-tune on these tasks. For docstring-derived benchmarks (AdvTest, M2CRB) we strip the docstring from the indexed code — otherwise the NL query matches itself verbatim (a no-strip AdvTest run scores a meaningless 0.98). This is the standard protocol; it is also why our AdvTest is lower than naïve setups that leave the docstring in.
|
|
266
|
+
- **Dev/held-out split.** Our ranking work iterates against a fixed dev split of each benchmark (e.g. GCSN: 600 dev + 400 held-out per language, stratified, seed=42) and treats the remainder as held-out, inspected aggregate-only at milestones. The table figures are full-corpus runs — they include the dev portion we tuned against; a held-out-only breakdown ships with the next results refresh.
|
|
266
267
|
- **What we deliberately don't claim yet.** CoIR (official metric NDCG@10 over per-subtask corpora up to ~1M docs), CoSQA+ (multi-positive, MAP-primary), and CLARC (per-group pools) use protocols and metrics our single-pool MRR@10 harness doesn't currently match. Rather than publish apples-to-oranges numbers, we omit them; faithful per-subtask CoIR (NDCG@10) runs are queued.
|
|
267
268
|
- **M2CRB** — the paper's metric is *auMRRc* (area under the MRR-vs-pool-size curve; best published **52.7**, fine-tuned). Because that area averages over easier small pools, `auMRRc ≥ full-pool MRR` for any model — so our **54.0 full-pool MRR@10** (all 5,795 functions, zero-shot) clears their best on a strictly harder measure. No one publishes a plain full-corpus MRR@10 on M2CRB, so ours is the best available.
|
|
268
269
|
- **AdvTest honesty note.** We could not reproduce the commonly-cited 59.5 for the bare CodeRankEmbed encoder on our corpus: the reference FP32 model scores 54.7 on our leak-free, docstring-stripped, full-19,210 setup, and our shipped INT8 build 51.4. We report our measured numbers and the reference check rather than the leaderboard figure.
|
|
@@ -399,7 +400,7 @@ flowchart TD
|
|
|
399
400
|
| 🎯 **Intent Rerank** | • demote docs / tests / config when you want implementation<br/>• log-scaled call-site boosts surface the most-referenced function |
|
|
400
401
|
| 🕸️ **Graph Expansion** | • typed-edge walks (`imports`/`extends`/`calls`/`uses`) · adaptive 2-hop on the AST graph · edges picked by intent<br/>• **PathRAG** flow pruning + degree normalization → hubs can't dominate |
|
|
401
402
|
| 🧮 **Late interaction Rerank** | • Query embedded per-token by **LateOn-Code** (149M; a 17M **edge** variant auto-selected on low-RAM hosts)<br/>• **MaxSim** against the pre-indexed quantized token vectors<br/>• native Rust+Rayon MaxSim kernel ⚡ · WASM-SIMD fallback (1.26 s → 27 ms on a 231-candidate rerank) |
|
|
402
|
-
| 📦 **Package** | • entity-aware expansion → whole functions (imports, docstrings, decorators)<br/>• same-file overlap demotion → diverse, non-overlapping spans<br/>• auto-selected **3k / 8k / 12k** token budget |
|
|
403
|
+
| 📦 **Package** | • entity-aware expansion → whole functions (imports, docstrings, decorators)<br/>• same-file overlap demotion → diverse, non-overlapping spans<br/>• **symbol-family completion** (agent mode) — generated/width families surface as a compact indexed manifest instead of truncating silently, inside the same budget<br/>• auto-selected **3k / 8k / 12k** token budget |
|
|
403
404
|
|
|
404
405
|
<details>
|
|
405
406
|
<summary><b>🌶️ Extra spice — the bits that didn't fit the diagram</b></summary>
|
|
@@ -418,7 +419,7 @@ flowchart TD
|
|
|
418
419
|
- **Quality priors:** every chunk carries a 0–1 prior from test proximity, git recency, symbol centrality (PageRank), comment density, and complexity — production code surfaces, stale fixtures sink.
|
|
419
420
|
- **Community structure:** a canonical **Leiden** pass detects code communities on the entity graph at index time, feeding vocabulary prewarming and structural signals — it understands your modules, not just your directories.
|
|
420
421
|
- **Multilingual:** 14 languages get full tree-sitter AST treatment; a 39-config registry covers 70+ extensions beyond that. Router features handle camelCase/snake_case, CJK density, and German compounds.
|
|
421
|
-
- **Format-gated signals:** structure-aware boosts and demotions (symbol-exact, path-token, mega-entity) fire only in agent mode — they help agent-shaped queries and would hurt plain NL, so they stay gated by default.
|
|
422
|
+
- **Format-gated signals:** structure-aware boosts and demotions (symbol-exact, path-token, anomalous-chunk, mega-entity) fire only in agent mode — they help agent-shaped queries and would hurt plain NL, so they stay gated by default.
|
|
422
423
|
|
|
423
424
|
**🛟 Rescues & honest trade-offs.**
|
|
424
425
|
- **Long-query rescue:** wordy NL queries that FTS5 would tokenize into an unsatisfiable `AND` fall back to multi-query BM25F + RRF — one query per content keyword, fused.
|
|
@@ -449,6 +450,7 @@ Every match comes back in stable `file:line` order — ripgrep-identical counts,
|
|
|
449
450
|
|
|
450
451
|
- Full methodology, per-repo table, and the optimization log: [`docs/GREP_INDEXING_STRATEGY.md`](docs/GREP_INDEXING_STRATEGY.md).
|
|
451
452
|
- Regexes with no extractable literals fall back to native grep over the indexed file set; fixed-string and glob queries use a ripgrep fallback.
|
|
453
|
+
- **Dialect recovery** (agent mode): patterns written in GNU-grep BRE muscle memory (`foo\|bar`, `\(group\)`) are literals in Rust's regex dialect and used to silently match nothing — a zero-hit exact search now gets one gated auto-retry with the translated pattern instead of a false "no matches".
|
|
452
454
|
|
|
453
455
|
</details>
|
|
454
456
|
|
|
@@ -553,6 +555,8 @@ without another search.
|
|
|
553
555
|
<summary><b>More</b></summary>
|
|
554
556
|
|
|
555
557
|
- The CLI/MCP form scales it up: `sweet-search read <file...>` (and the `read` MCP tool) batches **1–20 files in a single call**, each with the same symbol metadata — twenty files for the price of one tool invocation.
|
|
558
|
+
- **Query-aware continuation** (agent mode): a range read that stops before EOF names the unread symbols below it — ranked by relevance to the session's recent queries, not just declaration order — plus the exact command to continue.
|
|
559
|
+
- **Shown-span receipts** (agent mode, default-on): the daemon remembers what a session has already been shown; an exact re-read collapses to a one-line receipt instead of resending the bytes.
|
|
556
560
|
|
|
557
561
|
</details>
|
|
558
562
|
|
|
@@ -13,6 +13,10 @@ import { existsSync, statSync } from 'fs';
|
|
|
13
13
|
import { applyReadPragmas, prepareCached } from './db-utils.js';
|
|
14
14
|
import { readAdjacentManifest, resolveManifestCodeGraphPath, sqlAliasPrefix } from './code-graph-visibility.js';
|
|
15
15
|
|
|
16
|
+
function pathIsUnsafe(value) {
|
|
17
|
+
return value.startsWith('/') || value === '..' || value.startsWith('../') || value.includes('/../');
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
export class CodeGraphRepository {
|
|
17
21
|
constructor(dbPath, options = {}) {
|
|
18
22
|
this._baseDbPath = dbPath;
|
|
@@ -383,6 +387,60 @@ export class CodeGraphRepository {
|
|
|
383
387
|
}
|
|
384
388
|
}
|
|
385
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Find exact indexed symbol-family candidates under a bounded directory.
|
|
392
|
+
* The search domain supplies an identifier stem discovered from indexed
|
|
393
|
+
* seeds; this adapter performs only parameterized persistence work.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} stem - alphanumeric identifier fragment (3-64 chars)
|
|
396
|
+
* @param {{ filePrefix:string, types?:string[], limit?:number }} opts
|
|
397
|
+
* @returns {Array<{id,name,type,filePath,startLine,endLine,parentClass}>}
|
|
398
|
+
*/
|
|
399
|
+
findFamilyCandidates(stem, opts = {}) {
|
|
400
|
+
const db = this._open();
|
|
401
|
+
if (!db || typeof stem !== 'string' || !/^[A-Za-z][A-Za-z0-9]{2,63}$/.test(stem)) return [];
|
|
402
|
+
const prefix = typeof opts.filePrefix === 'string'
|
|
403
|
+
? opts.filePrefix.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '')
|
|
404
|
+
: '';
|
|
405
|
+
if (!prefix || prefix === '.' || pathIsUnsafe(prefix)) return [];
|
|
406
|
+
const types = Array.isArray(opts.types)
|
|
407
|
+
? [...new Set(opts.types.filter((type) => typeof type === 'string' && /^[A-Za-z][\w-]{0,31}$/.test(type)))].slice(0, 16)
|
|
408
|
+
: [];
|
|
409
|
+
const limit = Math.max(1, Math.min(128, opts.limit ?? 64));
|
|
410
|
+
const escapeLike = (value) => value.replace(/[\\%_]/g, '\\$&');
|
|
411
|
+
try {
|
|
412
|
+
const typeSql = types.length > 0 ? `AND type IN (${types.map(() => '?').join(',')})` : '';
|
|
413
|
+
const sql = `
|
|
414
|
+
SELECT id, name, type, file_path, start_line, end_line, parent_class
|
|
415
|
+
FROM entities
|
|
416
|
+
WHERE lower(name) LIKE lower(?) ESCAPE '\\'
|
|
417
|
+
AND file_path LIKE ? ESCAPE '\\'
|
|
418
|
+
${typeSql}
|
|
419
|
+
AND ${this._entityVisibilitySql(db)}
|
|
420
|
+
ORDER BY name ASC, file_path ASC, start_line ASC
|
|
421
|
+
LIMIT ?
|
|
422
|
+
`;
|
|
423
|
+
const args = [
|
|
424
|
+
`%${escapeLike(stem)}%`,
|
|
425
|
+
`${escapeLike(prefix)}/%`,
|
|
426
|
+
...types,
|
|
427
|
+
...this._entityVisibilityParams(db),
|
|
428
|
+
limit,
|
|
429
|
+
];
|
|
430
|
+
return prepareCached(db, sql).all(...args).map((row) => ({
|
|
431
|
+
id: row.id,
|
|
432
|
+
name: row.name,
|
|
433
|
+
type: row.type,
|
|
434
|
+
filePath: row.file_path,
|
|
435
|
+
startLine: row.start_line,
|
|
436
|
+
endLine: row.end_line,
|
|
437
|
+
parentClass: row.parent_class || null,
|
|
438
|
+
}));
|
|
439
|
+
} catch {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
386
444
|
/**
|
|
387
445
|
* Get a single entity by id, with file:line metadata.
|
|
388
446
|
*
|
|
@@ -292,6 +292,13 @@ export async function fetchModelFile(hfId, filePath, destDir, options = {}) {
|
|
|
292
292
|
if (startByte > 0) {
|
|
293
293
|
headers['Range'] = `bytes=${startByte}-`;
|
|
294
294
|
}
|
|
295
|
+
// HF 403s anonymous downloads from datacenter IPs (CI runners), even on
|
|
296
|
+
// public files. A read token lifts that. Only sent to huggingface.co —
|
|
297
|
+
// never to a custom hfEndpoint, which may be an untrusted mirror.
|
|
298
|
+
const hfToken = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN;
|
|
299
|
+
if (hfToken && url.startsWith('https://huggingface.co/')) {
|
|
300
|
+
headers['Authorization'] = `Bearer ${hfToken}`;
|
|
301
|
+
}
|
|
295
302
|
|
|
296
303
|
const resp = await fetch(url, { headers, signal });
|
|
297
304
|
|
|
@@ -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
|
+
}
|