@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.
Files changed (88) hide show
  1. package/README.md +49 -49
  2. package/dist/bin/codegraph.js +47 -20
  3. package/dist/bin/codegraph.js.map +1 -1
  4. package/dist/bin/node-version-check.d.ts +3 -0
  5. package/dist/bin/node-version-check.d.ts.map +1 -1
  6. package/dist/bin/node-version-check.js +5 -2
  7. package/dist/bin/node-version-check.js.map +1 -1
  8. package/dist/context/index.d.ts.map +1 -1
  9. package/dist/context/index.js +4 -2
  10. package/dist/context/index.js.map +1 -1
  11. package/dist/db/queries.d.ts.map +1 -1
  12. package/dist/db/queries.js +7 -1
  13. package/dist/db/queries.js.map +1 -1
  14. package/dist/extraction/index.d.ts.map +1 -1
  15. package/dist/extraction/index.js +63 -37
  16. package/dist/extraction/index.js.map +1 -1
  17. package/dist/installer/config-writer.d.ts.map +1 -1
  18. package/dist/installer/config-writer.js +3 -1
  19. package/dist/installer/config-writer.js.map +1 -1
  20. package/dist/installer/index.d.ts +12 -0
  21. package/dist/installer/index.d.ts.map +1 -1
  22. package/dist/installer/index.js +74 -5
  23. package/dist/installer/index.js.map +1 -1
  24. package/dist/installer/instructions-template.d.ts +2 -2
  25. package/dist/installer/instructions-template.d.ts.map +1 -1
  26. package/dist/installer/instructions-template.js +3 -2
  27. package/dist/installer/instructions-template.js.map +1 -1
  28. package/dist/installer/targets/claude.d.ts +10 -6
  29. package/dist/installer/targets/claude.d.ts.map +1 -1
  30. package/dist/installer/targets/claude.js +72 -10
  31. package/dist/installer/targets/claude.js.map +1 -1
  32. package/dist/mcp/index.d.ts +12 -0
  33. package/dist/mcp/index.d.ts.map +1 -1
  34. package/dist/mcp/index.js +143 -20
  35. package/dist/mcp/index.js.map +1 -1
  36. package/dist/mcp/server-instructions.d.ts +1 -1
  37. package/dist/mcp/server-instructions.d.ts.map +1 -1
  38. package/dist/mcp/server-instructions.js +14 -2
  39. package/dist/mcp/server-instructions.js.map +1 -1
  40. package/dist/mcp/tools.d.ts +75 -5
  41. package/dist/mcp/tools.d.ts.map +1 -1
  42. package/dist/mcp/tools.js +470 -87
  43. package/dist/mcp/tools.js.map +1 -1
  44. package/dist/mcp/transport.d.ts +17 -0
  45. package/dist/mcp/transport.d.ts.map +1 -1
  46. package/dist/mcp/transport.js +63 -0
  47. package/dist/mcp/transport.js.map +1 -1
  48. package/dist/resolution/frameworks/index.d.ts +1 -0
  49. package/dist/resolution/frameworks/index.d.ts.map +1 -1
  50. package/dist/resolution/frameworks/index.js +5 -1
  51. package/dist/resolution/frameworks/index.js.map +1 -1
  52. package/dist/resolution/frameworks/nestjs.d.ts +26 -0
  53. package/dist/resolution/frameworks/nestjs.d.ts.map +1 -0
  54. package/dist/resolution/frameworks/nestjs.js +374 -0
  55. package/dist/resolution/frameworks/nestjs.js.map +1 -0
  56. package/dist/search/query-utils.d.ts.map +1 -1
  57. package/dist/search/query-utils.js +29 -26
  58. package/dist/search/query-utils.js.map +1 -1
  59. package/dist/sync/git-hooks.d.ts +45 -0
  60. package/dist/sync/git-hooks.d.ts.map +1 -0
  61. package/dist/sync/git-hooks.js +223 -0
  62. package/dist/sync/git-hooks.js.map +1 -0
  63. package/dist/sync/index.d.ts +4 -0
  64. package/dist/sync/index.d.ts.map +1 -1
  65. package/dist/sync/index.js +12 -1
  66. package/dist/sync/index.js.map +1 -1
  67. package/dist/sync/watch-policy.d.ts +48 -0
  68. package/dist/sync/watch-policy.d.ts.map +1 -0
  69. package/dist/sync/watch-policy.js +124 -0
  70. package/dist/sync/watch-policy.js.map +1 -0
  71. package/dist/sync/watcher.d.ts.map +1 -1
  72. package/dist/sync/watcher.js +10 -0
  73. package/dist/sync/watcher.js.map +1 -1
  74. package/dist/ui/glyphs.d.ts +42 -0
  75. package/dist/ui/glyphs.d.ts.map +1 -0
  76. package/dist/ui/glyphs.js +78 -0
  77. package/dist/ui/glyphs.js.map +1 -0
  78. package/dist/ui/shimmer-worker.js +17 -11
  79. package/dist/ui/shimmer-worker.js.map +1 -1
  80. package/package.json +3 -3
  81. package/scripts/agent-eval/audit.sh +68 -0
  82. package/scripts/agent-eval/itrun.sh +107 -0
  83. package/scripts/agent-eval/parse-run.mjs +45 -0
  84. package/scripts/agent-eval/parse-session.mjs +93 -0
  85. package/scripts/agent-eval/run-agent.sh +34 -0
  86. package/scripts/agent-eval/run-all.sh +67 -0
  87. package/scripts/extract-release-notes.mjs +130 -0
  88. 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: Build comprehensive context for a task. Returns entry points, related symbols, and key code - often enough to understand the codebase without additional tool calls. NOTE: This provides CODE context, not product requirements. For new features, still clarify UX/behavior questions with the user before implementing.',
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 information about a specific code symbol. Use includeCode=true only when you need the full source code - otherwise just get location and signature to minimize context usage.',
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: 'Deep exploration tool returns comprehensive context for a topic in a SINGLE call. Groups all relevant source code by file (contiguous sections, not snippets), includes a relationship map, and uses deeper graph traversal. Designed to replace multiple codegraph_node + file Read calls. Use this instead of codegraph_context when you need thorough understanding. IMPORTANT: Use specific symbol names, file names, or short code terms in your query — NOT natural language sentences. Before calling this, use codegraph_search to discover relevant symbol names, then include those names in your query. Bad: "how are agent prompts loaded and passed to the CLI". Good: "readAgentsFromDirectory createClaudeSession chat-manager agents.ts".',
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
- throw new Error('CodeGraph not initialized for this project. Run \'codegraph init\' first.');
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
- // Show up to 15 relationships per kind
724
- const shown = edges.slice(0, 15);
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 > 15) {
730
- lines.push(`- ... and ${edges.length - 15} more`);
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 > ToolHandler.EXPLORE_MAX_OUTPUT * 0.9)
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 15 lines).
759
- // Include both node ranges AND edge source locations so template sections
760
- // with component usages/calls are covered (not just script block symbols).
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
- // Skip file/component nodes that span the entire file — they'd create one giant cluster
764
- .filter(n => !(n.kind === 'component' && n.startLine === 1 && n.endLine >= fileLines.length - 1))
765
- .map(n => ({ start: n.startLine, end: n.endLine, name: n.name, kind: n.kind }));
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 GAP_THRESHOLD = 15; // merge sections within 15 lines of each other
951
+ const gapThreshold = budget.gapThreshold;
790
952
  const clusters = [];
791
- let current = { start: ranges[0].start, end: ranges[0].end, symbols: [`${ranges[0].name}(${ranges[0].kind})`] };
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 + GAP_THRESHOLD) {
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 = { start: r.start, end: r.end, symbols: [`${r.name}(${r.kind})`] };
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
- for (const cluster of clusters) {
809
- const startIdx = Math.max(0, cluster.start - 1 - contextPadding);
810
- const endIdx = Math.min(fileLines.length, cluster.end + contextPadding);
811
- const section = fileLines.slice(startIdx, endIdx).join('\n');
812
- if (fileSection.length > 0) {
813
- fileSection += '\n\n// ... (gap) ...\n\n';
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
- // Skip if this section would blow the output limit
819
- if (totalChars + fileSection.length + 200 > ToolHandler.EXPLORE_MAX_OUTPUT) {
820
- const budget = ToolHandler.EXPLORE_MAX_OUTPUT - totalChars - 200;
821
- if (budget < 500)
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, budget) + '\n// ... trimmed ...';
824
- lines.push(`#### ${filePath} — ${allSymbols.join(', ')}`);
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(`#### ${filePath} — ${allSymbols.join(', ')}`);
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
- const remainingRelevant = sortedFiles.slice(filesIncluded);
845
- const peripheralFiles = [...fileGroups.entries()]
846
- .filter(([, group]) => group.score < 3)
847
- .sort((a, b) => b[1].score - a[1].score);
848
- const remainingFiles = [...remainingRelevant, ...peripheralFiles];
849
- if (remainingFiles.length > 0) {
850
- lines.push('### Additional relevant files (not shown)');
851
- lines.push('');
852
- for (const [filePath, group] of remainingFiles.slice(0, 10)) {
853
- const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
854
- lines.push(`- ${filePath}: ${symbols}`);
855
- }
856
- if (remainingFiles.length > 10) {
857
- lines.push(`- ... and ${remainingFiles.length - 10} more files`);
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
- lines.push('');
862
- lines.push('---');
863
- 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.`);
864
- // Add explore budget note based on project size
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(`> **Explore budget: ${budget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${budget} calls — do NOT make additional explore calls beyond this budget.`);
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
- catch {
872
- // Stats unavailable — skip budget note
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
- return this.textResult(lines.join('\n'));
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
- code = await cg.getCode(match.node.id);
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, supporting both simple names and
1096
- * qualified "Parent.child" notation (e.g., "Session.request" matches a method
1097
- * named "request" inside a class named "Session").
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 name match: "Parent.child" look for "::Parent::child" in qualified_name
1107
- if (symbol.includes('.')) {
1108
- const parts = symbol.split('.');
1109
- const qualifiedSuffix = parts.join('::');
1110
- if (node.qualifiedName.includes(qualifiedSuffix))
1111
- return true;
1112
- }
1113
- return false;
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") since the
1117
- // target may rank lower in FTS when there are many partial matches
1118
- const limit = symbol.includes('.') ? 50 : 10;
1119
- const results = cg.searchNodes(symbol, { limit });
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, use best fuzzy 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
- const results = cg.searchNodes(symbol, { limit: 50 });
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
- formatNodeDetails(node, code) {
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 (code) {
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');