feat(mcp): line numbers in explore output + per-file cluster fixes (#188)

* feat(mcp): line numbers in explore output + per-file cluster fixes

Follow-up to #185. Three changes to codegraph_explore:

1. Source sections now carry cat -n style line-number prefixes
   (<num>\t<code>), so the agent can cite file:line straight from the
   payload instead of re-Reading the file just to recover a line number.
   Isolated A/B: the no-line-numbers arm spent 2 Reads + a grep to find a
   line number the line-numbered arm cited with zero follow-up calls.
   Payload cost ~3-5%. Toggle off with CODEGRAPH_EXPLORE_LINENUMS=0.

2. Per-file cluster selection now ranks clusters containing a query entry
   point ahead of dense declaration blocks. Density-only ranking buried
   the relevant methods (perform/didCreateURLRequest/task in Alamofire's
   Session.swift) under the top-of-file class header + property list.

3. Whole-file "envelope" nodes (a class/struct/etc. spanning >50% of the
   file) are excluded from clustering. The Session class spans ~1,400
   lines; keeping it collapsed every method into one giant cluster that
   tail-trimmed down to just the class header, hiding the methods.

Net vs the 0.7.10 baseline, line numbers on: Alamofire -60%, Excalidraw
-32%, VS Code -12% per explore call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): language-neutral omission markers in explore output

The gap separator and the two tail-trim markers used C-style `//`
comments, which aren't comments in Python, Ruby, etc. Switch to plain
`... (gap) ...` / `... (trimmed) ...` so they read correctly inside any
language's fenced source block. With line numbers on, the line-number
jump already corroborates a gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): language-neutral truncation marker in codegraph_context

Sibling to the explore marker fix: codegraph_context's code-block
truncation used a C-style `// ... truncated ...`. Switch to
`... (truncated) ...` so it reads correctly in any language's fenced
source block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): bump version to 0.7.11

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-19 17:16:12 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 93e53e7c69
commit 2c1a314b84
6 changed files with 149 additions and 26 deletions
+29 -6
View File
@@ -9,6 +9,18 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- **MCP / explore**: `codegraph_explore` source sections now carry line
numbers (cat -n style `<num>\t<code>`, matching the Read tool). This lets
the agent cite `file:line` straight from the explore payload instead of
re-opening the file just to find a line number — the dominant residual
cost on precise-tracing questions. In an isolated A/B (answer a
"which exact line" question with the relevant code already in the
payload), the no-line-numbers arm spent 2 file Reads + a grep recovering
the line number while the line-numbered arm answered with zero follow-up
tool calls. Payload cost is small (~3-5%). Set
`CODEGRAPH_EXPLORE_LINENUMS=0` to disable.
### Changed ### Changed
- **MCP / explore**: `codegraph_explore` output is now adaptive to project - **MCP / explore**: `codegraph_explore` output is now adaptive to project
size. The tool used to apply a fixed 35KB cap regardless of how large the size. The tool used to apply a fixed 35KB cap regardless of how large the
@@ -22,12 +34,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
(<5,000) caps at ~28KB; large (<15,000) keeps the historical ~35KB; very (<5,000) caps at ~28KB; large (<15,000) keeps the historical ~35KB; very
large goes up to ~38KB. A new per-file char cap also prevents a single large goes up to ~38KB. A new per-file char cap also prevents a single
file with many adjacent symbols from collapsing into one whole-file dump file with many adjacent symbols from collapsing into one whole-file dump
(the Alamofire `Session.swift` case from #185). Measured against the (the Alamofire `Session.swift` case from #185). Per-file cluster
same repos used in the README benchmark: Alamofire ~62% smaller per call, selection ranks clusters that contain a query entry point ahead of dense
Excalidraw ~35%, VS Code ~14%. Agent-trust floor still holds — the declaration blocks, and whole-file "envelope" nodes (a class/struct that
Relationships section, scored cluster selection, and structured-source spans most of the file) are excluded from clustering so the methods the
output are all retained. Thanks to query asked about aren't buried under the container's opening lines.
[@essopsp](https://github.com/essopsp) for the repro. Measured against the same repos used in the README benchmark, end state
with line numbers on: Alamofire ~60% smaller per call, Excalidraw ~32%,
VS Code ~12%. Agent-trust floor still holds — the Relationships section,
scored cluster selection, and structured-source output are all retained.
Thanks to [@essopsp](https://github.com/essopsp) for the repro.
### Fixed
- **MCP**: source-omission markers in `codegraph_explore` and
`codegraph_context` output are now language-neutral (`... (gap) ...`,
`... (trimmed) ...`, `... (truncated) ...`) instead of C-style `//`
comments, which were misleading inside Python, Ruby, and other non-C
fenced source blocks.
## [0.7.10] - 2026-05-19 ## [0.7.10] - 2026-05-19
+43
View File
@@ -188,4 +188,47 @@ describe('codegraph_explore output respects the adaptive budget', () => {
const sourceFollowsHeader = text.indexOf('### Source Code') > 0; const sourceFollowsHeader = text.indexOf('### Source Code') > 0;
expect(hasRelationships || sourceFollowsHeader).toBe(true); expect(hasRelationships || sourceFollowsHeader).toBe(true);
}); });
it('prefixes source lines with line numbers by default (cat -n style)', async () => {
delete process.env.CODEGRAPH_EXPLORE_LINENUMS;
const result = await handler.execute('codegraph_explore', { query: 'Session method helper' });
const text = result.content?.[0]?.text ?? '';
// At least one fenced source line should look like `<digits>\t<code>`.
expect(/\n\d+\t/.test(text)).toBe(true);
});
it('omits line numbers when CODEGRAPH_EXPLORE_LINENUMS=0', async () => {
process.env.CODEGRAPH_EXPLORE_LINENUMS = '0';
try {
const result = await handler.execute('codegraph_explore', { query: 'Session method helper' });
const text = result.content?.[0]?.text ?? '';
// The synthetic source has no tab-prefixed numeric lines of its own,
// so none should appear when the toggle is off.
expect(/\n\d+\t(?:export| )/.test(text)).toBe(false);
} finally {
delete process.env.CODEGRAPH_EXPLORE_LINENUMS;
}
});
it('uses language-neutral omission markers (no C-style // in the output)', async () => {
// The gap/trimmed separators must not assume `//` is a comment — that's
// wrong in Python, Ruby, etc. They render inside fenced source blocks.
const result = await handler.execute('codegraph_explore', { query: 'Session method helper' });
const text = result.content?.[0]?.text ?? '';
expect(text).not.toContain('// ... (gap)');
expect(text).not.toContain('// ... trimmed');
});
it('does not collapse a whole-file class into just its header (envelope filter)', async () => {
// The synthetic `Session` class spans the entire file. Without the
// envelope filter it would form one giant cluster that tail-trims to
// the class declaration, hiding the methods. Confirm real method bodies
// make it into the output. Regression guard for the #185 follow-up.
const result = await handler.execute('codegraph_explore', { query: 'Session method helper' });
const text = result.content?.[0]?.text ?? '';
// A method body line (`methodN(arg: string)`) should appear, not just
// the `export class Session {` opener.
const hasMethodBody = /method\d+\(arg: string\)/.test(text);
expect(hasMethodBody).toBe(true);
});
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@colbymchenry/codegraph", "name": "@colbymchenry/codegraph",
"version": "0.7.10", "version": "0.7.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@colbymchenry/codegraph", "name": "@colbymchenry/codegraph",
"version": "0.7.10", "version": "0.7.11",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.3.0", "@clack/prompts": "^1.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@colbymchenry/codegraph", "name": "@colbymchenry/codegraph",
"version": "0.7.10", "version": "0.7.11",
"description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+4 -2
View File
@@ -1006,9 +1006,11 @@ export class ContextBuilder {
const code = await this.extractNodeCode(node); const code = await this.extractNodeCode(node);
if (code) { if (code) {
// Truncate if too long // Truncate if too long. Language-neutral marker (no `//` — not a
// comment in Python, Ruby, etc.); this renders inside a fenced
// source block whose language varies.
const truncated = code.length > maxBlockSize const truncated = code.length > maxBlockSize
? code.slice(0, maxBlockSize) + '\n// ... truncated ...' ? code.slice(0, maxBlockSize) + '\n... (truncated) ...'
: code; : code;
blocks.push({ blocks.push({
+70 -15
View File
@@ -142,6 +142,38 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
}; };
} }
/**
* Whether `codegraph_explore` should prefix source lines with their line
* numbers (cat -n style: `<num>\t<code>`).
*
* Line numbers let the agent cite `file:line` straight from the explore
* payload instead of re-Reading the file just to find a line number — the
* dominant residual cost on precise-tracing questions (#185 follow-up).
*
* Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
* A/B harness to measure the payload-cost vs. read-savings tradeoff).
*/
function exploreLineNumbersEnabled(): boolean {
return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
}
/**
* Prefix each line of a source slice with its 1-based line number, matching
* the Read tool's `cat -n` convention (number + tab) so the agent treats it
* the same way it treats Read output.
*
* @param slice contiguous source text (already extracted from the file)
* @param firstLineNumber the 1-based line number of the slice's first line
*/
function numberSourceLines(slice: string, firstLineNumber: number): string {
const out: string[] = [];
const split = slice.split('\n');
for (let i = 0; i < split.length; i++) {
out.push(`${firstLineNumber + i}\t${split[i]}`);
}
return out.join('\n');
}
/** /**
* Mark a Claude session as having consulted MCP tools. * Mark a Claude session as having consulted MCP tools.
* This enables Grep/Glob/Bash commands that would otherwise be blocked. * This enables Grep/Glob/Bash commands that would otherwise be blocked.
@@ -940,10 +972,19 @@ export class ToolHandler {
// are worth 10, directly-connected nodes 3, peripheral nodes 1, and // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
// bare edge-source lines 2 (less than a connected node but more than // bare edge-source lines 2 (less than a connected node but more than
// a peripheral one — they hint at a reference but aren't a definition). // a peripheral one — they hint at a reference but aren't a definition).
// Container kinds whose body can span most/all of a file. When such a
// node covers most of the file we drop it from the ranges: keeping it
// would merge every method inside it into one giant cluster spanning
// the whole file, which then tail-trims down to just the container's
// opening lines (its header/declarations) and buries the methods the
// query actually asked about (#185 follow-up — Session.swift in
// Alamofire is the canonical case: the `Session` class spans ~1,400
// lines). We want the granular symbols inside, not the envelope.
const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
.filter(n => n.startLine > 0 && n.endLine > 0) .filter(n => n.startLine > 0 && n.endLine > 0)
// Skip file/component nodes that span the entire file — they'd create one giant cluster // Drop whole-file envelope nodes (containers covering >50% of the file).
.filter(n => !(n.kind === 'component' && n.startLine === 1 && n.endLine >= fileLines.length - 1)) .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
.map(n => { .map(n => {
let importance = 1; let importance = 1;
if (entryNodeIds.has(n.id)) importance = 10; if (entryNodeIds.has(n.id)) importance = 10;
@@ -975,12 +1016,13 @@ export class ToolHandler {
if (ranges.length === 0) continue; if (ranges.length === 0) continue;
const gapThreshold = budget.gapThreshold; const gapThreshold = budget.gapThreshold;
const clusters: Array<{ start: number; end: number; symbols: string[]; score: number }> = []; const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
let current = { let current = {
start: ranges[0]!.start, start: ranges[0]!.start,
end: ranges[0]!.end, end: ranges[0]!.end,
symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`], symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
score: ranges[0]!.importance, score: ranges[0]!.importance,
maxImportance: ranges[0]!.importance,
}; };
for (let i = 1; i < ranges.length; i++) { for (let i = 1; i < ranges.length; i++) {
@@ -989,6 +1031,7 @@ export class ToolHandler {
current.end = Math.max(current.end, r.end); current.end = Math.max(current.end, r.end);
current.symbols.push(`${r.name}(${r.kind})`); current.symbols.push(`${r.name}(${r.kind})`);
current.score += r.importance; current.score += r.importance;
current.maxImportance = Math.max(current.maxImportance, r.importance);
} else { } else {
clusters.push(current); clusters.push(current);
current = { current = {
@@ -996,6 +1039,7 @@ export class ToolHandler {
end: r.end, end: r.end,
symbols: [`${r.name}(${r.kind})`], symbols: [`${r.name}(${r.kind})`],
score: r.importance, score: r.importance,
maxImportance: r.importance,
}; };
} }
} }
@@ -1005,25 +1049,36 @@ export class ToolHandler {
// The pathological case (#185): a file like Session.swift where every // The pathological case (#185): a file like Session.swift where every
// method is adjacent collapses into one cluster spanning the whole // method is adjacent collapses into one cluster spanning the whole
// file, and dumping that into the agent's context is most of the // file, and dumping that into the agent's context is most of the
// token cost on small projects. We pick clusters in score order // token cost on small projects. We pick clusters in priority order
// (importance per line, so we don't prefer one giant low-density // until the per-file char cap is hit. Truly enormous single clusters
// cluster over several focused ones) until the per-file char cap is // get tail-trimmed with a marker.
// hit. Truly enormous single clusters get tail-trimmed with a marker.
const contextPadding = 3; const contextPadding = 3;
const withLineNumbers = exploreLineNumbersEnabled();
const buildSection = (c: { start: number; end: number }): string => { const buildSection = (c: { start: number; end: number }): string => {
const startIdx = Math.max(0, c.start - 1 - contextPadding); const startIdx = Math.max(0, c.start - 1 - contextPadding);
const endIdx = Math.min(fileLines.length, c.end + contextPadding); const endIdx = Math.min(fileLines.length, c.end + contextPadding);
return fileLines.slice(startIdx, endIdx).join('\n'); const slice = fileLines.slice(startIdx, endIdx).join('\n');
// startIdx is 0-based, so the slice's first line is line startIdx + 1.
return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
}; };
const GAP_MARKER = '\n\n// ... (gap) ...\n\n'; // Language-neutral separator (no `//` — not a comment in Python, Ruby,
// etc.). With line numbers on, the line-number jump also signals the gap.
const GAP_MARKER = '\n\n... (gap) ...\n\n';
// Score clusters by score-per-line (density) so a 30-line cluster // Rank clusters for inclusion under the per-file cap. Entry-point
// with two entry symbols outranks a 400-line cluster with two // clusters come first: a cluster containing a query entry point
// peripheral symbols. Stable tiebreak by score, then by smaller // (importance 10) must outrank a dense block of mere declarations,
// span (cheaper to include). // otherwise on a large file like Session.swift the top-of-file class
// header + property list (many adjacent low-importance nodes, high
// density) wins the budget and buries the actual methods the query
// asked about (perform/didCreateURLRequest/task live deep in the
// file). Within the same importance tier, prefer density (score per
// line) so we still favor focused clusters over sprawling ones, then
// smaller span as a cheap-to-include tiebreak.
const rankedClusters = clusters const rankedClusters = clusters
.map((c, i) => ({ idx: i, span: c.end - c.start + 1, c })) .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
.sort((a, b) => { .sort((a, b) => {
if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
const densityA = a.c.score / a.span; const densityA = a.c.score / a.span;
const densityB = b.c.score / b.span; const densityB = b.c.score / b.span;
if (densityB !== densityA) return densityB - densityA; if (densityB !== densityA) return densityB - densityA;
@@ -1064,7 +1119,7 @@ export class ToolHandler {
// If a single chosen cluster is still oversize (long monolithic // If a single chosen cluster is still oversize (long monolithic
// function), tail-trim it. Better one trimmed view than nothing. // function), tail-trim it. Better one trimmed view than nothing.
if (fileSection.length > budget.maxCharsPerFile) { if (fileSection.length > budget.maxCharsPerFile) {
fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n// ... trimmed ...'; fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
fileTrimmed = true; fileTrimmed = true;
} }
if (chosenIndices.size < clusters.length || fileTrimmed) { if (chosenIndices.size < clusters.length || fileTrimmed) {
@@ -1094,7 +1149,7 @@ export class ToolHandler {
if (totalChars + fileSection.length + 200 > budget.maxOutputChars) { if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
const remaining = budget.maxOutputChars - totalChars - 200; const remaining = budget.maxOutputChars - totalChars - 200;
if (remaining < 500) break; if (remaining < 500) break;
const trimmed = fileSection.slice(0, remaining) + '\n// ... trimmed ...'; const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
lines.push(fileHeader); lines.push(fileHeader);
lines.push(''); lines.push('');