@colbymchenry/codegraph 0.7.10 → 0.8.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 +48 -48
- package/dist/bin/codegraph.js +25 -0
- package/dist/bin/codegraph.js.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +4 -2
- package/dist/context/index.js.map +1 -1
- package/dist/extraction/index.d.ts.map +1 -1
- package/dist/extraction/index.js +63 -37
- package/dist/extraction/index.js.map +1 -1
- package/dist/installer/config-writer.d.ts.map +1 -1
- package/dist/installer/config-writer.js +3 -1
- package/dist/installer/config-writer.js.map +1 -1
- package/dist/installer/index.d.ts +12 -0
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +72 -4
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/instructions-template.d.ts +2 -2
- package/dist/installer/instructions-template.d.ts.map +1 -1
- package/dist/installer/instructions-template.js +3 -2
- package/dist/installer/instructions-template.js.map +1 -1
- package/dist/installer/targets/claude.d.ts +10 -6
- package/dist/installer/targets/claude.d.ts.map +1 -1
- package/dist/installer/targets/claude.js +72 -10
- package/dist/installer/targets/claude.js.map +1 -1
- package/dist/mcp/index.d.ts +8 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +116 -18
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server-instructions.d.ts +1 -1
- package/dist/mcp/server-instructions.d.ts.map +1 -1
- package/dist/mcp/server-instructions.js +14 -2
- package/dist/mcp/server-instructions.js.map +1 -1
- package/dist/mcp/tools.d.ts +59 -2
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +384 -70
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp/transport.d.ts +17 -0
- package/dist/mcp/transport.d.ts.map +1 -1
- package/dist/mcp/transport.js +63 -0
- package/dist/mcp/transport.js.map +1 -1
- package/dist/resolution/frameworks/index.d.ts +1 -0
- package/dist/resolution/frameworks/index.d.ts.map +1 -1
- package/dist/resolution/frameworks/index.js +5 -1
- package/dist/resolution/frameworks/index.js.map +1 -1
- package/dist/resolution/frameworks/nestjs.d.ts +26 -0
- package/dist/resolution/frameworks/nestjs.d.ts.map +1 -0
- package/dist/resolution/frameworks/nestjs.js +374 -0
- package/dist/resolution/frameworks/nestjs.js.map +1 -0
- package/dist/search/query-utils.d.ts.map +1 -1
- package/dist/search/query-utils.js +29 -26
- package/dist/search/query-utils.js.map +1 -1
- package/dist/sync/git-hooks.d.ts +45 -0
- package/dist/sync/git-hooks.d.ts.map +1 -0
- package/dist/sync/git-hooks.js +223 -0
- package/dist/sync/git-hooks.js.map +1 -0
- package/dist/sync/index.d.ts +4 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +12 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/watch-policy.d.ts +48 -0
- package/dist/sync/watch-policy.d.ts.map +1 -0
- package/dist/sync/watch-policy.js +124 -0
- package/dist/sync/watch-policy.js.map +1 -0
- package/dist/sync/watcher.d.ts.map +1 -1
- package/dist/sync/watcher.js +10 -0
- package/dist/sync/watcher.js.map +1 -1
- package/package.json +3 -3
- package/scripts/agent-eval/audit.sh +68 -0
- package/scripts/agent-eval/itrun.sh +107 -0
- package/scripts/agent-eval/parse-run.mjs +45 -0
- package/scripts/agent-eval/parse-session.mjs +93 -0
- package/scripts/agent-eval/run-agent.sh +34 -0
- package/scripts/agent-eval/run-all.sh +67 -0
- package/scripts/extract-release-notes.mjs +130 -0
- package/scripts/release.sh +5 -7
package/dist/mcp/tools.js
CHANGED
|
@@ -40,6 +40,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
40
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
41
|
exports.ToolHandler = exports.tools = void 0;
|
|
42
42
|
exports.getExploreBudget = getExploreBudget;
|
|
43
|
+
exports.getExploreOutputBudget = getExploreOutputBudget;
|
|
43
44
|
const index_1 = __importStar(require("../index"));
|
|
44
45
|
const crypto_1 = require("crypto");
|
|
45
46
|
const fs_1 = require("fs");
|
|
@@ -57,6 +58,15 @@ const MAX_OUTPUT_LENGTH = 15000;
|
|
|
57
58
|
* same as `configurator::stage_apply::run`.
|
|
58
59
|
*/
|
|
59
60
|
const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
|
|
61
|
+
/**
|
|
62
|
+
* Node kinds that contain other symbols. For these, `codegraph_node` with
|
|
63
|
+
* `includeCode=true` returns a structural outline (member names + signatures
|
|
64
|
+
* + line numbers) instead of the full body, which for a large class is a
|
|
65
|
+
* multi-thousand-character wall of source that bloats the agent's context.
|
|
66
|
+
*/
|
|
67
|
+
const CONTAINER_NODE_KINDS = new Set([
|
|
68
|
+
'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
|
|
69
|
+
]);
|
|
60
70
|
/** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
|
|
61
71
|
function lastQualifierPart(symbol) {
|
|
62
72
|
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
|
@@ -78,6 +88,92 @@ function getExploreBudget(fileCount) {
|
|
|
78
88
|
return 4;
|
|
79
89
|
return 5;
|
|
80
90
|
}
|
|
91
|
+
function getExploreOutputBudget(fileCount) {
|
|
92
|
+
if (fileCount < 500) {
|
|
93
|
+
return {
|
|
94
|
+
maxOutputChars: 18000,
|
|
95
|
+
defaultMaxFiles: 5,
|
|
96
|
+
maxCharsPerFile: 3800,
|
|
97
|
+
gapThreshold: 8,
|
|
98
|
+
maxSymbolsInFileHeader: 6,
|
|
99
|
+
maxEdgesPerRelationshipKind: 6,
|
|
100
|
+
includeRelationships: true,
|
|
101
|
+
includeAdditionalFiles: false,
|
|
102
|
+
includeCompletenessSignal: false,
|
|
103
|
+
includeBudgetNote: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (fileCount < 5000) {
|
|
107
|
+
return {
|
|
108
|
+
maxOutputChars: 13000,
|
|
109
|
+
defaultMaxFiles: 6,
|
|
110
|
+
maxCharsPerFile: 2500,
|
|
111
|
+
gapThreshold: 10,
|
|
112
|
+
maxSymbolsInFileHeader: 8,
|
|
113
|
+
maxEdgesPerRelationshipKind: 8,
|
|
114
|
+
includeRelationships: true,
|
|
115
|
+
includeAdditionalFiles: true,
|
|
116
|
+
includeCompletenessSignal: true,
|
|
117
|
+
includeBudgetNote: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (fileCount < 15000) {
|
|
121
|
+
return {
|
|
122
|
+
maxOutputChars: 35000,
|
|
123
|
+
defaultMaxFiles: 12,
|
|
124
|
+
maxCharsPerFile: 7000,
|
|
125
|
+
gapThreshold: 15,
|
|
126
|
+
maxSymbolsInFileHeader: 15,
|
|
127
|
+
maxEdgesPerRelationshipKind: 15,
|
|
128
|
+
includeRelationships: true,
|
|
129
|
+
includeAdditionalFiles: true,
|
|
130
|
+
includeCompletenessSignal: true,
|
|
131
|
+
includeBudgetNote: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
maxOutputChars: 38000,
|
|
136
|
+
defaultMaxFiles: 14,
|
|
137
|
+
maxCharsPerFile: 7000,
|
|
138
|
+
gapThreshold: 15,
|
|
139
|
+
maxSymbolsInFileHeader: 15,
|
|
140
|
+
maxEdgesPerRelationshipKind: 15,
|
|
141
|
+
includeRelationships: true,
|
|
142
|
+
includeAdditionalFiles: true,
|
|
143
|
+
includeCompletenessSignal: true,
|
|
144
|
+
includeBudgetNote: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether `codegraph_explore` should prefix source lines with their line
|
|
149
|
+
* numbers (cat -n style: `<num>\t<code>`).
|
|
150
|
+
*
|
|
151
|
+
* Line numbers let the agent cite `file:line` straight from the explore
|
|
152
|
+
* payload instead of re-Reading the file just to find a line number — the
|
|
153
|
+
* dominant residual cost on precise-tracing questions (#185 follow-up).
|
|
154
|
+
*
|
|
155
|
+
* Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
|
|
156
|
+
* A/B harness to measure the payload-cost vs. read-savings tradeoff).
|
|
157
|
+
*/
|
|
158
|
+
function exploreLineNumbersEnabled() {
|
|
159
|
+
return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Prefix each line of a source slice with its 1-based line number, matching
|
|
163
|
+
* the Read tool's `cat -n` convention (number + tab) so the agent treats it
|
|
164
|
+
* the same way it treats Read output.
|
|
165
|
+
*
|
|
166
|
+
* @param slice contiguous source text (already extracted from the file)
|
|
167
|
+
* @param firstLineNumber the 1-based line number of the slice's first line
|
|
168
|
+
*/
|
|
169
|
+
function numberSourceLines(slice, firstLineNumber) {
|
|
170
|
+
const out = [];
|
|
171
|
+
const split = slice.split('\n');
|
|
172
|
+
for (let i = 0; i < split.length; i++) {
|
|
173
|
+
out.push(`${firstLineNumber + i}\t${split[i]}`);
|
|
174
|
+
}
|
|
175
|
+
return out.join('\n');
|
|
176
|
+
}
|
|
81
177
|
/**
|
|
82
178
|
* Mark a Claude session as having consulted MCP tools.
|
|
83
179
|
* This enables Grep/Glob/Bash commands that would otherwise be blocked.
|
|
@@ -135,7 +231,7 @@ exports.tools = [
|
|
|
135
231
|
},
|
|
136
232
|
{
|
|
137
233
|
name: 'codegraph_context',
|
|
138
|
-
description: 'PRIMARY TOOL
|
|
234
|
+
description: 'PRIMARY TOOL — call this FIRST for any "how does X work", architecture, feature, or bug-context question. Composes search + node + callers + callees and returns entry points, related symbols, and key code in ONE call — usually enough to answer with no further search/Read/Grep. Prefer this over chaining codegraph_search + codegraph_node, and over codegraph_explore. NOTE: provides CODE context, not product requirements; for new features still clarify UX/edge cases with the user.',
|
|
139
235
|
inputSchema: {
|
|
140
236
|
type: 'object',
|
|
141
237
|
properties: {
|
|
@@ -220,7 +316,7 @@ exports.tools = [
|
|
|
220
316
|
},
|
|
221
317
|
{
|
|
222
318
|
name: 'codegraph_node',
|
|
223
|
-
description: 'Get detailed
|
|
319
|
+
description: 'Get detailed info about ONE symbol (location, signature, docstring). Pass includeCode=true for source: a function/method returns its body; a class/interface/struct/enum returns a compact member OUTLINE (fields + method signatures + line numbers), not every method body — Read or codegraph_node a specific member for its body. Keep includeCode=false to minimize context. For SEVERAL related symbols, make ONE codegraph_explore (or codegraph_context) call instead of many node calls — repeated node calls each re-read the whole context and cost far more.',
|
|
224
320
|
inputSchema: {
|
|
225
321
|
type: 'object',
|
|
226
322
|
properties: {
|
|
@@ -240,7 +336,7 @@ exports.tools = [
|
|
|
240
336
|
},
|
|
241
337
|
{
|
|
242
338
|
name: 'codegraph_explore',
|
|
243
|
-
description: '
|
|
339
|
+
description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts".',
|
|
244
340
|
inputSchema: {
|
|
245
341
|
type: 'object',
|
|
246
342
|
properties: {
|
|
@@ -312,6 +408,9 @@ class ToolHandler {
|
|
|
312
408
|
cg;
|
|
313
409
|
// Cache of opened CodeGraph instances for cross-project queries
|
|
314
410
|
projectCache = new Map();
|
|
411
|
+
// The directory the server last searched for a default project. Surfaced in
|
|
412
|
+
// the "not initialized" error so users can see why detection missed.
|
|
413
|
+
defaultProjectHint = null;
|
|
315
414
|
constructor(cg) {
|
|
316
415
|
this.cg = cg;
|
|
317
416
|
}
|
|
@@ -321,6 +420,13 @@ class ToolHandler {
|
|
|
321
420
|
setDefaultCodeGraph(cg) {
|
|
322
421
|
this.cg = cg;
|
|
323
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Record the directory the server tried to resolve the default project from.
|
|
425
|
+
* Used only to make the "no default project" error actionable.
|
|
426
|
+
*/
|
|
427
|
+
setDefaultProjectHint(searchedPath) {
|
|
428
|
+
this.defaultProjectHint = searchedPath;
|
|
429
|
+
}
|
|
324
430
|
/**
|
|
325
431
|
* Whether a default CodeGraph instance is available
|
|
326
432
|
*/
|
|
@@ -364,7 +470,14 @@ class ToolHandler {
|
|
|
364
470
|
getCodeGraph(projectPath) {
|
|
365
471
|
if (!projectPath) {
|
|
366
472
|
if (!this.cg) {
|
|
367
|
-
|
|
473
|
+
const searched = this.defaultProjectHint ?? process.cwd();
|
|
474
|
+
throw new Error('No CodeGraph project is loaded for this session.\n' +
|
|
475
|
+
`Searched for a .codegraph/ directory starting from: ${searched}\n` +
|
|
476
|
+
'The index is likely fine — this is a working-directory detection issue: ' +
|
|
477
|
+
"the MCP client launched the server outside your project and didn't report the " +
|
|
478
|
+
'workspace root. Fix it either way:\n' +
|
|
479
|
+
' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
|
|
480
|
+
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]');
|
|
368
481
|
}
|
|
369
482
|
return this.cg;
|
|
370
483
|
}
|
|
@@ -619,22 +732,34 @@ class ToolHandler {
|
|
|
619
732
|
const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
|
|
620
733
|
return this.textResult(this.truncateOutput(formatted));
|
|
621
734
|
}
|
|
622
|
-
/** Maximum output for explore tool — sized to stay under MCP client token limits (~10k tokens) */
|
|
623
|
-
static EXPLORE_MAX_OUTPUT = 35000;
|
|
624
735
|
/**
|
|
625
736
|
* Handle codegraph_explore — deep exploration in a single call
|
|
626
737
|
*
|
|
627
738
|
* Strategy: find relevant symbols via graph traversal, group by file,
|
|
628
739
|
* then read contiguous file sections covering all symbols per file.
|
|
629
740
|
* This replaces multiple codegraph_node + Read calls.
|
|
741
|
+
*
|
|
742
|
+
* Output size is adaptive to project file count via
|
|
743
|
+
* `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
|
|
744
|
+
* tax on small projects while earning its keep on large ones.
|
|
630
745
|
*/
|
|
631
746
|
async handleExplore(args) {
|
|
632
747
|
const query = this.validateString(args.query, 'query');
|
|
633
748
|
if (typeof query !== 'string')
|
|
634
749
|
return query;
|
|
635
750
|
const cg = this.getCodeGraph(args.projectPath);
|
|
636
|
-
const maxFiles = (0, utils_1.clamp)(args.maxFiles || 12, 1, 20);
|
|
637
751
|
const projectRoot = cg.getProjectRoot();
|
|
752
|
+
// Resolve adaptive output budget from project size. Falls back to the
|
|
753
|
+
// largest-tier defaults if stats aren't available, which preserves
|
|
754
|
+
// pre-#185 behavior for callers that hit the rare stats failure.
|
|
755
|
+
let budget;
|
|
756
|
+
try {
|
|
757
|
+
budget = getExploreOutputBudget(cg.getStats().fileCount);
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
budget = getExploreOutputBudget(Infinity);
|
|
761
|
+
}
|
|
762
|
+
const maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
|
|
638
763
|
// Step 1: Find relevant context with generous parameters.
|
|
639
764
|
// Use a large maxNodes budget — explore has its own 35k char output limit
|
|
640
765
|
// that prevents context bloat, so more nodes just means better coverage
|
|
@@ -718,7 +843,7 @@ class ToolHandler {
|
|
|
718
843
|
// Relationship map — show how symbols connect
|
|
719
844
|
const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
|
|
720
845
|
);
|
|
721
|
-
if (significantEdges.length > 0) {
|
|
846
|
+
if (budget.includeRelationships && significantEdges.length > 0) {
|
|
722
847
|
lines.push('### Relationships');
|
|
723
848
|
lines.push('');
|
|
724
849
|
// Group edges by kind for readability
|
|
@@ -733,14 +858,14 @@ class ToolHandler {
|
|
|
733
858
|
byKind.set(edge.kind, group);
|
|
734
859
|
}
|
|
735
860
|
for (const [kind, edges] of byKind) {
|
|
736
|
-
|
|
737
|
-
const shown = edges.slice(0,
|
|
861
|
+
const cap = budget.maxEdgesPerRelationshipKind;
|
|
862
|
+
const shown = edges.slice(0, cap);
|
|
738
863
|
lines.push(`**${kind}:**`);
|
|
739
864
|
for (const e of shown) {
|
|
740
865
|
lines.push(`- ${e.source} → ${e.target}`);
|
|
741
866
|
}
|
|
742
|
-
if (edges.length >
|
|
743
|
-
lines.push(`- ... and ${edges.length -
|
|
867
|
+
if (edges.length > cap) {
|
|
868
|
+
lines.push(`- ... and ${edges.length - cap} more`);
|
|
744
869
|
}
|
|
745
870
|
lines.push('');
|
|
746
871
|
}
|
|
@@ -750,10 +875,11 @@ class ToolHandler {
|
|
|
750
875
|
lines.push('');
|
|
751
876
|
let totalChars = lines.join('\n').length;
|
|
752
877
|
let filesIncluded = 0;
|
|
878
|
+
let anyFileTrimmed = false;
|
|
753
879
|
for (const [filePath, group] of sortedFiles) {
|
|
754
880
|
if (filesIncluded >= maxFiles)
|
|
755
881
|
break;
|
|
756
|
-
if (totalChars >
|
|
882
|
+
if (totalChars > budget.maxOutputChars * 0.9)
|
|
757
883
|
break;
|
|
758
884
|
const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, filePath);
|
|
759
885
|
if (!absPath || !(0, fs_1.existsSync)(absPath))
|
|
@@ -768,14 +894,37 @@ class ToolHandler {
|
|
|
768
894
|
const fileLines = fileContent.split('\n');
|
|
769
895
|
const lang = group.nodes[0]?.language || '';
|
|
770
896
|
// Cluster nearby symbols to avoid reading huge gaps between distant symbols.
|
|
771
|
-
// Sort by start line, then merge overlapping/adjacent ranges (within
|
|
772
|
-
// Include both node ranges AND edge source
|
|
773
|
-
// with component usages/calls are
|
|
897
|
+
// Sort by start line, then merge overlapping/adjacent ranges (within the
|
|
898
|
+
// adaptive gap threshold). Include both node ranges AND edge source
|
|
899
|
+
// locations so template sections with component usages/calls are
|
|
900
|
+
// covered (not just script block symbols).
|
|
901
|
+
//
|
|
902
|
+
// Each range carries an `importance` score so we can rank clusters
|
|
903
|
+
// when the per-file budget forces us to drop some: entry-point nodes
|
|
904
|
+
// are worth 10, directly-connected nodes 3, peripheral nodes 1, and
|
|
905
|
+
// bare edge-source lines 2 (less than a connected node but more than
|
|
906
|
+
// a peripheral one — they hint at a reference but aren't a definition).
|
|
907
|
+
// Container kinds whose body can span most/all of a file. When such a
|
|
908
|
+
// node covers most of the file we drop it from the ranges: keeping it
|
|
909
|
+
// would merge every method inside it into one giant cluster spanning
|
|
910
|
+
// the whole file, which then tail-trims down to just the container's
|
|
911
|
+
// opening lines (its header/declarations) and buries the methods the
|
|
912
|
+
// query actually asked about (#185 follow-up — Session.swift in
|
|
913
|
+
// Alamofire is the canonical case: the `Session` class spans ~1,400
|
|
914
|
+
// lines). We want the granular symbols inside, not the envelope.
|
|
915
|
+
const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
|
|
774
916
|
const ranges = group.nodes
|
|
775
917
|
.filter(n => n.startLine > 0 && n.endLine > 0)
|
|
776
|
-
//
|
|
777
|
-
.filter(n => !(n.kind
|
|
778
|
-
.map(n =>
|
|
918
|
+
// Drop whole-file envelope nodes (containers covering >50% of the file).
|
|
919
|
+
.filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
|
|
920
|
+
.map(n => {
|
|
921
|
+
let importance = 1;
|
|
922
|
+
if (entryNodeIds.has(n.id))
|
|
923
|
+
importance = 10;
|
|
924
|
+
else if (connectedToEntry.has(n.id))
|
|
925
|
+
importance = 3;
|
|
926
|
+
return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
|
|
927
|
+
});
|
|
779
928
|
// Add edge source locations in this file — captures template references
|
|
780
929
|
// (component usages, event handlers) that aren't nodes themselves.
|
|
781
930
|
// Query edges directly from the DB (not just the subgraph) because BFS
|
|
@@ -793,48 +942,148 @@ class ToolHandler {
|
|
|
793
942
|
// Look up target name from subgraph first, fall back to edge kind
|
|
794
943
|
const targetNode = subgraph.nodes.get(edge.target);
|
|
795
944
|
const targetName = targetNode?.name ?? edge.kind;
|
|
796
|
-
ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind });
|
|
945
|
+
ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
|
|
797
946
|
}
|
|
798
947
|
}
|
|
799
948
|
ranges.sort((a, b) => a.start - b.start);
|
|
800
949
|
if (ranges.length === 0)
|
|
801
950
|
continue;
|
|
802
|
-
const
|
|
951
|
+
const gapThreshold = budget.gapThreshold;
|
|
803
952
|
const clusters = [];
|
|
804
|
-
let current = {
|
|
953
|
+
let current = {
|
|
954
|
+
start: ranges[0].start,
|
|
955
|
+
end: ranges[0].end,
|
|
956
|
+
symbols: [`${ranges[0].name}(${ranges[0].kind})`],
|
|
957
|
+
score: ranges[0].importance,
|
|
958
|
+
maxImportance: ranges[0].importance,
|
|
959
|
+
};
|
|
805
960
|
for (let i = 1; i < ranges.length; i++) {
|
|
806
961
|
const r = ranges[i];
|
|
807
|
-
if (r.start <= current.end +
|
|
962
|
+
if (r.start <= current.end + gapThreshold) {
|
|
808
963
|
current.end = Math.max(current.end, r.end);
|
|
809
964
|
current.symbols.push(`${r.name}(${r.kind})`);
|
|
965
|
+
current.score += r.importance;
|
|
966
|
+
current.maxImportance = Math.max(current.maxImportance, r.importance);
|
|
810
967
|
}
|
|
811
968
|
else {
|
|
812
969
|
clusters.push(current);
|
|
813
|
-
current = {
|
|
970
|
+
current = {
|
|
971
|
+
start: r.start,
|
|
972
|
+
end: r.end,
|
|
973
|
+
symbols: [`${r.name}(${r.kind})`],
|
|
974
|
+
score: r.importance,
|
|
975
|
+
maxImportance: r.importance,
|
|
976
|
+
};
|
|
814
977
|
}
|
|
815
978
|
}
|
|
816
979
|
clusters.push(current);
|
|
817
|
-
// Build file section output from clusters
|
|
980
|
+
// Build file section output from clusters, capped by per-file budget.
|
|
981
|
+
// The pathological case (#185): a file like Session.swift where every
|
|
982
|
+
// method is adjacent collapses into one cluster spanning the whole
|
|
983
|
+
// file, and dumping that into the agent's context is most of the
|
|
984
|
+
// token cost on small projects. We pick clusters in priority order
|
|
985
|
+
// until the per-file char cap is hit. Truly enormous single clusters
|
|
986
|
+
// get tail-trimmed with a marker.
|
|
818
987
|
const contextPadding = 3;
|
|
988
|
+
const withLineNumbers = exploreLineNumbersEnabled();
|
|
989
|
+
const buildSection = (c) => {
|
|
990
|
+
const startIdx = Math.max(0, c.start - 1 - contextPadding);
|
|
991
|
+
const endIdx = Math.min(fileLines.length, c.end + contextPadding);
|
|
992
|
+
const slice = fileLines.slice(startIdx, endIdx).join('\n');
|
|
993
|
+
// startIdx is 0-based, so the slice's first line is line startIdx + 1.
|
|
994
|
+
return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
|
|
995
|
+
};
|
|
996
|
+
// Language-neutral separator (no `//` — not a comment in Python, Ruby,
|
|
997
|
+
// etc.). With line numbers on, the line-number jump also signals the gap.
|
|
998
|
+
const GAP_MARKER = '\n\n... (gap) ...\n\n';
|
|
999
|
+
// Rank clusters for inclusion under the per-file cap. Entry-point
|
|
1000
|
+
// clusters come first: a cluster containing a query entry point
|
|
1001
|
+
// (importance 10) must outrank a dense block of mere declarations,
|
|
1002
|
+
// otherwise on a large file like Session.swift the top-of-file class
|
|
1003
|
+
// header + property list (many adjacent low-importance nodes, high
|
|
1004
|
+
// density) wins the budget and buries the actual methods the query
|
|
1005
|
+
// asked about (perform/didCreateURLRequest/task live deep in the
|
|
1006
|
+
// file). Within the same importance tier, prefer density (score per
|
|
1007
|
+
// line) so we still favor focused clusters over sprawling ones, then
|
|
1008
|
+
// smaller span as a cheap-to-include tiebreak.
|
|
1009
|
+
const rankedClusters = clusters
|
|
1010
|
+
.map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
|
|
1011
|
+
.sort((a, b) => {
|
|
1012
|
+
if (b.c.maxImportance !== a.c.maxImportance)
|
|
1013
|
+
return b.c.maxImportance - a.c.maxImportance;
|
|
1014
|
+
const densityA = a.c.score / a.span;
|
|
1015
|
+
const densityB = b.c.score / b.span;
|
|
1016
|
+
if (densityB !== densityA)
|
|
1017
|
+
return densityB - densityA;
|
|
1018
|
+
if (b.c.score !== a.c.score)
|
|
1019
|
+
return b.c.score - a.c.score;
|
|
1020
|
+
return a.span - b.span;
|
|
1021
|
+
});
|
|
1022
|
+
const chosenIndices = new Set();
|
|
1023
|
+
let projectedChars = 0;
|
|
1024
|
+
for (const rc of rankedClusters) {
|
|
1025
|
+
const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
|
|
1026
|
+
// Always take the top-ranked cluster, even if oversize, so we don't
|
|
1027
|
+
// return an empty file section (agent would then re-Read the file,
|
|
1028
|
+
// negating the savings).
|
|
1029
|
+
if (chosenIndices.size === 0) {
|
|
1030
|
+
chosenIndices.add(rc.idx);
|
|
1031
|
+
projectedChars += sectionLen;
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
if (projectedChars + sectionLen > budget.maxCharsPerFile)
|
|
1035
|
+
continue;
|
|
1036
|
+
chosenIndices.add(rc.idx);
|
|
1037
|
+
projectedChars += sectionLen;
|
|
1038
|
+
}
|
|
1039
|
+
// Emit chosen clusters in source order so the file reads top-to-bottom.
|
|
819
1040
|
let fileSection = '';
|
|
820
1041
|
const allSymbols = [];
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1042
|
+
let fileTrimmed = false;
|
|
1043
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
1044
|
+
if (!chosenIndices.has(i))
|
|
1045
|
+
continue;
|
|
1046
|
+
const cluster = clusters[i];
|
|
1047
|
+
const section = buildSection(cluster);
|
|
1048
|
+
if (fileSection.length > 0)
|
|
1049
|
+
fileSection += GAP_MARKER;
|
|
828
1050
|
fileSection += section;
|
|
829
1051
|
allSymbols.push(...cluster.symbols);
|
|
830
1052
|
}
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1053
|
+
// If a single chosen cluster is still oversize (long monolithic
|
|
1054
|
+
// function), tail-trim it. Better one trimmed view than nothing.
|
|
1055
|
+
if (fileSection.length > budget.maxCharsPerFile) {
|
|
1056
|
+
fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
|
|
1057
|
+
fileTrimmed = true;
|
|
1058
|
+
}
|
|
1059
|
+
if (chosenIndices.size < clusters.length || fileTrimmed) {
|
|
1060
|
+
anyFileTrimmed = true;
|
|
1061
|
+
}
|
|
1062
|
+
// Dedupe + cap the symbols list shown in the per-file header. Some
|
|
1063
|
+
// files (Session.swift in Alamofire) produced 3.4KB symbol lists
|
|
1064
|
+
// from cluster scoring + edge-source lines, dwarfing the per-file
|
|
1065
|
+
// body cap. Show top names by frequency, with a "+N more" tail.
|
|
1066
|
+
const symbolCounts = new Map();
|
|
1067
|
+
for (const s of allSymbols) {
|
|
1068
|
+
symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
|
|
1069
|
+
}
|
|
1070
|
+
const sortedSymbols = [...symbolCounts.entries()]
|
|
1071
|
+
.sort((a, b) => b[1] - a[1])
|
|
1072
|
+
.map(([name]) => name);
|
|
1073
|
+
const headerCap = budget.maxSymbolsInFileHeader;
|
|
1074
|
+
const headerSymbols = sortedSymbols.slice(0, headerCap);
|
|
1075
|
+
const omittedCount = sortedSymbols.length - headerSymbols.length;
|
|
1076
|
+
const headerSuffix = omittedCount > 0
|
|
1077
|
+
? `${headerSymbols.join(', ')}, +${omittedCount} more`
|
|
1078
|
+
: headerSymbols.join(', ');
|
|
1079
|
+
const fileHeader = `#### ${filePath} — ${headerSuffix}`;
|
|
1080
|
+
// Respect the total output cap on a file-by-file basis.
|
|
1081
|
+
if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
|
|
1082
|
+
const remaining = budget.maxOutputChars - totalChars - 200;
|
|
1083
|
+
if (remaining < 500)
|
|
835
1084
|
break;
|
|
836
|
-
const trimmed = fileSection.slice(0,
|
|
837
|
-
lines.push(
|
|
1085
|
+
const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
|
|
1086
|
+
lines.push(fileHeader);
|
|
838
1087
|
lines.push('');
|
|
839
1088
|
lines.push('```' + lang);
|
|
840
1089
|
lines.push(trimmed);
|
|
@@ -842,9 +1091,10 @@ class ToolHandler {
|
|
|
842
1091
|
lines.push('');
|
|
843
1092
|
totalChars += trimmed.length + 200;
|
|
844
1093
|
filesIncluded++;
|
|
1094
|
+
anyFileTrimmed = true;
|
|
845
1095
|
break;
|
|
846
1096
|
}
|
|
847
|
-
lines.push(
|
|
1097
|
+
lines.push(fileHeader);
|
|
848
1098
|
lines.push('');
|
|
849
1099
|
lines.push('```' + lang);
|
|
850
1100
|
lines.push(fileSection);
|
|
@@ -853,38 +1103,66 @@ class ToolHandler {
|
|
|
853
1103
|
totalChars += fileSection.length + 200;
|
|
854
1104
|
filesIncluded++;
|
|
855
1105
|
}
|
|
856
|
-
// Add remaining files as references (from both relevant and peripheral files)
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
lines.push(
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1106
|
+
// Add remaining files as references (from both relevant and peripheral files).
|
|
1107
|
+
// Small projects (per budget) skip this — the relevant story already fits
|
|
1108
|
+
// in the source section, and a trailing pointer list is pure overhead.
|
|
1109
|
+
if (budget.includeAdditionalFiles) {
|
|
1110
|
+
const remainingRelevant = sortedFiles.slice(filesIncluded);
|
|
1111
|
+
const peripheralFiles = [...fileGroups.entries()]
|
|
1112
|
+
.filter(([, group]) => group.score < 3)
|
|
1113
|
+
.sort((a, b) => b[1].score - a[1].score);
|
|
1114
|
+
const remainingFiles = [...remainingRelevant, ...peripheralFiles];
|
|
1115
|
+
if (remainingFiles.length > 0) {
|
|
1116
|
+
lines.push('### Additional relevant files (not shown)');
|
|
1117
|
+
lines.push('');
|
|
1118
|
+
for (const [filePath, group] of remainingFiles.slice(0, 10)) {
|
|
1119
|
+
const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
|
|
1120
|
+
lines.push(`- ${filePath}: ${symbols}`);
|
|
1121
|
+
}
|
|
1122
|
+
if (remainingFiles.length > 10) {
|
|
1123
|
+
lines.push(`- ... and ${remainingFiles.length - 10} more files`);
|
|
1124
|
+
}
|
|
871
1125
|
}
|
|
872
1126
|
}
|
|
873
|
-
// Add completeness signal so agents know they don't need to re-read these files
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
try {
|
|
879
|
-
const stats = cg.getStats();
|
|
880
|
-
const budget = getExploreBudget(stats.fileCount);
|
|
1127
|
+
// Add completeness signal so agents know they don't need to re-read these files.
|
|
1128
|
+
// On small projects the budget gates this off — but if we actually had to
|
|
1129
|
+
// trim or drop clusters, surface a brief note so the agent knows it can
|
|
1130
|
+
// still Read for more detail.
|
|
1131
|
+
if (budget.includeCompletenessSignal) {
|
|
881
1132
|
lines.push('');
|
|
882
|
-
lines.push(
|
|
1133
|
+
lines.push('---');
|
|
1134
|
+
lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`);
|
|
883
1135
|
}
|
|
884
|
-
|
|
885
|
-
|
|
1136
|
+
else if (anyFileTrimmed) {
|
|
1137
|
+
lines.push('');
|
|
1138
|
+
lines.push(`> Some file sections were trimmed for size. Use \`codegraph_node\` or Read for the full source if needed.`);
|
|
886
1139
|
}
|
|
887
|
-
|
|
1140
|
+
// Add explore budget note based on project size
|
|
1141
|
+
if (budget.includeBudgetNote) {
|
|
1142
|
+
try {
|
|
1143
|
+
const stats = cg.getStats();
|
|
1144
|
+
const callBudget = getExploreBudget(stats.fileCount);
|
|
1145
|
+
lines.push('');
|
|
1146
|
+
lines.push(`> **Explore budget: ${callBudget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${callBudget} calls — do NOT make additional explore calls beyond this budget.`);
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
// Stats unavailable — skip budget note
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// Hard-cap to the adaptive budget. The per-file loop bounds the source
|
|
1153
|
+
// sections, but the relationship map, additional-files list, and
|
|
1154
|
+
// completeness/budget notes can still push the assembled output past
|
|
1155
|
+
// maxOutputChars (observed 30k against a 28k tier cap). A fat explore
|
|
1156
|
+
// payload persists in the agent's context and is re-read as cache-input
|
|
1157
|
+
// on every subsequent turn, so the overrun is paid many times over.
|
|
1158
|
+
const output = lines.join('\n');
|
|
1159
|
+
if (output.length > budget.maxOutputChars) {
|
|
1160
|
+
const cut = output.slice(0, budget.maxOutputChars);
|
|
1161
|
+
const lastNewline = cut.lastIndexOf('\n');
|
|
1162
|
+
const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
|
|
1163
|
+
return this.textResult(safe + '\n\n... (explore output truncated to budget — use codegraph_node or Read for more)');
|
|
1164
|
+
}
|
|
1165
|
+
return this.textResult(output);
|
|
888
1166
|
}
|
|
889
1167
|
/**
|
|
890
1168
|
* Handle codegraph_node
|
|
@@ -901,10 +1179,22 @@ class ToolHandler {
|
|
|
901
1179
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
902
1180
|
}
|
|
903
1181
|
let code = null;
|
|
1182
|
+
let outline = null;
|
|
904
1183
|
if (includeCode) {
|
|
905
|
-
|
|
1184
|
+
// For container symbols (class/interface/struct/…), the full body is the
|
|
1185
|
+
// sum of every method body — a wall of source (e.g. a 10k-char class)
|
|
1186
|
+
// that bloats context and is rarely needed in full. Return a structural
|
|
1187
|
+
// outline (members + signatures + line numbers) instead; the agent can
|
|
1188
|
+
// Read or codegraph_node a specific method for its body. Leaf symbols
|
|
1189
|
+
// (function/method/etc.) return their full body as before.
|
|
1190
|
+
if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
|
|
1191
|
+
outline = this.buildContainerOutline(cg, match.node);
|
|
1192
|
+
}
|
|
1193
|
+
if (!outline) {
|
|
1194
|
+
code = await cg.getCode(match.node.id);
|
|
1195
|
+
}
|
|
906
1196
|
}
|
|
907
|
-
const formatted = this.formatNodeDetails(match.node, code) + match.note;
|
|
1197
|
+
const formatted = this.formatNodeDetails(match.node, code, outline) + match.note;
|
|
908
1198
|
return this.textResult(this.truncateOutput(formatted));
|
|
909
1199
|
}
|
|
910
1200
|
/**
|
|
@@ -1281,7 +1571,28 @@ class ToolHandler {
|
|
|
1281
1571
|
}
|
|
1282
1572
|
return lines.join('\n');
|
|
1283
1573
|
}
|
|
1284
|
-
|
|
1574
|
+
/**
|
|
1575
|
+
* Build a compact structural outline of a container symbol from its
|
|
1576
|
+
* indexed children (methods, fields, properties, …) — name, kind,
|
|
1577
|
+
* line number, and signature — so the agent gets the shape of a class
|
|
1578
|
+
* without the full source of every method. Returns '' when the container
|
|
1579
|
+
* has no indexed children, so the caller can fall back to full source.
|
|
1580
|
+
*/
|
|
1581
|
+
buildContainerOutline(cg, node) {
|
|
1582
|
+
const children = cg.getChildren(node.id)
|
|
1583
|
+
.filter(c => c.kind !== 'import' && c.kind !== 'export')
|
|
1584
|
+
.sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
|
|
1585
|
+
if (children.length === 0)
|
|
1586
|
+
return '';
|
|
1587
|
+
const lines = [`**Members (${children.length}):**`, ''];
|
|
1588
|
+
for (const c of children) {
|
|
1589
|
+
const loc = c.startLine ? `:${c.startLine}` : '';
|
|
1590
|
+
const sig = c.signature ? ` — \`${c.signature}\`` : '';
|
|
1591
|
+
lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
|
|
1592
|
+
}
|
|
1593
|
+
return lines.join('\n');
|
|
1594
|
+
}
|
|
1595
|
+
formatNodeDetails(node, code, outline) {
|
|
1285
1596
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
1286
1597
|
const lines = [
|
|
1287
1598
|
`## ${node.name} (${node.kind})`,
|
|
@@ -1295,7 +1606,10 @@ class ToolHandler {
|
|
|
1295
1606
|
if (node.docstring && node.docstring.length < 200) {
|
|
1296
1607
|
lines.push('', node.docstring);
|
|
1297
1608
|
}
|
|
1298
|
-
if (
|
|
1609
|
+
if (outline) {
|
|
1610
|
+
lines.push('', outline, '', `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
|
|
1611
|
+
}
|
|
1612
|
+
else if (code) {
|
|
1299
1613
|
lines.push('', '```' + node.language, code, '```');
|
|
1300
1614
|
}
|
|
1301
1615
|
return lines.join('\n');
|