@colbymchenry/codegraph 0.7.9 → 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 +49 -49
- package/dist/bin/codegraph.js +47 -20
- package/dist/bin/codegraph.js.map +1 -1
- package/dist/bin/node-version-check.d.ts +3 -0
- package/dist/bin/node-version-check.d.ts.map +1 -1
- package/dist/bin/node-version-check.js +5 -2
- package/dist/bin/node-version-check.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/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +7 -1
- package/dist/db/queries.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 +74 -5
- 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 +12 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +143 -20
- 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 +75 -5
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +470 -87
- 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/dist/ui/glyphs.d.ts +42 -0
- package/dist/ui/glyphs.d.ts.map +1 -0
- package/dist/ui/glyphs.js +78 -0
- package/dist/ui/glyphs.js.map +1 -0
- package/dist/ui/shimmer-worker.js +17 -11
- package/dist/ui/shimmer-worker.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");
|
|
@@ -49,6 +50,28 @@ const path_1 = require("path");
|
|
|
49
50
|
const db_1 = require("../db");
|
|
50
51
|
/** Maximum output length to prevent context bloat (characters) */
|
|
51
52
|
const MAX_OUTPUT_LENGTH = 15000;
|
|
53
|
+
/**
|
|
54
|
+
* Rust path roots that have no file-system equivalent — `crate` is the
|
|
55
|
+
* current crate, `super` is the parent module, `self` is the current
|
|
56
|
+
* module. Used by `matchesSymbol` to strip these before file-path
|
|
57
|
+
* matching so `crate::configurator::stage_apply::run` resolves the
|
|
58
|
+
* same as `configurator::stage_apply::run`.
|
|
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
|
+
]);
|
|
70
|
+
/** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
|
|
71
|
+
function lastQualifierPart(symbol) {
|
|
72
|
+
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
|
73
|
+
return parts[parts.length - 1] ?? symbol;
|
|
74
|
+
}
|
|
52
75
|
/**
|
|
53
76
|
* Calculate the recommended number of codegraph_explore calls based on project size.
|
|
54
77
|
* Larger codebases need more exploration calls to cover their surface area,
|
|
@@ -65,6 +88,92 @@ function getExploreBudget(fileCount) {
|
|
|
65
88
|
return 4;
|
|
66
89
|
return 5;
|
|
67
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
|
+
}
|
|
68
177
|
/**
|
|
69
178
|
* Mark a Claude session as having consulted MCP tools.
|
|
70
179
|
* This enables Grep/Glob/Bash commands that would otherwise be blocked.
|
|
@@ -122,7 +231,7 @@ exports.tools = [
|
|
|
122
231
|
},
|
|
123
232
|
{
|
|
124
233
|
name: 'codegraph_context',
|
|
125
|
-
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.',
|
|
126
235
|
inputSchema: {
|
|
127
236
|
type: 'object',
|
|
128
237
|
properties: {
|
|
@@ -207,7 +316,7 @@ exports.tools = [
|
|
|
207
316
|
},
|
|
208
317
|
{
|
|
209
318
|
name: 'codegraph_node',
|
|
210
|
-
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.',
|
|
211
320
|
inputSchema: {
|
|
212
321
|
type: 'object',
|
|
213
322
|
properties: {
|
|
@@ -227,7 +336,7 @@ exports.tools = [
|
|
|
227
336
|
},
|
|
228
337
|
{
|
|
229
338
|
name: 'codegraph_explore',
|
|
230
|
-
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".',
|
|
231
340
|
inputSchema: {
|
|
232
341
|
type: 'object',
|
|
233
342
|
properties: {
|
|
@@ -299,6 +408,9 @@ class ToolHandler {
|
|
|
299
408
|
cg;
|
|
300
409
|
// Cache of opened CodeGraph instances for cross-project queries
|
|
301
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;
|
|
302
414
|
constructor(cg) {
|
|
303
415
|
this.cg = cg;
|
|
304
416
|
}
|
|
@@ -308,6 +420,13 @@ class ToolHandler {
|
|
|
308
420
|
setDefaultCodeGraph(cg) {
|
|
309
421
|
this.cg = cg;
|
|
310
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
|
+
}
|
|
311
430
|
/**
|
|
312
431
|
* Whether a default CodeGraph instance is available
|
|
313
432
|
*/
|
|
@@ -351,7 +470,14 @@ class ToolHandler {
|
|
|
351
470
|
getCodeGraph(projectPath) {
|
|
352
471
|
if (!projectPath) {
|
|
353
472
|
if (!this.cg) {
|
|
354
|
-
|
|
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"]');
|
|
355
481
|
}
|
|
356
482
|
return this.cg;
|
|
357
483
|
}
|
|
@@ -606,22 +732,34 @@ class ToolHandler {
|
|
|
606
732
|
const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
|
|
607
733
|
return this.textResult(this.truncateOutput(formatted));
|
|
608
734
|
}
|
|
609
|
-
/** Maximum output for explore tool — sized to stay under MCP client token limits (~10k tokens) */
|
|
610
|
-
static EXPLORE_MAX_OUTPUT = 35000;
|
|
611
735
|
/**
|
|
612
736
|
* Handle codegraph_explore — deep exploration in a single call
|
|
613
737
|
*
|
|
614
738
|
* Strategy: find relevant symbols via graph traversal, group by file,
|
|
615
739
|
* then read contiguous file sections covering all symbols per file.
|
|
616
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.
|
|
617
745
|
*/
|
|
618
746
|
async handleExplore(args) {
|
|
619
747
|
const query = this.validateString(args.query, 'query');
|
|
620
748
|
if (typeof query !== 'string')
|
|
621
749
|
return query;
|
|
622
750
|
const cg = this.getCodeGraph(args.projectPath);
|
|
623
|
-
const maxFiles = (0, utils_1.clamp)(args.maxFiles || 12, 1, 20);
|
|
624
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);
|
|
625
763
|
// Step 1: Find relevant context with generous parameters.
|
|
626
764
|
// Use a large maxNodes budget — explore has its own 35k char output limit
|
|
627
765
|
// that prevents context bloat, so more nodes just means better coverage
|
|
@@ -705,7 +843,7 @@ class ToolHandler {
|
|
|
705
843
|
// Relationship map — show how symbols connect
|
|
706
844
|
const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
|
|
707
845
|
);
|
|
708
|
-
if (significantEdges.length > 0) {
|
|
846
|
+
if (budget.includeRelationships && significantEdges.length > 0) {
|
|
709
847
|
lines.push('### Relationships');
|
|
710
848
|
lines.push('');
|
|
711
849
|
// Group edges by kind for readability
|
|
@@ -720,14 +858,14 @@ class ToolHandler {
|
|
|
720
858
|
byKind.set(edge.kind, group);
|
|
721
859
|
}
|
|
722
860
|
for (const [kind, edges] of byKind) {
|
|
723
|
-
|
|
724
|
-
const shown = edges.slice(0,
|
|
861
|
+
const cap = budget.maxEdgesPerRelationshipKind;
|
|
862
|
+
const shown = edges.slice(0, cap);
|
|
725
863
|
lines.push(`**${kind}:**`);
|
|
726
864
|
for (const e of shown) {
|
|
727
865
|
lines.push(`- ${e.source} → ${e.target}`);
|
|
728
866
|
}
|
|
729
|
-
if (edges.length >
|
|
730
|
-
lines.push(`- ... and ${edges.length -
|
|
867
|
+
if (edges.length > cap) {
|
|
868
|
+
lines.push(`- ... and ${edges.length - cap} more`);
|
|
731
869
|
}
|
|
732
870
|
lines.push('');
|
|
733
871
|
}
|
|
@@ -737,10 +875,11 @@ class ToolHandler {
|
|
|
737
875
|
lines.push('');
|
|
738
876
|
let totalChars = lines.join('\n').length;
|
|
739
877
|
let filesIncluded = 0;
|
|
878
|
+
let anyFileTrimmed = false;
|
|
740
879
|
for (const [filePath, group] of sortedFiles) {
|
|
741
880
|
if (filesIncluded >= maxFiles)
|
|
742
881
|
break;
|
|
743
|
-
if (totalChars >
|
|
882
|
+
if (totalChars > budget.maxOutputChars * 0.9)
|
|
744
883
|
break;
|
|
745
884
|
const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, filePath);
|
|
746
885
|
if (!absPath || !(0, fs_1.existsSync)(absPath))
|
|
@@ -755,14 +894,37 @@ class ToolHandler {
|
|
|
755
894
|
const fileLines = fileContent.split('\n');
|
|
756
895
|
const lang = group.nodes[0]?.language || '';
|
|
757
896
|
// Cluster nearby symbols to avoid reading huge gaps between distant symbols.
|
|
758
|
-
// Sort by start line, then merge overlapping/adjacent ranges (within
|
|
759
|
-
// Include both node ranges AND edge source
|
|
760
|
-
// 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']);
|
|
761
916
|
const ranges = group.nodes
|
|
762
917
|
.filter(n => n.startLine > 0 && n.endLine > 0)
|
|
763
|
-
//
|
|
764
|
-
.filter(n => !(n.kind
|
|
765
|
-
.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
|
+
});
|
|
766
928
|
// Add edge source locations in this file — captures template references
|
|
767
929
|
// (component usages, event handlers) that aren't nodes themselves.
|
|
768
930
|
// Query edges directly from the DB (not just the subgraph) because BFS
|
|
@@ -780,48 +942,148 @@ class ToolHandler {
|
|
|
780
942
|
// Look up target name from subgraph first, fall back to edge kind
|
|
781
943
|
const targetNode = subgraph.nodes.get(edge.target);
|
|
782
944
|
const targetName = targetNode?.name ?? edge.kind;
|
|
783
|
-
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 });
|
|
784
946
|
}
|
|
785
947
|
}
|
|
786
948
|
ranges.sort((a, b) => a.start - b.start);
|
|
787
949
|
if (ranges.length === 0)
|
|
788
950
|
continue;
|
|
789
|
-
const
|
|
951
|
+
const gapThreshold = budget.gapThreshold;
|
|
790
952
|
const clusters = [];
|
|
791
|
-
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
|
+
};
|
|
792
960
|
for (let i = 1; i < ranges.length; i++) {
|
|
793
961
|
const r = ranges[i];
|
|
794
|
-
if (r.start <= current.end +
|
|
962
|
+
if (r.start <= current.end + gapThreshold) {
|
|
795
963
|
current.end = Math.max(current.end, r.end);
|
|
796
964
|
current.symbols.push(`${r.name}(${r.kind})`);
|
|
965
|
+
current.score += r.importance;
|
|
966
|
+
current.maxImportance = Math.max(current.maxImportance, r.importance);
|
|
797
967
|
}
|
|
798
968
|
else {
|
|
799
969
|
clusters.push(current);
|
|
800
|
-
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
|
+
};
|
|
801
977
|
}
|
|
802
978
|
}
|
|
803
979
|
clusters.push(current);
|
|
804
|
-
// 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.
|
|
805
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.
|
|
806
1040
|
let fileSection = '';
|
|
807
1041
|
const allSymbols = [];
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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;
|
|
815
1050
|
fileSection += section;
|
|
816
1051
|
allSymbols.push(...cluster.symbols);
|
|
817
1052
|
}
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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)
|
|
822
1084
|
break;
|
|
823
|
-
const trimmed = fileSection.slice(0,
|
|
824
|
-
lines.push(
|
|
1085
|
+
const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
|
|
1086
|
+
lines.push(fileHeader);
|
|
825
1087
|
lines.push('');
|
|
826
1088
|
lines.push('```' + lang);
|
|
827
1089
|
lines.push(trimmed);
|
|
@@ -829,9 +1091,10 @@ class ToolHandler {
|
|
|
829
1091
|
lines.push('');
|
|
830
1092
|
totalChars += trimmed.length + 200;
|
|
831
1093
|
filesIncluded++;
|
|
1094
|
+
anyFileTrimmed = true;
|
|
832
1095
|
break;
|
|
833
1096
|
}
|
|
834
|
-
lines.push(
|
|
1097
|
+
lines.push(fileHeader);
|
|
835
1098
|
lines.push('');
|
|
836
1099
|
lines.push('```' + lang);
|
|
837
1100
|
lines.push(fileSection);
|
|
@@ -840,38 +1103,66 @@ class ToolHandler {
|
|
|
840
1103
|
totalChars += fileSection.length + 200;
|
|
841
1104
|
filesIncluded++;
|
|
842
1105
|
}
|
|
843
|
-
// Add remaining files as references (from both relevant and peripheral files)
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
lines.push(
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
+
}
|
|
858
1125
|
}
|
|
859
1126
|
}
|
|
860
|
-
// Add completeness signal so agents know they don't need to re-read these files
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
try {
|
|
866
|
-
const stats = cg.getStats();
|
|
867
|
-
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) {
|
|
868
1132
|
lines.push('');
|
|
869
|
-
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.`);
|
|
870
1135
|
}
|
|
871
|
-
|
|
872
|
-
|
|
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.`);
|
|
873
1139
|
}
|
|
874
|
-
|
|
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);
|
|
875
1166
|
}
|
|
876
1167
|
/**
|
|
877
1168
|
* Handle codegraph_node
|
|
@@ -888,10 +1179,22 @@ class ToolHandler {
|
|
|
888
1179
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
889
1180
|
}
|
|
890
1181
|
let code = null;
|
|
1182
|
+
let outline = null;
|
|
891
1183
|
if (includeCode) {
|
|
892
|
-
|
|
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
|
+
}
|
|
893
1196
|
}
|
|
894
|
-
const formatted = this.formatNodeDetails(match.node, code) + match.note;
|
|
1197
|
+
const formatted = this.formatNodeDetails(match.node, code, outline) + match.note;
|
|
895
1198
|
return this.textResult(this.truncateOutput(formatted));
|
|
896
1199
|
}
|
|
897
1200
|
/**
|
|
@@ -1092,9 +1395,22 @@ class ToolHandler {
|
|
|
1092
1395
|
* Returns the best match and a note about alternatives if any.
|
|
1093
1396
|
*/
|
|
1094
1397
|
/**
|
|
1095
|
-
* Check if a node matches a symbol query
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1398
|
+
* Check if a node matches a symbol query.
|
|
1399
|
+
*
|
|
1400
|
+
* Accepts simple names (`run`) and three flavors of qualifier:
|
|
1401
|
+
* - dotted `Session.request` (TS/JS/Python)
|
|
1402
|
+
* - colon-pair `stage_apply::run` (Rust, C++, Ruby)
|
|
1403
|
+
* - slash `configurator/stage_apply` (path-ish)
|
|
1404
|
+
*
|
|
1405
|
+
* Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
|
|
1406
|
+
* works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
|
|
1407
|
+
* the canonical `crate::module::symbol` form resolves.
|
|
1408
|
+
*
|
|
1409
|
+
* Resolution order, last part must always equal `node.name`:
|
|
1410
|
+
* 1. Suffix-match against `qualifiedName` (handles class-scoped methods
|
|
1411
|
+
* where the extractor builds the qualified name from the AST stack)
|
|
1412
|
+
* 2. File-path containment (handles file-derived modules in Rust/
|
|
1413
|
+
* Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
|
|
1098
1414
|
*/
|
|
1099
1415
|
matchesSymbol(node, symbol) {
|
|
1100
1416
|
// Simple name match
|
|
@@ -1103,20 +1419,50 @@ class ToolHandler {
|
|
|
1103
1419
|
// File basename match (e.g., "product-card" matches "product-card.liquid")
|
|
1104
1420
|
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol)
|
|
1105
1421
|
return true;
|
|
1106
|
-
// Qualified
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1422
|
+
// Qualified-name lookups: split on any supported separator. `\w` keeps
|
|
1423
|
+
// identifier chars (incl. `_`) intact; everything else is treated as
|
|
1424
|
+
// a separator we tolerate.
|
|
1425
|
+
if (!/[.\/]|::/.test(symbol))
|
|
1426
|
+
return false;
|
|
1427
|
+
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
|
1428
|
+
if (parts.length < 2)
|
|
1429
|
+
return false;
|
|
1430
|
+
const lastPart = parts[parts.length - 1];
|
|
1431
|
+
if (node.name !== lastPart)
|
|
1432
|
+
return false;
|
|
1433
|
+
// Stage 1: qualified-name suffix match. The extractor joins the
|
|
1434
|
+
// semantic hierarchy with `::`, so `Session.request` and
|
|
1435
|
+
// `Session::request` both become `Session::request` here.
|
|
1436
|
+
const colonSuffix = parts.join('::');
|
|
1437
|
+
if (node.qualifiedName.includes(colonSuffix))
|
|
1438
|
+
return true;
|
|
1439
|
+
// Stage 2: file-path containment. Rust modules and Python packages
|
|
1440
|
+
// are not in `qualifiedName` — they're encoded in the file path. So
|
|
1441
|
+
// `stage_apply::run` matches a `run` in any file whose path
|
|
1442
|
+
// contains a `stage_apply` segment (with or without an extension).
|
|
1443
|
+
//
|
|
1444
|
+
// Filter out Rust path prefixes that have no file-system equivalent.
|
|
1445
|
+
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
|
|
1446
|
+
if (containerHints.length === 0)
|
|
1447
|
+
return false;
|
|
1448
|
+
const segments = node.filePath.split('/').filter((s) => s.length > 0);
|
|
1449
|
+
return containerHints.every((hint) => segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint));
|
|
1114
1450
|
}
|
|
1115
1451
|
findSymbol(cg, symbol) {
|
|
1116
|
-
// Use higher limit for qualified lookups (e.g., "Session.request"
|
|
1117
|
-
// target may rank lower in FTS when
|
|
1118
|
-
|
|
1119
|
-
const
|
|
1452
|
+
// Use higher limit for qualified lookups (e.g., "Session.request",
|
|
1453
|
+
// "stage_apply::run") since the target may rank lower in FTS when
|
|
1454
|
+
// there are many partial matches across the qualifier parts.
|
|
1455
|
+
const isQualified = /[.\/]|::/.test(symbol);
|
|
1456
|
+
const limit = isQualified ? 50 : 10;
|
|
1457
|
+
let results = cg.searchNodes(symbol, { limit });
|
|
1458
|
+
// FTS strips colons as a special char, so `stage_apply::run` searches
|
|
1459
|
+
// for the literal `stage_applyrun` and finds nothing. Re-search by
|
|
1460
|
+
// the bare last part and let `matchesSymbol` filter by qualifier.
|
|
1461
|
+
if (isQualified && results.length === 0) {
|
|
1462
|
+
const tail = lastQualifierPart(symbol);
|
|
1463
|
+
if (tail && tail !== symbol)
|
|
1464
|
+
results = cg.searchNodes(tail, { limit });
|
|
1465
|
+
}
|
|
1120
1466
|
if (results.length === 0 || !results[0]) {
|
|
1121
1467
|
return null;
|
|
1122
1468
|
}
|
|
@@ -1131,7 +1477,12 @@ class ToolHandler {
|
|
|
1131
1477
|
const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
|
|
1132
1478
|
return { node: picked, note };
|
|
1133
1479
|
}
|
|
1134
|
-
// No exact match,
|
|
1480
|
+
// No exact match. For qualified lookups, don't silently fall back
|
|
1481
|
+
// to a fuzzy result — the user typed a specific qualifier, and
|
|
1482
|
+
// resolving `stage_apply::nonexistent_fn` to the unrelated
|
|
1483
|
+
// `stage_apply.rs` file would be actively misleading (#173).
|
|
1484
|
+
if (isQualified)
|
|
1485
|
+
return null;
|
|
1135
1486
|
return { node: results[0].node, note: '' };
|
|
1136
1487
|
}
|
|
1137
1488
|
/**
|
|
@@ -1139,7 +1490,15 @@ class ToolHandler {
|
|
|
1139
1490
|
* results across all matching symbols (e.g., multiple classes with an `execute` method).
|
|
1140
1491
|
*/
|
|
1141
1492
|
findAllSymbols(cg, symbol) {
|
|
1142
|
-
|
|
1493
|
+
let results = cg.searchNodes(symbol, { limit: 50 });
|
|
1494
|
+
// Mirror the fallback in `findSymbol` for qualified queries — FTS
|
|
1495
|
+
// strips colons, so a module-qualified lookup needs a second pass
|
|
1496
|
+
// by the bare last part.
|
|
1497
|
+
if (results.length === 0 && /[.\/]|::/.test(symbol)) {
|
|
1498
|
+
const tail = lastQualifierPart(symbol);
|
|
1499
|
+
if (tail && tail !== symbol)
|
|
1500
|
+
results = cg.searchNodes(tail, { limit: 50 });
|
|
1501
|
+
}
|
|
1143
1502
|
if (results.length === 0) {
|
|
1144
1503
|
return { nodes: [], note: '' };
|
|
1145
1504
|
}
|
|
@@ -1212,7 +1571,28 @@ class ToolHandler {
|
|
|
1212
1571
|
}
|
|
1213
1572
|
return lines.join('\n');
|
|
1214
1573
|
}
|
|
1215
|
-
|
|
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) {
|
|
1216
1596
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
1217
1597
|
const lines = [
|
|
1218
1598
|
`## ${node.name} (${node.kind})`,
|
|
@@ -1226,7 +1606,10 @@ class ToolHandler {
|
|
|
1226
1606
|
if (node.docstring && node.docstring.length < 200) {
|
|
1227
1607
|
lines.push('', node.docstring);
|
|
1228
1608
|
}
|
|
1229
|
-
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) {
|
|
1230
1613
|
lines.push('', '```' + node.language, code, '```');
|
|
1231
1614
|
}
|
|
1232
1615
|
return lines.join('\n');
|