@colbymchenry/codegraph-darwin-x64 0.9.8 → 0.9.9

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 (38) hide show
  1. package/lib/dist/bin/codegraph.js +1 -36
  2. package/lib/dist/bin/codegraph.js.map +1 -1
  3. package/lib/dist/context/index.d.ts +9 -0
  4. package/lib/dist/context/index.d.ts.map +1 -1
  5. package/lib/dist/context/index.js +95 -6
  6. package/lib/dist/context/index.js.map +1 -1
  7. package/lib/dist/context/markers.d.ts +19 -0
  8. package/lib/dist/context/markers.d.ts.map +1 -0
  9. package/lib/dist/context/markers.js +22 -0
  10. package/lib/dist/context/markers.js.map +1 -0
  11. package/lib/dist/extraction/tree-sitter.d.ts +26 -0
  12. package/lib/dist/extraction/tree-sitter.d.ts.map +1 -1
  13. package/lib/dist/extraction/tree-sitter.js +139 -19
  14. package/lib/dist/extraction/tree-sitter.js.map +1 -1
  15. package/lib/dist/index.d.ts +7 -1
  16. package/lib/dist/index.d.ts.map +1 -1
  17. package/lib/dist/index.js +9 -1
  18. package/lib/dist/index.js.map +1 -1
  19. package/lib/dist/installer/targets/shared.d.ts.map +1 -1
  20. package/lib/dist/installer/targets/shared.js +3 -2
  21. package/lib/dist/installer/targets/shared.js.map +1 -1
  22. package/lib/dist/mcp/server-instructions.d.ts +1 -1
  23. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  24. package/lib/dist/mcp/server-instructions.js +18 -19
  25. package/lib/dist/mcp/server-instructions.js.map +1 -1
  26. package/lib/dist/mcp/tools.d.ts +41 -51
  27. package/lib/dist/mcp/tools.d.ts.map +1 -1
  28. package/lib/dist/mcp/tools.js +534 -902
  29. package/lib/dist/mcp/tools.js.map +1 -1
  30. package/lib/dist/search/query-utils.d.ts +18 -0
  31. package/lib/dist/search/query-utils.d.ts.map +1 -1
  32. package/lib/dist/search/query-utils.js +30 -0
  33. package/lib/dist/search/query-utils.js.map +1 -1
  34. package/lib/dist/types.d.ts +8 -0
  35. package/lib/dist/types.d.ts.map +1 -1
  36. package/lib/node_modules/.package-lock.json +1 -1
  37. package/lib/package.json +1 -1
  38. package/package.json +1 -1
@@ -4,39 +4,6 @@
4
4
  *
5
5
  * Defines the tools exposed by the CodeGraph MCP server.
6
6
  */
7
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
- if (k2 === undefined) k2 = k;
9
- var desc = Object.getOwnPropertyDescriptor(m, k);
10
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
- desc = { enumerable: true, get: function() { return m[k]; } };
12
- }
13
- Object.defineProperty(o, k2, desc);
14
- }) : (function(o, m, k, k2) {
15
- if (k2 === undefined) k2 = k;
16
- o[k2] = m[k];
17
- }));
18
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
- Object.defineProperty(o, "default", { enumerable: true, value: v });
20
- }) : function(o, v) {
21
- o["default"] = v;
22
- });
23
- var __importStar = (this && this.__importStar) || (function () {
24
- var ownKeys = function(o) {
25
- ownKeys = Object.getOwnPropertyNames || function (o) {
26
- var ar = [];
27
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
- return ar;
29
- };
30
- return ownKeys(o);
31
- };
32
- return function (mod) {
33
- if (mod && mod.__esModule) return mod;
34
- var result = {};
35
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
- __setModuleDefault(result, mod);
37
- return result;
38
- };
39
- })();
40
7
  Object.defineProperty(exports, "__esModule", { value: true });
41
8
  exports.ToolHandler = exports.tools = void 0;
42
9
  exports.getExploreBudget = getExploreBudget;
@@ -52,12 +19,10 @@ const directory_1 = require("../directory");
52
19
  // sync + cached (CommonJS build).
53
20
  const loadCodeGraph = () => require('../index').default;
54
21
  const worktree_1 = require("../sync/worktree");
55
- const crypto_1 = require("crypto");
22
+ const query_utils_1 = require("../search/query-utils");
56
23
  const fs_1 = require("fs");
57
24
  const utils_1 = require("../utils");
58
25
  const generated_detection_1 = require("../extraction/generated-detection");
59
- const os_1 = require("os");
60
- const pathModule = __importStar(require("path"));
61
26
  const path_1 = require("path");
62
27
  /** Maximum output length to prevent context bloat (characters) */
63
28
  const MAX_OUTPUT_LENGTH = 15000;
@@ -114,13 +79,24 @@ function getExploreBudget(fileCount) {
114
79
  return 5;
115
80
  }
116
81
  function getExploreOutputBudget(fileCount) {
82
+ // Tiered budget, scaled to project size. The budget is a CEILING (relevance
83
+ // still gates WHAT is included), and it MUST stay under the agent's INLINE
84
+ // tool-result cap (~25K chars). Above that, the host externalizes the result
85
+ // to a file the agent then Reads back — re-introducing a read AND the
86
+ // cache-write cost — which is exactly what a 35K vscode explore did in the
87
+ // n=4 README A/B. So even large repos cap at ~24K: the answer is the handful
88
+ // of ~100-line flow windows the agent would have grep-located and read (it
89
+ // natively reads ~6–9 files, median 100-line ranges), NOT a sprawl of 12
90
+ // files. Concentration onto the flow emerges from this cap + the named-file-
91
+ // first sort dropping peripheral files. Invariant: a larger tier must never
92
+ // get a smaller `maxCharsPerFile` than a smaller tier.
117
93
  if (fileCount < 150) {
118
94
  return {
119
95
  // ITER3: revert iter2's aggressive body shrink (forced Read fallback —
120
96
  // the per-file 2.5K cap pushed the agent to Read instead of node).
121
97
  // Back to the iter1 shape (13K/4/3.8K) but keep the test-file
122
- // hard-exclude. The cost lever for this tier lives in handleContext
123
- // (steering the agent to stop after 1-2 calls), not in this budget.
98
+ // hard-exclude. The cost lever for this tier lives in steering the
99
+ // agent to stop after 1-2 calls, not in this budget.
124
100
  maxOutputChars: 13000,
125
101
  defaultMaxFiles: 4,
126
102
  maxCharsPerFile: 3800,
@@ -152,13 +128,11 @@ function getExploreOutputBudget(fileCount) {
152
128
  }
153
129
  if (fileCount < 5000) {
154
130
  return {
155
- // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
156
- // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
157
- // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
158
- // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
159
- // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
160
- maxOutputChars: 28000,
161
- defaultMaxFiles: 10,
131
+ // ~150-line per-file window (the native read unit) × ~6 files, capped at
132
+ // the ~24K inline ceiling so the response is never externalized. Per-file
133
+ // stays the <500 tier (3800) monotonic.
134
+ maxOutputChars: 24000,
135
+ defaultMaxFiles: 8,
162
136
  maxCharsPerFile: 6500,
163
137
  gapThreshold: 12,
164
138
  maxSymbolsInFileHeader: 10,
@@ -170,10 +144,14 @@ function getExploreOutputBudget(fileCount) {
170
144
  excludeLowValueFiles: false,
171
145
  };
172
146
  }
147
+ // Large + very-large repos: SAME ~24K inline ceiling (a bigger response just
148
+ // externalizes — see vscode). More files indexed → more CALLS via
149
+ // getExploreBudget, not a bigger single response. Per-file 7000 (≥ smaller
150
+ // tiers) gives the central file a ~180-line orientation window.
173
151
  if (fileCount < 15000) {
174
152
  return {
175
- maxOutputChars: 35000,
176
- defaultMaxFiles: 12,
153
+ maxOutputChars: 24000,
154
+ defaultMaxFiles: 8,
177
155
  maxCharsPerFile: 7000,
178
156
  gapThreshold: 15,
179
157
  maxSymbolsInFileHeader: 15,
@@ -186,8 +164,8 @@ function getExploreOutputBudget(fileCount) {
186
164
  };
187
165
  }
188
166
  return {
189
- maxOutputChars: 38000,
190
- defaultMaxFiles: 14,
167
+ maxOutputChars: 24000,
168
+ defaultMaxFiles: 8,
191
169
  maxCharsPerFile: 7000,
192
170
  gapThreshold: 15,
193
171
  maxSymbolsInFileHeader: 15,
@@ -244,55 +222,6 @@ function numberSourceLines(slice, firstLineNumber) {
244
222
  }
245
223
  return out.join('\n');
246
224
  }
247
- /**
248
- * Mark a Claude session as having consulted MCP tools.
249
- * This enables Grep/Glob/Bash commands that would otherwise be blocked.
250
- *
251
- * Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
252
- * tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
253
- * machine any other local user can pre-create `codegraph-consulted-<hash>` as
254
- * a symlink pointing at a file the victim owns. The old `writeFileSync` would
255
- * happily follow that link and overwrite the target's contents with the ISO
256
- * timestamp string (CWE-59). The session-id hash provides the predictability
257
- * gate, but it's defense-in-depth: if a session id ever surfaces in logs,
258
- * argv, or telemetry the attack becomes trivial, and the right fix is to not
259
- * follow links from /tmp paths in the first place.
260
- */
261
- function markSessionConsulted(sessionId) {
262
- try {
263
- const hash = (0, crypto_1.createHash)('md5').update(sessionId).digest('hex').slice(0, 16);
264
- const markerPath = (0, path_1.join)((0, os_1.tmpdir)(), `codegraph-consulted-${hash}`);
265
- // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
266
- // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
267
- // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
268
- // drops it and openSync would follow the link. This lstat check closes that
269
- // gap cross-platform; ENOENT (path is free) falls through to create it.
270
- try {
271
- if ((0, fs_1.lstatSync)(markerPath).isSymbolicLink())
272
- return;
273
- }
274
- catch {
275
- // No existing entry (or stat failed) — nothing to refuse; proceed.
276
- }
277
- // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
278
- // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
279
- // mode 0o600 prevents readback by other local users (the marker payload is
280
- // benign, but narrowing the exposure costs nothing).
281
- const flags = fs_1.constants.O_WRONLY | fs_1.constants.O_CREAT | fs_1.constants.O_TRUNC | fs_1.constants.O_NOFOLLOW;
282
- const fd = (0, fs_1.openSync)(markerPath, flags, 0o600);
283
- try {
284
- (0, fs_1.writeSync)(fd, new Date().toISOString());
285
- }
286
- finally {
287
- (0, fs_1.closeSync)(fd);
288
- }
289
- }
290
- catch {
291
- // Silently fail - don't break MCP on marker write failure. ELOOP from a
292
- // planted symlink lands here too, which is the intended behavior: refuse
293
- // to write rather than overwrite an attacker-chosen target.
294
- }
295
- }
296
225
  /**
297
226
  * Per-file staleness banner emitted at the top of a tool response when the
298
227
  * file watcher has pending events for files referenced by the response.
@@ -339,15 +268,16 @@ const projectPathProperty = {
339
268
  /**
340
269
  * All CodeGraph MCP tools
341
270
  *
342
- * Designed for minimal context usage - use codegraph_context as the primary tool,
343
- * and only use other tools for targeted follow-up queries.
271
+ * Designed for minimal context usage - use codegraph_explore as the primary tool
272
+ * (one call usually answers the whole question), and only use other tools for
273
+ * targeted follow-up queries.
344
274
  *
345
275
  * All tools support cross-project queries via the optional `projectPath` parameter.
346
276
  */
347
277
  exports.tools = [
348
278
  {
349
279
  name: 'codegraph_search',
350
- description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
280
+ description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_explore instead to get the actual source / understand an area in one call.',
351
281
  inputSchema: {
352
282
  type: 'object',
353
283
  properties: {
@@ -370,34 +300,9 @@ exports.tools = [
370
300
  required: ['query'],
371
301
  },
372
302
  },
373
- {
374
- name: 'codegraph_context',
375
- description: 'PRIMARY TOOL — call FIRST for any "how does X work"/architecture/bug question. Returns entry points + related symbols + key code in one call; usually answers without further search/Read/Grep. Provides CODE context, not product requirements.',
376
- inputSchema: {
377
- type: 'object',
378
- properties: {
379
- task: {
380
- type: 'string',
381
- description: 'Description of the task, bug, or feature to build context for',
382
- },
383
- maxNodes: {
384
- type: 'number',
385
- description: 'Maximum symbols to include (default: 20)',
386
- default: 20,
387
- },
388
- includeCode: {
389
- type: 'boolean',
390
- description: 'Include code snippets for key symbols (default: true)',
391
- default: true,
392
- },
393
- projectPath: projectPathProperty,
394
- },
395
- required: ['task'],
396
- },
397
- },
398
303
  {
399
304
  name: 'codegraph_callers',
400
- description: 'List functions that call <symbol>. For deep flow use codegraph_trace.',
305
+ description: 'List functions that call <symbol>. For the full flow, use codegraph_explore.',
401
306
  inputSchema: {
402
307
  type: 'object',
403
308
  properties: {
@@ -417,7 +322,7 @@ exports.tools = [
417
322
  },
418
323
  {
419
324
  name: 'codegraph_callees',
420
- description: 'List functions that <symbol> calls. For deep flow use codegraph_trace.',
325
+ description: 'List functions that <symbol> calls. For the full flow, use codegraph_explore.',
421
326
  inputSchema: {
422
327
  type: 'object',
423
328
  properties: {
@@ -457,7 +362,7 @@ exports.tools = [
457
362
  },
458
363
  {
459
364
  name: 'codegraph_node',
460
- description: 'One symbol\'s location, signature, callers/callees trail. includeCode=true returns the verbatim body. Use codegraph_trace for full paths instead of chaining nodes.',
365
+ description: 'SECONDARY (after codegraph_explore): get ONE symbol in full — its location, signature, callers/callees trail, and verbatim body (includeCode=true). When the name is AMBIGUOUS (an overloaded method, or the same method name on different types), it returns EVERY matching definition\'s full body in a single call — so you never need to Read a file to find the specific overload you want. For a heavily-overloaded name, pass `file` (and/or `line`) to pin the exact definition — e.g. the `file:line` a trail or another tool already showed you. Reach for this when explore trimmed a body you need. Use codegraph_explore for several related symbols or the full flow.',
461
366
  inputSchema: {
462
367
  type: 'object',
463
368
  properties: {
@@ -470,6 +375,14 @@ exports.tools = [
470
375
  description: 'Include full source code (default: false to minimize context)',
471
376
  default: false,
472
377
  },
378
+ file: {
379
+ type: 'string',
380
+ description: 'Optional: disambiguate an overloaded name to the definition in this file (path or basename, e.g. "harness.rs").',
381
+ },
382
+ line: {
383
+ type: 'number',
384
+ description: 'Optional: disambiguate to the definition at/around this line (use with the file:line a trail showed you).',
385
+ },
473
386
  projectPath: projectPathProperty,
474
387
  },
475
388
  required: ['symbol'],
@@ -477,7 +390,7 @@ exports.tools = [
477
390
  },
478
391
  {
479
392
  name: 'codegraph_explore',
480
- description: 'Source of SEVERAL related symbols grouped by file, in one capped call. Query is a bag of symbol/file names (not a question). Returned source is verbatim Read-equivalent — do not re-open shown files. Prefer over chained codegraph_node.',
393
+ description: 'PRIMARY TOOL call FIRST for almost any question: how does X work, architecture, a bug, where/what is X, or surveying an area. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — do NOT re-open shown files). Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — answers without further search/node/Read/Grep.',
481
394
  inputSchema: {
482
395
  type: 'object',
483
396
  properties: {
@@ -538,25 +451,6 @@ exports.tools = [
538
451
  },
539
452
  },
540
453
  },
541
- {
542
- name: 'codegraph_trace',
543
- description: 'Call path between two symbols — "how does <from> reach <to>?" Returns the chain with each hop\'s body inlined plus the destination\'s callees, in ONE call. Ideal for flow questions (update→render, request→handler, QuerySet→SQL). If no static path exists the chain broke at dynamic dispatch — the failure response inlines both endpoints + their TO-file siblings.',
544
- inputSchema: {
545
- type: 'object',
546
- properties: {
547
- from: {
548
- type: 'string',
549
- description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
550
- },
551
- to: {
552
- type: 'string',
553
- description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
554
- },
555
- projectPath: projectPathProperty,
556
- },
557
- required: ['from', 'to'],
558
- },
559
- },
560
454
  ];
561
455
  /**
562
456
  * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
@@ -636,7 +530,7 @@ class ToolHandler {
636
530
  * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
637
531
  * trim the tool surface without rebuilding the client config; the ablated
638
532
  * tool is then truly absent from ListTools rather than merely denied on call.
639
- * Matching is on the short form, so "trace" and "codegraph_trace" both work.
533
+ * Matching is on the short form, so "node" and "codegraph_node" both work.
640
534
  */
641
535
  toolAllowlist() {
642
536
  const raw = process.env.CODEGRAPH_MCP_TOOLS;
@@ -691,11 +585,9 @@ class ToolHandler {
691
585
  // so it deserves the same gating.
692
586
  const TINY_REPO_FILE_THRESHOLD = 500;
693
587
  const TINY_REPO_CORE_TOOLS = new Set([
588
+ 'codegraph_explore',
694
589
  'codegraph_search',
695
- 'codegraph_context',
696
590
  'codegraph_node',
697
- 'codegraph_explore',
698
- 'codegraph_trace',
699
591
  ]);
700
592
  if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
701
593
  visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
@@ -1004,9 +896,6 @@ class ToolHandler {
1004
896
  case 'codegraph_search':
1005
897
  result = await this.handleSearch(args);
1006
898
  break;
1007
- case 'codegraph_context':
1008
- result = await this.handleContext(args);
1009
- break;
1010
899
  case 'codegraph_callers':
1011
900
  result = await this.handleCallers(args);
1012
901
  break;
@@ -1030,9 +919,6 @@ class ToolHandler {
1030
919
  case 'codegraph_files':
1031
920
  result = await this.handleFiles(args);
1032
921
  break;
1033
- case 'codegraph_trace':
1034
- result = await this.handleTrace(args);
1035
- break;
1036
922
  default:
1037
923
  return this.errorResult(`Unknown tool: ${toolName}`);
1038
924
  }
@@ -1072,261 +958,6 @@ class ToolHandler {
1072
958
  const formatted = this.formatSearchResults(ranked);
1073
959
  return this.textResult(this.truncateOutput(formatted));
1074
960
  }
1075
- /**
1076
- * Handle codegraph_context
1077
- */
1078
- async handleContext(args) {
1079
- const task = this.validateString(args.task, 'task');
1080
- if (typeof task !== 'string')
1081
- return task;
1082
- // Mark session as consulted (enables Grep/Glob/Bash)
1083
- const sessionId = process.env.CLAUDE_SESSION_ID;
1084
- if (sessionId) {
1085
- markSessionConsulted(sessionId);
1086
- }
1087
- const cg = this.getCodeGraph(args.projectPath);
1088
- // On tiny repos (<150 files), trim maxNodes hard — the entire repo
1089
- // is grep-able in a turn so a 20-node context is wasted budget.
1090
- // 8 covers the typical 1-3 entry-point + their immediate neighbors
1091
- // without dragging in the rest of the small codebase.
1092
- let defaultMaxNodes = 20;
1093
- let isTinyRepo = false;
1094
- let isSmallRepo = false;
1095
- try {
1096
- const stats = cg.getStats();
1097
- if (stats.fileCount < 150) {
1098
- defaultMaxNodes = 8;
1099
- isTinyRepo = true;
1100
- }
1101
- else if (stats.fileCount < 500) {
1102
- isSmallRepo = true;
1103
- }
1104
- }
1105
- catch {
1106
- // stats failure — fall back to the standard default
1107
- }
1108
- const maxNodes = args.maxNodes || defaultMaxNodes;
1109
- const includeCode = args.includeCode !== false;
1110
- const context = await cg.buildContext(task, {
1111
- maxNodes,
1112
- includeCode,
1113
- format: 'markdown',
1114
- });
1115
- // Detect if this looks like a feature request (vs bug fix or exploration)
1116
- const isFeatureQuery = this.looksLikeFeatureRequest(task);
1117
- const reminder = isFeatureQuery
1118
- ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
1119
- : '';
1120
- // Auto-trace for flow queries: when the task is asking "how does X
1121
- // reach/flow/propagate from A to B", run the trace internally and
1122
- // append its body to the context response. Saves the agent the
1123
- // follow-up codegraph_trace call that was the #2 cost driver on
1124
- // multi-module flow questions (Q3 / etcd Q2 in the audit).
1125
- const flowTrace = await this.maybeInlineFlowTrace(task, cg);
1126
- // Iter3 — sufficiency steering on small repos.
1127
- //
1128
- // Measured economics on tiny (<150) and small (<500) projects: every
1129
- // additional MCP tool call costs ~$0.02-0.05 in cache-write tokens
1130
- // (5K-15K per response at $3.75/1M). The agent reflexively follows
1131
- // codegraph_context with explore/node even when the context response
1132
- // is already sufficient — that pattern drove the cost gap that
1133
- // smaller bodies (iter2) failed to close (smaller bodies just shifted
1134
- // the agent to Read instead). Direct directive on small-repo
1135
- // responses: tell the agent the context call IS the comprehensive
1136
- // pass for a project of this size and that follow-ups should be
1137
- // narrow (trace from→to, node single-symbol) — not another broad
1138
- // explore that re-bundles the same content.
1139
- // ITER4: unified strong directive for both tiny (<150) and small
1140
- // (<500) tiers — measured iter3 result was that the soft <500
1141
- // wording was IGNORED on sinatra (5 tool calls, +92% loss) while
1142
- // the strong <150 wording was followed on cobra/slim (3 calls,
1143
- // -21%/-22% wins). The single-file-framework problem (sinatra)
1144
- // is structurally the same as cobra's; both deserve the same
1145
- // sufficiency steering.
1146
- let smallRepoTail = '';
1147
- let smallRepoRouteInline = '';
1148
- if (isTinyRepo || isSmallRepo) {
1149
- // Iter12: backend-computed routing manifest for routing queries.
1150
- // Builds a URL → handler map directly from the graph (each route
1151
- // node has a `references` edge to its handler), then inlines the
1152
- // top handler file's source. The agent gets the canonical
1153
- // routing answer in one MCP call — no need to parse framework
1154
- // DSL or grep for handlers.
1155
- //
1156
- // Replaces iter10's raw route-file inline. The manifest is more
1157
- // information-dense (parsed URL→handler map vs raw config DSL)
1158
- // and we still inline the top handler file's source so the agent
1159
- // has the implementation bodies inline too.
1160
- const isRouteQuery = /\b(route|routes|routing|request|handler|endpoint|api|controller|middleware|dispatch|invok)/i.test(task);
1161
- if (isRouteQuery) {
1162
- try {
1163
- const manifest = cg.getRoutingManifest(40);
1164
- if (manifest) {
1165
- // 1) Compact URL→handler list (~30-60 lines, ~1-2KB).
1166
- const lines = [
1167
- `\n\n## Routing manifest (${manifest.totalRoutes} routes, top handler file holds ${manifest.topHandlerFileCount})`,
1168
- '',
1169
- '| URL | Handler | Location |',
1170
- '|---|---|---|',
1171
- ];
1172
- for (const e of manifest.entries) {
1173
- lines.push(`| \`${e.url}\` | \`${e.handler}\` | ${e.handlerFile}:${e.handlerLine} |`);
1174
- }
1175
- // 2) Inline the top handler file's source.
1176
- if (manifest.topHandlerFile && manifest.topHandlerFileCount >= 2) {
1177
- try {
1178
- const fullPath = pathModule.join(cg.getProjectRoot(), manifest.topHandlerFile);
1179
- const stat = (0, fs_1.statSync)(fullPath);
1180
- if (stat.size > 0 && stat.size <= 16000) {
1181
- const source = (0, fs_1.readFileSync)(fullPath, 'utf-8');
1182
- const capped = source.length > 7000 ? source.slice(0, 7000) + '\n... (truncated)' : source;
1183
- const ext = (manifest.topHandlerFile.match(/\.([a-z]+)$/i)?.[1] || '').toLowerCase();
1184
- const lang = ext === 'rb' ? 'ruby' : ext === 'py' ? 'python' :
1185
- ext === 'go' ? 'go' : ext === 'rs' ? 'rust' :
1186
- ext === 'js' || ext === 'jsx' ? 'javascript' :
1187
- ext === 'ts' || ext === 'tsx' ? 'typescript' :
1188
- ext === 'java' ? 'java' : ext === 'kt' ? 'kotlin' :
1189
- ext === 'cs' ? 'csharp' : ext === 'php' ? 'php' :
1190
- ext === 'swift' ? 'swift' : ext === 'yml' || ext === 'yaml' ? 'yaml' : '';
1191
- lines.push('');
1192
- lines.push(`### Top handler file (\`${manifest.topHandlerFile}\` — ${manifest.topHandlerFileCount}/${manifest.totalRoutes} routes, full source inlined — do NOT Read)`);
1193
- lines.push('');
1194
- lines.push('```' + lang);
1195
- lines.push(capped);
1196
- lines.push('```');
1197
- }
1198
- }
1199
- catch { /* file read failed, skip the source inline */ }
1200
- }
1201
- smallRepoRouteInline = lines.join('\n');
1202
- }
1203
- }
1204
- catch {
1205
- // Manifest build failed — drop silently
1206
- }
1207
- }
1208
- const sizeQualifier = isTinyRepo ? 'under 150' : 'under 500';
1209
- const routingClause = smallRepoRouteInline
1210
- ? ' The URL→handler manifest and top handler file are also inlined above — answer routing questions from them.'
1211
- : '';
1212
- smallRepoTail = `\n\n---\n> **This project is small** (${sizeQualifier} indexed files). The entry points and code above cover the relevant surface — **do NOT call codegraph_explore as a follow-up; its content will largely duplicate this response**. If you need a specific flow, call \`codegraph_trace from→to\`. If you need one specific symbol's body, call \`codegraph_node <name>\`.${routingClause} Otherwise, answer from what is above.`;
1213
- }
1214
- // buildContext returns string when format is 'markdown'
1215
- if (typeof context === 'string') {
1216
- return this.textResult(this.truncateOutput(context + flowTrace + reminder + smallRepoRouteInline + smallRepoTail));
1217
- }
1218
- // If it returns TaskContext, format it
1219
- return this.textResult(this.truncateOutput(this.formatTaskContext(context) + flowTrace + reminder + smallRepoRouteInline + smallRepoTail));
1220
- }
1221
- /**
1222
- * Detect a flow-style task ("how does X reach Y", "trace the path from A to B")
1223
- * and pre-run trace between the most likely endpoints, returning the trace
1224
- * body to splice into the context response. Returns '' for non-flow queries
1225
- * or when no plausible endpoint pair can be extracted.
1226
- *
1227
- * Conservative by design: only fires when the task has both a clear flow
1228
- * keyword AND at least two distinct PascalCase / camelCase identifiers.
1229
- * False positives waste a graph query; false negatives just fall back to
1230
- * the agent calling trace itself (existing path-proximity wiring handles
1231
- * disambiguation either way).
1232
- */
1233
- async maybeInlineFlowTrace(task, cg) {
1234
- const lower = task.toLowerCase();
1235
- const FLOW_KEYWORDS = [
1236
- 'trace ',
1237
- 'from ',
1238
- 'reach ',
1239
- 'flow ',
1240
- 'propagat',
1241
- 'how does ',
1242
- 'how do ',
1243
- ];
1244
- if (!FLOW_KEYWORDS.some((k) => lower.includes(k)))
1245
- return '';
1246
- // Extract candidate symbols — PascalCase or camelCase identifiers ≥3 chars.
1247
- // Filter out common non-symbol words and the flow keywords themselves.
1248
- const STOP_WORDS = new Set([
1249
- 'how', 'does', 'the', 'and', 'from', 'through', 'reach', 'reaches',
1250
- 'flow', 'path', 'trace', 'cross', 'module', 'modules', 'where',
1251
- 'update', 'updates', 'updated', 'when', 'what', 'this', 'that',
1252
- ]);
1253
- const ids = [];
1254
- const seen = new Set();
1255
- const re = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)+|[a-z]+[A-Z][a-z]*(?:[A-Z][a-z]*)*)\b/g;
1256
- let m;
1257
- while ((m = re.exec(task)) !== null) {
1258
- const sym = m[1];
1259
- if (sym.length < 3)
1260
- continue;
1261
- const key = sym.toLowerCase();
1262
- if (STOP_WORDS.has(key) || seen.has(key))
1263
- continue;
1264
- seen.add(key);
1265
- ids.push(sym);
1266
- }
1267
- if (ids.length < 2)
1268
- return '';
1269
- // The first two distinct symbols, in order of appearance, are the most
1270
- // likely from/to endpoints — "from X ... through to Y" naturally places
1271
- // them in that order in the prose. If the trace fails to connect, it
1272
- // still returns the inlined endpoint bodies (the trace-failure rewrite).
1273
- const fromSym = ids[0];
1274
- const toSym = ids[1];
1275
- let traceResult;
1276
- try {
1277
- traceResult = await this.handleTrace({
1278
- from: fromSym,
1279
- to: toSym,
1280
- projectPath: cg.getProjectRoot(),
1281
- });
1282
- }
1283
- catch {
1284
- return '';
1285
- }
1286
- // Extract the textual body. Defensive: handleTrace's contract is the
1287
- // standard tool-result shape used elsewhere in this file.
1288
- const body = traceResult.content
1289
- ?.map((c) => (c.type === 'text' ? c.text : ''))
1290
- .filter(Boolean)
1291
- .join('\n')
1292
- .trim();
1293
- if (!body)
1294
- return '';
1295
- return [
1296
- '',
1297
- '## Inline flow trace',
1298
- '',
1299
- `Auto-traced \`${fromSym}\` → \`${toSym}\` because the query looks like a flow question. No follow-up codegraph_trace is needed for this pair.`,
1300
- '',
1301
- body,
1302
- ].join('\n');
1303
- }
1304
- /**
1305
- * Heuristic to detect if a query looks like a feature request
1306
- */
1307
- looksLikeFeatureRequest(task) {
1308
- const featureKeywords = [
1309
- 'add', 'create', 'implement', 'build', 'enable', 'allow',
1310
- 'new feature', 'support for', 'ability to', 'want to',
1311
- 'should be able', 'need to add', 'swap', 'edit', 'modify'
1312
- ];
1313
- const bugKeywords = [
1314
- 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
1315
- 'not working', 'fails', 'undefined', 'null'
1316
- ];
1317
- const explorationKeywords = [
1318
- 'how does', 'where is', 'what is', 'find', 'show me',
1319
- 'explain', 'understand', 'explore'
1320
- ];
1321
- const lowerTask = task.toLowerCase();
1322
- // If it's clearly a bug or exploration, not a feature
1323
- if (bugKeywords.some(k => lowerTask.includes(k)))
1324
- return false;
1325
- if (explorationKeywords.some(k => lowerTask.includes(k)))
1326
- return false;
1327
- // If it matches feature keywords, it's likely a feature request
1328
- return featureKeywords.some(k => lowerTask.includes(k));
1329
- }
1330
961
  /**
1331
962
  * Handle codegraph_callers
1332
963
  */
@@ -1425,295 +1056,6 @@ class ToolHandler {
1425
1056
  const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
1426
1057
  return this.textResult(this.truncateOutput(formatted));
1427
1058
  }
1428
- /**
1429
- * Handle codegraph_trace — shortest CALL PATH between two symbols.
1430
- *
1431
- * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
1432
- * each hop annotated with file:line and the call-site line. This is the
1433
- * capability grep/Read structurally cannot provide. When no static path
1434
- * exists, the chain has almost certainly broken at dynamic dispatch
1435
- * (callbacks, descriptors, metaclasses) — we say so and surface the start
1436
- * symbol's outgoing calls so the agent bridges the one missing hop with
1437
- * codegraph_node rather than blindly reading.
1438
- */
1439
- async handleTrace(args) {
1440
- const from = this.validateString(args.from, 'from');
1441
- if (typeof from !== 'string')
1442
- return from;
1443
- const to = this.validateString(args.to, 'to');
1444
- if (typeof to !== 'string')
1445
- return to;
1446
- const cg = this.getCodeGraph(args.projectPath);
1447
- const fromMatches = this.findAllSymbols(cg, from);
1448
- if (fromMatches.nodes.length === 0)
1449
- return this.textResult(`Symbol "${from}" not found in the codebase`);
1450
- const toMatches = this.findAllSymbols(cg, to);
1451
- if (toMatches.nodes.length === 0)
1452
- return this.textResult(`Symbol "${to}" not found in the codebase`);
1453
- // Trace along call edges only — a true call path. Names can map to several
1454
- // nodes, so try a few from×to candidate pairs until a usable path turns up.
1455
- //
1456
- // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
1457
- // is almost always a spurious wander through unrelated code (django's
1458
- // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
1459
- // the real execution flow — and a confident-but-wrong 15-hop trace is worse
1460
- // than none. Over-cap paths are rejected and reported as "no direct path"
1461
- // (which, on real code, means the flow breaks at dynamic dispatch).
1462
- const edgeKinds = ['calls'];
1463
- const MAX_HOPS = 7;
1464
- // Path-proximity pairing: in a multi-module repo a symbol name like
1465
- // `EndBlocker` exists in 20+ modules. FTS picks one almost arbitrarily;
1466
- // the WRONG pair (e.g. simapp's wrapper EndBlocker paired with gov's Tally)
1467
- // has no static path, falls through to the dynamic-dispatch failure branch,
1468
- // and surfaces unrelated bodies — exactly the cosmos-Q3 trace failure mode.
1469
- // Score every from×to combo by shared file-path prefix length; try the
1470
- // most-co-located pair first (e.g. `x/gov/abci.go::EndBlocker` ×
1471
- // `x/gov/keeper/tally.go::Tally` share `x/gov/`).
1472
- //
1473
- // Consider the FULL candidate set, not just the FTS top-5: the right
1474
- // EndBlocker for a gov-module flow may rank 8th in FTS but share the
1475
- // entire `x/gov/` prefix with the destination. Path-proximity supersedes
1476
- // FTS for this disambiguation. Findpath trials are still capped by
1477
- // FINDPATH_PAIR_BUDGET below to bound graph traversal cost.
1478
- const sharedDirPrefixLen = (a, b) => {
1479
- const aDir = a.replace(/[^/]+$/, '');
1480
- const bDir = b.replace(/[^/]+$/, '');
1481
- let i = 0;
1482
- while (i < aDir.length && i < bDir.length && aDir[i] === bDir[i])
1483
- i++;
1484
- return i;
1485
- };
1486
- // Cosmos-Q3 surfaced a second-order failure: `enterprise/group/x/group/`
1487
- // SHARES MORE of its path with `enterprise/group/x/group/keeper/tally.go`
1488
- // (24 chars) than `x/gov/abci.go` shares with `x/gov/keeper/tally.go`
1489
- // (6 chars), so pure shared-prefix prefers the side-experiment module
1490
- // over the canonical one — even though the user's question is clearly
1491
- // about the main gov module. Penalize candidates living under prefixes
1492
- // that conventionally hold extensions / experiments / vendored code, so
1493
- // the canonical-path pair wins even when its shared prefix is short.
1494
- const isLessCanonicalPath = (p) => /^(enterprise|contrib|examples?|sample|playground|vendor|third[_-]?party|deprecated|legacy)\//i.test(p);
1495
- const LESS_CANONICAL_PENALTY = 100; // any canonical candidate beats any less-canonical one
1496
- const scorePair = (a, b) => sharedDirPrefixLen(a, b)
1497
- - (isLessCanonicalPath(a) ? LESS_CANONICAL_PENALTY : 0)
1498
- - (isLessCanonicalPath(b) ? LESS_CANONICAL_PENALTY : 0);
1499
- const fromCands = fromMatches.nodes;
1500
- const toCands = toMatches.nodes;
1501
- // Candidate relevance: an overloaded name (Alamofire has 44 `request`s, most
1502
- // of them EMPTY EventMonitor protocol-conformance stubs `func request(…){}`)
1503
- // floods the pool with no-op decls. Shared-dir-prefix alone then MISLEADS —
1504
- // two unrelated `Source/Features/` delegate stubs outscore the real
1505
- // `Source/Core/Session.request` × `Source/Core/…task` pair the agent meant,
1506
- // so trace resolves to stubs, finds no path, and the agent reads by line.
1507
- // Penalize empty stubs and test-file symbols so a substantive entry point
1508
- // wins; among real methods this is ~flat, so path-proximity still decides
1509
- // (cosmos EndBlocker disambiguation is unaffected — none of its candidates
1510
- // are stubs/tests).
1511
- const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
1512
- const nodeRelevance = (n) => {
1513
- const bodyLines = Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
1514
- let s = Math.min(bodyLines, 20); // a substantive body is more likely the meant symbol
1515
- if (bodyLines <= 1)
1516
- s -= 40; // empty/one-line stub (protocol no-op, decl-only) — almost never the trace endpoint
1517
- if (isTestPath(n.filePath))
1518
- s -= 150; // a Source/ symbol is meant over a Tests/ same-named one
1519
- return s;
1520
- };
1521
- const pairs = [];
1522
- for (const f of fromCands) {
1523
- for (const t of toCands) {
1524
- pairs.push({ f, t, score: scorePair(f.filePath, t.filePath) + nodeRelevance(f) + nodeRelevance(t) });
1525
- }
1526
- }
1527
- // Sort by shared prefix desc, then by FTS order (already encoded in the
1528
- // pairs' insertion order — both for f and t). The tiebreaker preserves
1529
- // findAllSymbols' generated-file-last ranking.
1530
- pairs.sort((a, b) => b.score - a.score);
1531
- // Cap how many graph-path probes we attempt so a 50×50 cross-product
1532
- // doesn't blow up on a god-named symbol like `Get` (well-named flows have
1533
- // their good pair near the top of the sort anyway).
1534
- const FINDPATH_PAIR_BUDGET = 20;
1535
- const fromTry = fromCands;
1536
- const toTry = toCands;
1537
- let path = null;
1538
- let overCap = null;
1539
- let bestPair = null;
1540
- let triedPairs = 0;
1541
- for (const { f, t } of pairs) {
1542
- if (path)
1543
- break;
1544
- if (triedPairs >= FINDPATH_PAIR_BUDGET)
1545
- break;
1546
- triedPairs++;
1547
- const p = cg.findPath(f.id, t.id, edgeKinds);
1548
- if (p && p.length > 1) {
1549
- if (p.length <= MAX_HOPS) {
1550
- path = p;
1551
- bestPair = { f, t };
1552
- break;
1553
- }
1554
- if (!overCap || p.length < overCap.length) {
1555
- overCap = p;
1556
- bestPair = { f, t };
1557
- }
1558
- }
1559
- else if (!bestPair) {
1560
- // No path yet — remember the top-scored pair so the failure branch
1561
- // surfaces the most-co-located candidates' bodies, not whatever FTS
1562
- // happened to put first.
1563
- bestPair = { f, t };
1564
- }
1565
- }
1566
- if (!path) {
1567
- // No static path — almost always a dynamic-dispatch break. INSTEAD of
1568
- // telling the agent to chase the gap with codegraph_node/callers/callees
1569
- // (which fans out into 3-4 follow-up tool calls + a Read), inline the
1570
- // material those would have returned right here. Measured on cosmos-Q3:
1571
- // the failed-trace + subsequent fan-out used to cost ~2× a single
1572
- // sufficient trace call; this branch closes that gap.
1573
- // Prefer the path-proximity-best pair we identified above (e.g. gov's
1574
- // EndBlocker × gov's Tally) over the FTS top-pick (simapp's wrapper).
1575
- const start = bestPair?.f ?? fromTry[0];
1576
- const end = bestPair?.t ?? toTry[0];
1577
- const fileCache = new Map();
1578
- const lines = [
1579
- `No direct static call path from "${from}" to "${to}" — the chain almost certainly breaks at dynamic dispatch (a callback / interface dispatch / framework hook / metaclass). Both endpoint bodies + their immediate neighbors are inlined below; answer from them — a follow-up codegraph_node/callers/callees on these would just return what is already here.`,
1580
- '',
1581
- ];
1582
- if (overCap) {
1583
- lines.push(`> Indirect chain of ${overCap.length} hops exists but is over the ${MAX_HOPS}-hop cap (usually a BFS wander through unrelated code, not the real execution flow).`, '');
1584
- }
1585
- // Track which node IDs we've already inlined a body for so we don't
1586
- // double-emit when a callee of FROM is also surfaced separately.
1587
- const inlinedBodies = new Set();
1588
- const inlineBody = (n, lineCap, charCap) => {
1589
- if (inlinedBodies.has(n.id))
1590
- return false;
1591
- inlinedBodies.add(n.id);
1592
- const body = this.sourceRangeAt(cg, n.filePath, n.startLine, n.endLine, fileCache, lineCap, charCap);
1593
- if (body) {
1594
- lines.push(body);
1595
- return true;
1596
- }
1597
- return false;
1598
- };
1599
- const inlineEndpoint = (label, node) => {
1600
- lines.push(`### ${label}: \`${node.name}\` (${node.filePath}:${node.startLine}-${node.endLine})`);
1601
- inlineBody(node, 120, 3600);
1602
- const callers = cg.getCallers(node.id).slice(0, 6);
1603
- if (callers.length > 0) {
1604
- lines.push(`**Callers of \`${node.name}\`:** ` +
1605
- callers.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
1606
- }
1607
- const callees = cg.getCallees(node.id).slice(0, 8);
1608
- if (callees.length > 0) {
1609
- lines.push(`**\`${node.name}\` calls:** ` +
1610
- callees.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
1611
- }
1612
- lines.push('');
1613
- };
1614
- inlineEndpoint('FROM', start);
1615
- if (end.id !== start.id)
1616
- inlineEndpoint('TO', end);
1617
- // Inline the OTHER top-level functions/methods in TO's file — that's
1618
- // where the missing dynamic-dispatch flow usually lives. Concrete
1619
- // measurement from cosmos-Q1: `msgServer.Send` statically calls only
1620
- // utility functions (`StringToBytes`, `Wrapf`); its real next-hop
1621
- // `SendCoins` is invoked via an embedded-interface call (`k.Keeper.SendCoins`)
1622
- // that static parsing CAN'T see. The flow IS in the same file as the
1623
- // destination (`x/bank/keeper/send.go`: SendCoins → subUnlockedCoins →
1624
- // addCoins → setBalance). Pre-inlining those file-mates is what
1625
- // replaces the agent's "trace fail → search SendCoins → node SendCoins
1626
- // → trace again" fan-out.
1627
- const NEIGHBOR_LINES = 40;
1628
- const NEIGHBOR_CHARS = 1200;
1629
- const NEIGHBOR_K = 5;
1630
- const fileSiblings = (anchor) => {
1631
- // Functions and methods in the same file as the anchor, excluding
1632
- // the anchor itself and anything we've already inlined. Sort by
1633
- // distance from the anchor's startLine so the closest symbols come
1634
- // first (the flow is usually adjacent in the file).
1635
- const sameFile = cg
1636
- .getNodesByKind('function')
1637
- .filter((n) => n.filePath === anchor.filePath)
1638
- .concat(cg.getNodesByKind('method').filter((n) => n.filePath === anchor.filePath));
1639
- return sameFile
1640
- .filter((n) => n.id !== anchor.id && !inlinedBodies.has(n.id))
1641
- .sort((a, b) => Math.abs(a.startLine - anchor.startLine) - Math.abs(b.startLine - anchor.startLine))
1642
- .slice(0, NEIGHBOR_K);
1643
- };
1644
- const renderSiblings = (label, siblings) => {
1645
- if (siblings.length === 0)
1646
- return;
1647
- lines.push(`### ${label}`);
1648
- for (const sib of siblings) {
1649
- lines.push('');
1650
- lines.push(`- \`${sib.name}\` (${sib.filePath}:${sib.startLine}-${sib.endLine})`);
1651
- inlineBody(sib, NEIGHBOR_LINES, NEIGHBOR_CHARS);
1652
- }
1653
- lines.push('');
1654
- };
1655
- renderSiblings(`Other functions in \`${end.filePath}\` (the flow that the dynamic-dispatch hop reaches — bodies inlined)`, fileSiblings(end));
1656
- lines.push('> Endpoint bodies + the other functions in the destination\'s file are inlined above. Together they typically cover the missing dynamic-dispatch boundary (interface-method calls like `k.Keeper.SendCoins` that static parsing can\'t follow). **No further codegraph_node / codegraph_callers / codegraph_callees / Read / Grep is needed for any symbol already shown here** — call them again only if you need to walk DEEPER than what is inlined.');
1657
- return this.textResult(this.truncateOutput(lines.join('\n') + fromMatches.note + toMatches.note));
1658
- }
1659
- const lines = [
1660
- `## Trace: ${from} → ${to}`,
1661
- '',
1662
- `Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
1663
- '',
1664
- `${path.length} hops:`,
1665
- '',
1666
- ];
1667
- // Inline what each hop needs so the agent doesn't Read/Grep to get it: the
1668
- // call-site source line, the registration site for dynamic-dispatch hops, AND
1669
- // the hop's own body (capped per hop so the trace stays path-scoped). Earlier
1670
- // versions inlined only the call-site line, which left agents calling explore
1671
- // or Read for the bodies — the exact follow-up the ablation experiment measured.
1672
- const fileCache = new Map();
1673
- for (let i = 0; i < path.length; i++) {
1674
- const step = path[i];
1675
- if (step.edge) {
1676
- const synth = this.synthEdgeNote(step.edge);
1677
- if (synth) {
1678
- lines.push(` ↓ ${synth.label}`);
1679
- if (synth.registeredAt) {
1680
- const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
1681
- lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
1682
- }
1683
- }
1684
- else {
1685
- // The call happens in the PREVIOUS hop's file at edge.line.
1686
- const prev = path[i - 1];
1687
- const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
1688
- const callSrc = this.sourceLineAt(cg, ref, fileCache);
1689
- lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
1690
- }
1691
- }
1692
- lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
1693
- const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
1694
- if (body)
1695
- lines.push(body);
1696
- }
1697
- // The "last mile": what the destination does next. Agents otherwise explore/Read
1698
- // for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
1699
- // so inlining the destination's callees is what actually stops the investigation —
1700
- // sufficiency, not a "don't explore" instruction.
1701
- const dest = path[path.length - 1].node;
1702
- const destCallees = cg.getCallees(dest.id)
1703
- .filter(c => !path.some(p => p.node.id === c.node.id))
1704
- .slice(0, 6);
1705
- if (destCallees.length > 0) {
1706
- lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
1707
- for (const c of destCallees) {
1708
- lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
1709
- const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
1710
- if (body)
1711
- lines.push(body);
1712
- }
1713
- }
1714
- lines.push('', '> Full path + every hop body + the destination\'s calls are inlined above — the complete flow. Answer from it; a Read is only needed to chase a specific local variable\'s data-flow.');
1715
- return this.textResult(this.truncateOutput(lines.join('\n')));
1716
- }
1717
1059
  /**
1718
1060
  * Describe a synthesized (dynamic-dispatch) edge for human output: how the
1719
1061
  * callback was wired up — the bridge static parsing can't see. Returns null
@@ -1783,82 +1125,6 @@ class ToolHandler {
1783
1125
  }
1784
1126
  return null;
1785
1127
  }
1786
- /**
1787
- * Read one trimmed source line at "relpath:line" (relative to the project
1788
- * root). `cache` holds split file contents so a multi-hop trace reads each
1789
- * file at most once. Returns null if the file/line can't be resolved.
1790
- */
1791
- sourceLineAt(cg, ref, cache) {
1792
- if (!ref)
1793
- return null;
1794
- const i = ref.lastIndexOf(':');
1795
- if (i < 0)
1796
- return null;
1797
- const filePath = ref.slice(0, i);
1798
- const line = parseInt(ref.slice(i + 1), 10);
1799
- if (!Number.isFinite(line) || line < 1)
1800
- return null;
1801
- let fileLines = cache.get(filePath);
1802
- if (!fileLines) {
1803
- const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
1804
- if (!abs || !(0, fs_1.existsSync)(abs))
1805
- return null;
1806
- try {
1807
- fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
1808
- }
1809
- catch {
1810
- return null;
1811
- }
1812
- cache.set(filePath, fileLines);
1813
- }
1814
- const raw = fileLines[line - 1];
1815
- if (raw == null)
1816
- return null;
1817
- const t = raw.trim();
1818
- return t.length > 160 ? t.slice(0, 157) + '…' : t;
1819
- }
1820
- /**
1821
- * Read a hop's body — filePath lines [startLine..endLine] — for inlining into
1822
- * a trace, capped (lines + chars) so the whole path stays path-scoped even on
1823
- * a 7-hop chain. Dedents to the body's own indentation and marks truncation.
1824
- * Shares `cache` with sourceLineAt so each file is read at most once per trace.
1825
- */
1826
- sourceRangeAt(cg, filePath, startLine, endLine, cache, maxLines = 28, maxChars = 1200) {
1827
- if (!Number.isFinite(startLine) || startLine < 1)
1828
- return null;
1829
- let fileLines = cache.get(filePath);
1830
- if (!fileLines) {
1831
- const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
1832
- if (!abs || !(0, fs_1.existsSync)(abs))
1833
- return null;
1834
- try {
1835
- fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
1836
- }
1837
- catch {
1838
- return null;
1839
- }
1840
- cache.set(filePath, fileLines);
1841
- }
1842
- const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
1843
- let slice = fileLines.slice(startLine - 1, end);
1844
- if (slice.length === 0)
1845
- return null;
1846
- let omitted = 0;
1847
- if (slice.length > maxLines) {
1848
- omitted = slice.length - maxLines;
1849
- slice = slice.slice(0, maxLines);
1850
- }
1851
- const nonBlank = slice.filter(l => l.trim().length > 0);
1852
- const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
1853
- let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
1854
- if (text.length > maxChars) {
1855
- text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
1856
- omitted = Math.max(omitted, 1);
1857
- }
1858
- if (omitted > 0)
1859
- text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
1860
- return text;
1861
- }
1862
1128
  /**
1863
1129
  * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
1864
1130
  * symbol names that usually spans the flow it's investigating (e.g.
@@ -2019,7 +1285,7 @@ class ToolHandler {
2019
1285
  if (synthLines.length) {
2020
1286
  out.push('## Dynamic-dispatch links among your symbols', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
2021
1287
  }
2022
- out.push('> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
1288
+ out.push('> Full source for these symbols is below the call flow among them, followed by their bodies.', '');
2023
1289
  // namedNodeIds = every callable the agent explicitly named (a superset of
2024
1290
  // the spine). A file holding one is something the agent asked to SEE, so it
2025
1291
  // must keep full source even if it's an off-spine polymorphic sibling — the
@@ -2031,6 +1297,147 @@ class ToolHandler {
2031
1297
  return EMPTY;
2032
1298
  }
2033
1299
  }
1300
+ /**
1301
+ * Compact "blast radius" for the entry symbols of an explore result: who
1302
+ * depends on each (callers) and which test files cover it — LOCATIONS ONLY,
1303
+ * no source, so the agent knows what to update / re-verify before editing
1304
+ * without reaching for a separate impact call. Always-on, but skips symbols
1305
+ * that have no dependents (nothing to warn about), and returns '' when none
1306
+ * qualify so a leaf-only exploration stays clean.
1307
+ */
1308
+ buildBlastRadiusSection(cg, subgraph) {
1309
+ const ROOT_CAP = 5; // only the symbols the query actually targeted
1310
+ const FILE_CAP = 4; // caller files listed per symbol before "+N more"
1311
+ const MEANINGFUL = new Set([
1312
+ 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol',
1313
+ 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
1314
+ ]);
1315
+ const rel = (p) => p.replace(/\\/g, '/');
1316
+ const roots = subgraph.roots
1317
+ .map((id) => subgraph.nodes.get(id))
1318
+ .filter((n) => !!n && MEANINGFUL.has(n.kind))
1319
+ .slice(0, ROOT_CAP);
1320
+ if (roots.length === 0)
1321
+ return '';
1322
+ const entries = [];
1323
+ for (const root of roots) {
1324
+ let callers = [];
1325
+ try {
1326
+ callers = cg.getCallers(root.id);
1327
+ }
1328
+ catch { /* skip this root */ }
1329
+ const seen = new Set();
1330
+ const uniq = [];
1331
+ for (const c of callers) {
1332
+ if (c?.node && !seen.has(c.node.id)) {
1333
+ seen.add(c.node.id);
1334
+ uniq.push(c.node);
1335
+ }
1336
+ }
1337
+ if (uniq.length === 0)
1338
+ continue; // no blast radius → nothing to flag
1339
+ const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
1340
+ const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
1341
+ const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
1342
+ const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
1343
+ const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
1344
+ const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
1345
+ const tests = testFiles.length > 0
1346
+ ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
1347
+ : '; ⚠️ no covering tests found';
1348
+ entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
1349
+ }
1350
+ if (entries.length === 0)
1351
+ return '';
1352
+ return [
1353
+ '### Blast radius — what depends on these (update/verify before editing)',
1354
+ '',
1355
+ ...entries,
1356
+ '',
1357
+ ].join('\n');
1358
+ }
1359
+ /**
1360
+ * Graph-connectivity relevance via Random-Walk-with-Restart (personalized
1361
+ * PageRank) from the query's matched SEED nodes over the call/reference graph.
1362
+ *
1363
+ * This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
1364
+ * codegraph's home turf: relevance by STRUCTURE, not words. A file whose
1365
+ * symbols are call-connected to the matched cluster accrues walk mass and
1366
+ * ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
1367
+ * "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
1368
+ * — gets only its own restart probability and ranks ~0. Immune to the
1369
+ * tokenization trap that fools term matching, deterministic, no embeddings.
1370
+ *
1371
+ * Undirected adjacency (reachability both ways), restart α=0.25 to the seeds,
1372
+ * power iteration to convergence. Bounded to the already-relevant subgraph, so
1373
+ * it's a few hundred nodes × ~25 iterations — negligible cost.
1374
+ */
1375
+ computeGraphRelevance(nodeIds, edges, seedIds) {
1376
+ const out = new Map();
1377
+ const n = nodeIds.length;
1378
+ if (n === 0)
1379
+ return out;
1380
+ const idx = new Map();
1381
+ for (let i = 0; i < n; i++)
1382
+ idx.set(nodeIds[i], i);
1383
+ const RANK_EDGES = new Set([
1384
+ 'calls', 'references', 'extends', 'implements', 'overrides',
1385
+ 'instantiates', 'returns', 'type_of', 'imports',
1386
+ ]);
1387
+ const adj = Array.from({ length: n }, () => []);
1388
+ for (const e of edges) {
1389
+ if (!RANK_EDGES.has(e.kind))
1390
+ continue;
1391
+ const i = idx.get(e.source);
1392
+ const j = idx.get(e.target);
1393
+ if (i === undefined || j === undefined || i === j)
1394
+ continue;
1395
+ adj[i].push(j);
1396
+ adj[j].push(i); // undirected — reachable either direction
1397
+ }
1398
+ // Restart vector: uniform over seeds present in the candidate set. (Falls
1399
+ // back to uniform-over-all if no seed landed in the set, so we never return
1400
+ // all-zero.)
1401
+ const r = new Array(n).fill(0);
1402
+ let rsum = 0;
1403
+ for (const id of seedIds) {
1404
+ const i = idx.get(id);
1405
+ if (i !== undefined) {
1406
+ r[i] = 1;
1407
+ rsum += 1;
1408
+ }
1409
+ }
1410
+ if (rsum === 0) {
1411
+ for (let i = 0; i < n; i++)
1412
+ r[i] = 1;
1413
+ rsum = n;
1414
+ }
1415
+ for (let i = 0; i < n; i++)
1416
+ r[i] /= rsum;
1417
+ const alpha = 0.25;
1418
+ let s = r.slice();
1419
+ for (let iter = 0; iter < 25; iter++) {
1420
+ const next = new Array(n).fill(0);
1421
+ for (let i = 0; i < n; i++) {
1422
+ const si = s[i];
1423
+ if (si === 0)
1424
+ continue;
1425
+ const d = adj[i].length;
1426
+ if (d === 0) {
1427
+ next[i] += si;
1428
+ continue;
1429
+ } // dangling: keep its mass
1430
+ const share = si / d;
1431
+ for (const j of adj[i])
1432
+ next[j] += share;
1433
+ }
1434
+ for (let i = 0; i < n; i++)
1435
+ s[i] = (1 - alpha) * next[i] + alpha * r[i];
1436
+ }
1437
+ for (let i = 0; i < n; i++)
1438
+ out.set(nodeIds[i], s[i]);
1439
+ return out;
1440
+ }
2034
1441
  /**
2035
1442
  * Handle codegraph_explore — deep exploration in a single call
2036
1443
  *
@@ -2127,18 +1534,50 @@ class ToolHandler {
2127
1534
  const tokens = [...new Set(query.split(/[\s,()[\]]+/)
2128
1535
  .map((t) => t.replace(FILE_EXT, '').trim())
2129
1536
  .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
1537
+ // PascalCase tokens in the query are type/file disambiguators — when the
1538
+ // agent writes "DataRequest task validate", the `task`/`validate` it wants
1539
+ // are DataRequest's, NOT the same-named overloads in Validation.swift /
1540
+ // Concurrency.swift / the abstract base. Used below to bias overloaded
1541
+ // names toward the file/class the query also names.
1542
+ const typeTokens = tokens.filter((o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o));
1543
+ const inNamedContext = (n) => typeTokens.some((ct) => {
1544
+ const lc = ct.toLowerCase();
1545
+ return n.filePath.toLowerCase().includes(lc) || n.qualifiedName.toLowerCase().includes(lc);
1546
+ });
2130
1547
  for (const t of tokens) {
2131
- const cands = this.findAllSymbols(cg, t).nodes
1548
+ // Enumerate ALL defs of a bare token via the direct index, not FTS — a
1549
+ // 50+-overload name (tokio `poll`) ranks the wanted def (`Harness::poll`)
1550
+ // below the FTS cut, so findAllSymbols would never see it and the
1551
+ // type-token bias below couldn't pick the harness.rs one. (Same fix as
1552
+ // codegraph_node's findSymbolMatches.) Qualified tokens keep findAllSymbols.
1553
+ const isQual = /[.\/]|::/.test(t);
1554
+ const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
1555
+ const cands = raw
2132
1556
  .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
2133
1557
  .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
2134
- // A specific name (<=3 defs) injects all its defs; an overloaded name
2135
- // (`request` = 44, mostly stubs) injects only the single most substantive
2136
- // one, so the build-overload flood doesn't crowd the subgraph.
2137
- for (const n of cands.slice(0, cands.length <= 3 ? cands.length : 1)) {
2138
- if (!subgraph.nodes.has(n.id)) {
1558
+ // A specific name (<=3 defs) injects all its defs. An overloaded name
1559
+ // (`validate` = 10, `request` = 44) would flood the subgraph, so inject
1560
+ // only: the overloads whose file/class the query ALSO names (the agent
1561
+ // told us which one it wants DataRequest's, not Validation.swift's),
1562
+ // capped; else fall back to the single most-substantive def. This is the
1563
+ // explore-side mirror of codegraph_node's overload disambiguation.
1564
+ let picks;
1565
+ if (cands.length <= 3) {
1566
+ picks = cands;
1567
+ }
1568
+ else {
1569
+ const ctx = cands.filter(inNamedContext);
1570
+ picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
1571
+ }
1572
+ for (const n of picks) {
1573
+ if (!subgraph.nodes.has(n.id))
2139
1574
  subgraph.nodes.set(n.id, n);
2140
- namedSeedIds.add(n.id);
2141
- }
1575
+ // Mark as a named seed EVEN IF the FTS gather already had it — being
1576
+ // "named by the agent" is independent of whether search happened to
1577
+ // surface it, and it drives the +50 score, the gate, and the
1578
+ // named-file sort below. (Previously only NEW injections were marked,
1579
+ // so a named symbol FTS already gathered never sorted to the top.)
1580
+ namedSeedIds.add(n.id);
2142
1581
  }
2143
1582
  }
2144
1583
  }
@@ -2218,21 +1657,107 @@ class ToolHandler {
2218
1657
  }
2219
1658
  }
2220
1659
  }
2221
- // Sort files: highest relevance first, deprioritize low-value files
1660
+ // Secondary signal: how many DISTINCT query terms each file matches (path +
1661
+ // symbol names). Kept only as a tiebreak — the PRIMARY relevance is graph
1662
+ // connectivity below. (Term counting alone tied the real central file with
1663
+ // incidental same-word matches; it's a weak text signal, not the ranker.)
1664
+ const uniqueQueryTerms = [...new Set(queryTerms)].filter(t => t.length >= 3);
1665
+ const fileTermHits = new Map();
1666
+ for (const [fp, group] of relevantFiles) {
1667
+ const hay = fp.toLowerCase() + ' ' + group.nodes.map(n => n.name.toLowerCase()).join(' ');
1668
+ let hits = 0;
1669
+ for (const t of uniqueQueryTerms)
1670
+ if (hay.includes(t))
1671
+ hits++;
1672
+ fileTermHits.set(fp, hits);
1673
+ }
1674
+ // PRIMARY relevance: graph connectivity (Random-Walk-with-Restart from the
1675
+ // matched seeds — see computeGraphRelevance). Aggregate each file's nodes'
1676
+ // walk mass. This is the signal text search lacks: the real cluster
1677
+ // (org-user.storage.ts, call-connected to the matches) accrues mass; a lone
1678
+ // text match (LensSwitcher.swift, matched "switch" but calls nothing in the
1679
+ // flow) gets only its restart probability → ~0, and is dropped by the gate.
1680
+ const nodeRwr = this.computeGraphRelevance([...subgraph.nodes.keys()], subgraph.edges, entryNodeIds);
1681
+ const fileGraphScore = new Map();
1682
+ for (const node of subgraph.nodes.values()) {
1683
+ fileGraphScore.set(node.filePath, (fileGraphScore.get(node.filePath) ?? 0) + (nodeRwr.get(node.id) ?? 0));
1684
+ }
1685
+ const maxGraph = Math.max(0, ...fileGraphScore.values());
1686
+ // Central file(s): the 1-2 most graph-central files that also match the
1687
+ // query textually (so a connected hub-utility with no term match isn't
1688
+ // mistaken for the subject). The heart of the answer — they earn the larger
1689
+ // WHOLE-FILE ceiling below (a god-file central file still exceeds it and
1690
+ // falls to generous full-method sectioning — never a whole dump).
1691
+ const centralFiles = new Set([...fileGraphScore.entries()]
1692
+ .filter(([fp, g]) => g > 0 && (fileTermHits.get(fp) ?? 0) >= 1)
1693
+ .sort((a, b) => b[1] - a[1] || (fileTermHits.get(b[0]) ?? 0) - (fileTermHits.get(a[0]) ?? 0))
1694
+ .slice(0, 2)
1695
+ .map(([f]) => f));
1696
+ // Files that DEFINE a symbol the agent named (or a subgraph root). These are
1697
+ // the highest-relevance files there are — the agent asked for them by name —
1698
+ // so the connectivity gate below must never drop them, even when their RWR
1699
+ // mass is low (a leaf family file like codec.ts is call-connected to little
1700
+ // but is exactly what the agent queried). Without this protection the gate
1701
+ // prunes a named file and the agent Reads it back.
1702
+ const entryFiles = new Set();
1703
+ for (const id of entryNodeIds) {
1704
+ const n = subgraph.nodes.get(id);
1705
+ if (n)
1706
+ entryFiles.add(n.filePath);
1707
+ }
1708
+ // Relevance gate (so the generous budget is a CEILING, not a target): keep a
1709
+ // file only if it is STRUCTURALLY relevant by ANY of:
1710
+ // - graph score within a fraction of the top (it's on/near the flow), OR
1711
+ // - central (a query entry-point lives here), OR
1712
+ // - it DEFINES a symbol the agent named (entryFiles), OR
1713
+ // - it matches >= 2 DISTINCT named query terms — a strong text signal that
1714
+ // the agent is asking about this file even when nothing calls it (codec.ts:
1715
+ // the agent named `encode`/`Codec`/`JsonCodec`, all leaf classes with zero
1716
+ // RWR mass — graph alone wrongly drops it).
1717
+ // A lone text match on one shared word (LensSwitcher: term=1, g~0) is still
1718
+ // dropped, so the budget never fills with incidental files. Guarded so it
1719
+ // never prunes below 2.
1720
+ if (maxGraph > 0) {
1721
+ const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
1722
+ || centralFiles.has(fp)
1723
+ || entryFiles.has(fp)
1724
+ || (fileTermHits.get(fp) ?? 0) >= 2);
1725
+ if (gated.length >= 2)
1726
+ relevantFiles = gated;
1727
+ }
1728
+ // Sort files: graph-central first, then distinct-term match, then the
1729
+ // existing low-value/generated/score tiebreaks.
1730
+ // Files that DEFINE a symbol the agent NAMED. These sort first — ahead of
1731
+ // graph connectivity — because the agent asked for them by name. Without
1732
+ // this, a named leaf override reached only by dynamic dispatch (Alamofire's
1733
+ // `DataRequest.task`/`validate`, low RWR mass) sorts below the high-
1734
+ // connectivity abstract base (`Request.swift`) and the same-named overloads
1735
+ // in other files (`Validation.swift`), falls outside the budget, and the
1736
+ // agent Reads it. The named file is the answer — rank it at the top.
1737
+ const namedSeedFiles = new Set();
1738
+ for (const id of namedSeedIds) {
1739
+ const n = subgraph.nodes.get(id);
1740
+ if (n)
1741
+ namedSeedFiles.add(n.filePath);
1742
+ }
2222
1743
  const sortedFiles = relevantFiles.sort((a, b) => {
2223
1744
  const aPath = a[0].toLowerCase();
2224
1745
  const bPath = b[0].toLowerCase();
2225
- // Check if any node name or file path relates to query terms
2226
- const hasQueryRelevance = (filePath, nodes) => {
2227
- const fp = filePath.toLowerCase();
2228
- if (queryTerms.some(t => fp.includes(t)))
2229
- return true;
2230
- return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
2231
- };
2232
- const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
2233
- const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
2234
- if (aRelevant !== bRelevant)
2235
- return aRelevant ? -1 : 1;
1746
+ // Agent-named files first (it asked for a symbol defined here by name).
1747
+ const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
1748
+ const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
1749
+ if (aNamed !== bNamed)
1750
+ return bNamed - aNamed;
1751
+ // Graph connectivity is the next key (small epsilon so near-ties fall
1752
+ // through to the text signal rather than coin-flipping on float noise).
1753
+ const aG = fileGraphScore.get(a[0]) ?? 0;
1754
+ const bG = fileGraphScore.get(b[0]) ?? 0;
1755
+ if (Math.abs(aG - bG) > maxGraph * 0.01)
1756
+ return bG - aG;
1757
+ const aHits = fileTermHits.get(a[0]) ?? 0;
1758
+ const bHits = fileTermHits.get(b[0]) ?? 0;
1759
+ if (aHits !== bHits)
1760
+ return bHits - aHits;
2236
1761
  const aLow = isLowValue(aPath);
2237
1762
  const bLow = isLowValue(bPath);
2238
1763
  if (aLow !== bLow)
@@ -2258,6 +1783,12 @@ class ToolHandler {
2258
1783
  `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
2259
1784
  '',
2260
1785
  ];
1786
+ // Blast radius (always-on, compact): for the entry symbols, who depends on
1787
+ // them + which tests cover them — locations only, no source — so the agent
1788
+ // knows what to update/verify before editing without a separate call.
1789
+ const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
1790
+ if (blastRadius)
1791
+ lines.push(blastRadius);
2261
1792
  // Relationship map — show how symbols connect
2262
1793
  const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
2263
1794
  );
@@ -2412,7 +1943,7 @@ class ToolHandler {
2412
1943
  // so leave it to the normal full render.
2413
1944
  const namedBodyChars = group.nodes
2414
1945
  .filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
2415
- .reduce((s, n) => s + fileLines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 220)).join('\n').length, 0);
1946
+ .reduce((s, n) => s + fileLines.slice(n.startLine - 1, n.endLine).join('\n').length, 0);
2416
1947
  const onSpineGodFile = hasSpineNode
2417
1948
  && namedBodyChars > budget.maxCharsPerFile
2418
1949
  && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
@@ -2432,14 +1963,19 @@ class ToolHandler {
2432
1963
  : flow.pathNodeIds.has(n.id) ? 0
2433
1964
  : flow.uniqueNamedNodeIds.has(n.id) ? 1
2434
1965
  : (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
2435
- const bodyCap = budget.maxCharsPerFile * 2;
1966
+ // One ~250-line WINDOW per file. syms are taken by priority (spine first,
1967
+ // then uniquely-named, then family-base), and the cap applies to ALL of
1968
+ // them — including the spine — so a big-spine god-file (tokio's worker.rs:
1969
+ // run→run_task→next_task→steal_work) can't eat the whole response and
1970
+ // starve the co-flow file (harness.rs's poll). The native agent windows
1971
+ // such a file too (~190 lines at a time), so this mimics, not truncates.
1972
+ // Always emit ≥1 (never an empty section).
1973
+ const bodyCap = budget.maxCharsPerFile * 1.5;
2436
1974
  const bodyIds = new Set();
2437
1975
  let bodyChars = 0;
2438
1976
  for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
2439
- const sz = fileLines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 220)).join('\n').length;
2440
- // Spine methods (prio 0) ALWAYS get a full body the cap governs the
2441
- // off-path extras (unique-named, family base), never the flow path itself.
2442
- if (prio(n) > 0 && bodyChars + sz > bodyCap && bodyIds.size > 0)
1977
+ const sz = fileLines.slice(n.startLine - 1, n.endLine).join('\n').length;
1978
+ if (bodyChars + sz > bodyCap && bodyIds.size > 0)
2443
1979
  continue;
2444
1980
  bodyIds.add(n.id);
2445
1981
  bodyChars += sz;
@@ -2455,7 +1991,7 @@ class ToolHandler {
2455
1991
  if (n.startLine <= coveredUntil)
2456
1992
  continue;
2457
1993
  if (bodyIds.has(n.id)) {
2458
- const end = Math.min(n.endLine, n.startLine + 220);
1994
+ const end = n.endLine;
2459
1995
  const body = fileLines.slice(n.startLine - 1, end).join('\n');
2460
1996
  skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
2461
1997
  coveredUntil = end;
@@ -2503,14 +2039,30 @@ class ToolHandler {
2503
2039
  continue;
2504
2040
  }
2505
2041
  }
2506
- // Whole-small-file rule: if a relevant file is small enough to afford,
2507
- // return it ENTIRELY instead of clustering. Clustering exists to tame
2508
- // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
2509
- // lossy subset of a file the agent will just Read in full anyway — costing
2510
- // a round-trip and a re-read every later turn. Reserve clustering for files
2042
+ // Whole-file rule: if a relevant file is small enough to afford, return it
2043
+ // ENTIRELY instead of clustering. Clustering exists to tame god-files
2044
+ // (App.tsx ~13k lines); on a ~134-line component a cluster is a lossy
2045
+ // subset of a file the agent will just Read in full anyway — costing a
2046
+ // round-trip and a re-read every later turn. Reserve clustering for files
2511
2047
  // too big to ship whole. Still bounded by the total maxOutputChars check.
2512
- const WHOLE_FILE_MAX_LINES = 220;
2513
- const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
2048
+ //
2049
+ // CENTRAL files (where the query's entry points live) get a larger — but
2050
+ // bounded — ceiling: they're the heart of the answer, the file(s) the agent
2051
+ // would Read whole, so a genuinely small one comes back whole rather than as
2052
+ // thin clusters. A LARGE central file (the 791-line org-user store) exceeds
2053
+ // the ceiling and falls through to sectioning/clustering below — full method
2054
+ // bodies + signatures — so we never dump (or overflow on) a whole god-file.
2055
+ const isCentralFile = centralFiles.has(filePath);
2056
+ // Central files get a slightly larger whole-file window than peripheral ones,
2057
+ // but a TIGHT one (~1.5× the per-file cap): the native read of a central file
2058
+ // is a ~150–250 line orientation window, NOT the whole file. A flat "whole
2059
+ // central file" both overflowed the inline cap AND starved the co-flow files
2060
+ // (worker.rs ate the budget, dropping harness.rs's poll). A larger central
2061
+ // file falls through to per-method windowing/clustering below.
2062
+ const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
2063
+ const WHOLE_FILE_MAX_CHARS = isCentralFile
2064
+ ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
2065
+ : budget.maxCharsPerFile * 3;
2514
2066
  if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
2515
2067
  const body = fileContent.replace(/\n+$/, '');
2516
2068
  let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
@@ -2520,12 +2072,12 @@ class ToolHandler {
2520
2072
  const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
2521
2073
  const omitted = uniqSymbols.length - headerNames.length;
2522
2074
  const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
2523
- if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
2524
- const remaining = budget.maxOutputChars - totalChars - 200;
2525
- if (remaining < 500)
2526
- break;
2527
- wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
2075
+ if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
2076
+ // Don't slice a whole file mid-method: an incidental file that doesn't
2077
+ // fit is skipped; a necessary one (below) renders in full. Half a file
2078
+ // forces the Read this is meant to prevent.
2528
2079
  anyFileTrimmed = true;
2080
+ continue;
2529
2081
  }
2530
2082
  lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
2531
2083
  totalChars += wholeSection.length + 200;
@@ -2706,7 +2258,6 @@ class ToolHandler {
2706
2258
  // Emit chosen clusters in source order so the file reads top-to-bottom.
2707
2259
  let fileSection = '';
2708
2260
  const allSymbols = [];
2709
- let fileTrimmed = false;
2710
2261
  for (let i = 0; i < clusters.length; i++) {
2711
2262
  if (!chosenIndices.has(i))
2712
2263
  continue;
@@ -2717,13 +2268,12 @@ class ToolHandler {
2717
2268
  fileSection += section;
2718
2269
  allSymbols.push(...cluster.symbols);
2719
2270
  }
2720
- // If a single chosen cluster is still oversize (long monolithic
2721
- // function), tail-trim it. Better one trimmed view than nothing.
2722
- if (fileSection.length > budget.maxCharsPerFile) {
2723
- fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
2724
- fileTrimmed = true;
2725
- }
2726
- if (chosenIndices.size < clusters.length || fileTrimmed) {
2271
+ // A chosen cluster is a COMPLETE method-range — we never cut through a body.
2272
+ // An oversize single cluster (a long monolithic function) renders in FULL:
2273
+ // half a method is useless (the agent just Reads the rest for the other half),
2274
+ // which is the very fallback explore exists to prevent. A pathological file is
2275
+ // bounded by the per-file cluster SELECTION above + the total hard ceiling.
2276
+ if (chosenIndices.size < clusters.length) {
2727
2277
  anyFileTrimmed = true;
2728
2278
  }
2729
2279
  // Dedupe + cap the symbols list shown in the per-file header. Some
@@ -2755,11 +2305,11 @@ class ToolHandler {
2755
2305
  // (DataRequest/Validation) all render, instead of the cap dropping whichever
2756
2306
  // phase the file order happened to put last.
2757
2307
  if (!fileNecessary && totalChars + fileSection.length + 200 > budget.maxOutputChars) {
2758
- const remaining = budget.maxOutputChars - totalChars - 200;
2759
- if (remaining < 500)
2760
- continue; // incidental file, no room skip it, keep scanning for necessary ones
2761
- fileSection = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
2308
+ // Incidental file that doesn't fit: SKIP it whole — never slice mid-method.
2309
+ // Keep scanning for necessary files (which bypass this cap and render in
2310
+ // full, bounded by the hard ceiling).
2762
2311
  anyFileTrimmed = true;
2312
+ continue;
2763
2313
  }
2764
2314
  lines.push(fileHeader);
2765
2315
  lines.push('');
@@ -2816,26 +2366,26 @@ class ToolHandler {
2816
2366
  // Stats unavailable — skip budget note
2817
2367
  }
2818
2368
  }
2819
- // Hard-cap to the adaptive budget. The per-file loop bounds the source
2820
- // sections, but the relationship map, additional-files list, and
2821
- // completeness/budget notes can still push the assembled output past
2822
- // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
2823
- // payload persists in the agent's context and is re-read as cache-input
2824
- // on every subsequent turn, so the overrun is paid many times over.
2825
- // Final ceiling. The render loop is now the authority on WHAT to emit — it
2826
- // renders necessary files (named/spine) even past maxOutputChars and caps
2827
- // only incidental ones, all bounded by maxFiles + per-file true-spine — so
2828
- // this is a SAFETY ceiling above that necessary content, not a hard cut
2829
- // through it. Cutting at a flat maxOutputChars here undid the whole point:
2830
- // Alamofire's loop assembles build+validators-exec+validate (~15K) and a 13K
2831
- // slice dropped the validate phase the agent then Read. Allow necessary
2832
- // overflow up to 1.5× (still bounds a pathological monolith).
2369
+ // Final ceiling an ABSOLUTE inline cap, not a multiple of the budget. The
2370
+ // render loop renders necessary (named/spine) files even a bit past
2371
+ // maxOutputChars and caps only incidental ones, so this is the last safety.
2372
+ // It MUST stay under the host's inline tool-result limit (~25K chars): above
2373
+ // that the result is externalized to a file the agent Reads back (a 35K
2374
+ // vscode explore did exactly this in the n=4 A/B). So allow a little
2375
+ // necessary overflow above the 24K budget, but hard-stop at 25K never into
2376
+ // externalize territory.
2833
2377
  const output = flow.text + lines.join('\n');
2834
- const hardCeiling = Math.round(budget.maxOutputChars * 1.5);
2378
+ const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
2835
2379
  if (output.length > hardCeiling) {
2380
+ // Cut at a FILE-SECTION boundary (the last `#### ` header before the
2381
+ // ceiling) so we drop whole trailing file-sections rather than slicing
2382
+ // through a method body — a half-rendered method just forces the Read this
2383
+ // tool exists to prevent. Fall back to a line boundary only if no section
2384
+ // header sits in the back half (degenerate single-giant-section case).
2836
2385
  const cut = output.slice(0, hardCeiling);
2837
- const lastNewline = cut.lastIndexOf('\n');
2838
- const safe = lastNewline > hardCeiling * 0.8 ? cut.slice(0, lastNewline) : cut;
2386
+ const lastSection = cut.lastIndexOf('\n#### ');
2387
+ const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
2388
+ const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
2839
2389
  return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
2840
2390
  }
2841
2391
  return this.textResult(output);
@@ -2850,29 +2400,110 @@ class ToolHandler {
2850
2400
  const cg = this.getCodeGraph(args.projectPath);
2851
2401
  // Default to false to minimize context usage
2852
2402
  const includeCode = args.includeCode === true;
2853
- const match = this.findSymbol(cg, symbol);
2854
- if (!match) {
2403
+ const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
2404
+ const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
2405
+ let matches = this.findSymbolMatches(cg, symbol);
2406
+ if (matches.length === 0) {
2855
2407
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
2856
2408
  }
2409
+ // Disambiguate a heavily-overloaded name to a specific definition the caller
2410
+ // pinned by file/line (the `file:line` a trail or another tool showed it) —
2411
+ // so it can fetch e.g. `Harness::poll` at harness.rs:153 out of 50+ `poll`s
2412
+ // instead of Reading. file matches by path suffix/substring; line prefers the
2413
+ // def whose body contains it, else the nearest start. Only narrows (never
2414
+ // empties — if a hint matches nothing it's ignored).
2415
+ if (matches.length > 1 && (fileHint || lineHint !== undefined)) {
2416
+ const norm = (p) => p.replace(/\\/g, '/').toLowerCase();
2417
+ let narrowed = matches;
2418
+ if (fileHint) {
2419
+ const fh = norm(fileHint);
2420
+ const byFile = narrowed.filter((n) => norm(n.filePath).endsWith(fh) || norm(n.filePath).includes(fh));
2421
+ if (byFile.length > 0)
2422
+ narrowed = byFile;
2423
+ }
2424
+ if (lineHint !== undefined && narrowed.length > 1) {
2425
+ const containing = narrowed.filter((n) => n.startLine <= lineHint && (n.endLine ?? n.startLine) >= lineHint);
2426
+ narrowed = containing.length > 0
2427
+ ? containing
2428
+ : [...narrowed].sort((a, b) => Math.abs(a.startLine - lineHint) - Math.abs(b.startLine - lineHint)).slice(0, 1);
2429
+ }
2430
+ if (narrowed.length > 0)
2431
+ matches = narrowed;
2432
+ }
2433
+ // Single definition — the common case.
2434
+ if (matches.length === 1) {
2435
+ return this.textResult(this.truncateOutput(await this.renderNodeSection(cg, matches[0], includeCode)));
2436
+ }
2437
+ // Multiple definitions share this name — overloads, or same-named methods on
2438
+ // different types (Alamofire `didCompleteTask`/`task`/`validate`, gin
2439
+ // `reset`). Returning ONE forces the agent to guess, and when it guesses
2440
+ // wrong it READS the file to find the right overload — the dominant
2441
+ // codegraph_node read cause on Swift/Go. So return them ALL: pack as many
2442
+ // FULL bodies as fit a char budget (the agent gets the one it needs in this
2443
+ // one call, no follow-up parameter to learn), and list any remainder by
2444
+ // file:line so a large overload set can't overflow the per-tool cap.
2445
+ const header = `**${matches.length} definitions named "${symbol}"**`;
2446
+ if (!includeCode) {
2447
+ const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
2448
+ return this.textResult(this.truncateOutput([header, '', 'Re-query with `includeCode: true` to get every body in one call — no need to pick one first.', '', ...list].join('\n')));
2449
+ }
2450
+ const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
2451
+ // The CHAR budget is the real limiter — keep the count cap high so a set of
2452
+ // SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
2453
+ // render in full rather than relegating the one the agent wanted to a
2454
+ // bodiless list. Only a set of many LARGE bodies hits the char budget first.
2455
+ const HARD_CAP = 16;
2456
+ const rendered = [];
2457
+ const listed = [];
2458
+ let used = 0;
2459
+ for (const n of matches) {
2460
+ if (rendered.length >= HARD_CAP) {
2461
+ listed.push(n);
2462
+ continue;
2463
+ }
2464
+ const section = await this.renderNodeSection(cg, n, true);
2465
+ // Always emit the first; emit the rest only while within the char budget.
2466
+ if (rendered.length === 0 || used + section.length <= BODY_BUDGET) {
2467
+ rendered.push(section);
2468
+ used += section.length;
2469
+ }
2470
+ else {
2471
+ listed.push(n);
2472
+ }
2473
+ }
2474
+ const out = [
2475
+ header,
2476
+ `Returning ${rendered.length} in full${listed.length ? `; ${listed.length} more listed below` : ''} — pick the one you need (no Read required).`,
2477
+ '',
2478
+ rendered.join('\n\n---\n\n'),
2479
+ ];
2480
+ if (listed.length) {
2481
+ const LIST_CAP = 20;
2482
+ const shownList = listed.slice(0, LIST_CAP);
2483
+ out.push('', '### Other definitions', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
2484
+ if (listed.length > LIST_CAP)
2485
+ out.push(`- … +${listed.length - LIST_CAP} more`);
2486
+ out.push('', `> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0].filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`);
2487
+ }
2488
+ return this.textResult(this.truncateOutput(out.join('\n')));
2489
+ }
2490
+ /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
2491
+ async renderNodeSection(cg, node, includeCode) {
2857
2492
  let code = null;
2858
2493
  let outline = null;
2859
2494
  if (includeCode) {
2860
2495
  // For container symbols (class/interface/struct/…), the full body is the
2861
- // sum of every method body — a wall of source (e.g. a 10k-char class)
2862
- // that bloats context and is rarely needed in full. Return a structural
2863
- // outline (members + signatures + line numbers) instead; the agent can
2864
- // Read or codegraph_node a specific method for its body. Leaf symbols
2865
- // (function/method/etc.) return their full body as before.
2866
- if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
2867
- outline = this.buildContainerOutline(cg, match.node);
2496
+ // sum of every method body — a wall of source. Return a structural outline
2497
+ // (members + signatures + line numbers) instead; leaf symbols return their
2498
+ // full body.
2499
+ if (CONTAINER_NODE_KINDS.has(node.kind)) {
2500
+ outline = this.buildContainerOutline(cg, node);
2868
2501
  }
2869
2502
  if (!outline) {
2870
- code = await cg.getCode(match.node.id);
2503
+ code = await cg.getCode(node.id);
2871
2504
  }
2872
2505
  }
2873
- const trail = this.formatTrail(cg, match.node);
2874
- const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
2875
- return this.textResult(this.truncateOutput(formatted));
2506
+ return this.formatNodeDetails(node, code, outline) + this.formatTrail(cg, node);
2876
2507
  }
2877
2508
  /**
2878
2509
  * Build the "trail" for a symbol: its direct callees (what it calls) and
@@ -3212,51 +2843,55 @@ class ToolHandler {
3212
2843
  const segments = node.filePath.split('/').filter((s) => s.length > 0);
3213
2844
  return containerHints.every((hint) => segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint));
3214
2845
  }
3215
- findSymbol(cg, symbol) {
3216
- // Use higher limit for qualified lookups (e.g., "Session.request",
3217
- // "stage_apply::run") since the target may rank lower in FTS when
3218
- // there are many partial matches across the qualifier parts.
2846
+ /**
2847
+ * Find ALL definitions matching a name, ranked, so codegraph_node can return
2848
+ * every overload instead of guessing one (the wrong guess a Read). Keepers
2849
+ * rank before generated stubs (.pb.go etc.); stable within a group preserves
2850
+ * FTS order. Returns [] when nothing matches; a qualified lookup that finds no
2851
+ * exact match returns [] rather than a misleading fuzzy file hit (#173); a
2852
+ * bare name with no exact match falls back to the single top fuzzy result.
2853
+ */
2854
+ findSymbolMatches(cg, symbol) {
3219
2855
  const isQualified = /[.\/]|::/.test(symbol);
3220
- const limit = isQualified ? 50 : 10;
2856
+ // For a bare name, enumerate EVERY exact-name definition via the direct index
2857
+ // (not FTS, which caps + ranks): tokio's `poll` has 50+ defs and the one the
2858
+ // caller wants (`Harness::poll` at harness.rs:153) ranks below any search cut,
2859
+ // so it could be neither rendered nor pinned by the file/line disambiguator —
2860
+ // and the agent Read it. With the full set, the multi-overload render + the
2861
+ // file/line filter can both reach it.
2862
+ if (!isQualified) {
2863
+ const exact = cg.getNodesByName(symbol);
2864
+ if (exact.length > 0) {
2865
+ return [...exact].sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0));
2866
+ }
2867
+ // No exact match — use the single top fuzzy result (e.g. a file basename).
2868
+ const fuzzy = cg.searchNodes(symbol, { limit: 10 });
2869
+ return fuzzy[0] ? [fuzzy[0].node] : [];
2870
+ }
2871
+ // Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol.
2872
+ const limit = 50;
3221
2873
  let results = cg.searchNodes(symbol, { limit });
3222
- // FTS strips colons as a special char, so `stage_apply::run` searches
3223
- // for the literal `stage_applyrun` and finds nothing. Re-search by
3224
- // the bare last part and let `matchesSymbol` filter by qualifier.
2874
+ // FTS strips colons, so `stage_apply::run` searches the literal
2875
+ // `stage_applyrun` and finds nothing. Re-search by the bare last part and
2876
+ // let `matchesSymbol` filter by qualifier.
3225
2877
  if (isQualified && results.length === 0) {
3226
2878
  const tail = lastQualifierPart(symbol);
3227
2879
  if (tail && tail !== symbol)
3228
2880
  results = cg.searchNodes(tail, { limit });
3229
2881
  }
3230
- if (results.length === 0 || !results[0]) {
3231
- return null;
3232
- }
3233
- const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
3234
- if (exactMatches.length === 1) {
3235
- return { node: exactMatches[0].node, note: '' };
3236
- }
3237
- if (exactMatches.length > 1) {
3238
- // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …)
3239
- // so a query like "Send" prefers the keeper implementation over
3240
- // the protobuf-generated interface stub. Stable sort preserves
3241
- // FTS order within each group. See generated-detection.ts.
3242
- const ranked = [...exactMatches].sort((a, b) => {
3243
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
3244
- const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
3245
- return aGen - bGen;
3246
- });
3247
- // Multiple exact matches - pick first, note the others
3248
- const picked = ranked[0].node;
3249
- const others = ranked.slice(1).map(r => `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`);
3250
- const note = `\n\n> **Note:** ${ranked.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
3251
- return { node: picked, note };
3252
- }
3253
- // No exact match. For qualified lookups, don't silently fall back
3254
- // to a fuzzy result — the user typed a specific qualifier, and
3255
- // resolving `stage_apply::nonexistent_fn` to the unrelated
3256
- // `stage_apply.rs` file would be actively misleading (#173).
3257
- if (isQualified)
3258
- return null;
3259
- return { node: results[0].node, note: '' };
2882
+ if (results.length === 0)
2883
+ return [];
2884
+ const exactMatches = results.filter((r) => this.matchesSymbol(r.node, symbol));
2885
+ if (exactMatches.length === 0) {
2886
+ // No exact match — a qualified lookup must not fall back to a fuzzy file
2887
+ // hit (#173); a bare name may use the single top fuzzy result.
2888
+ return isQualified ? [] : results[0] ? [results[0].node] : [];
2889
+ }
2890
+ // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …) so a flow
2891
+ // query prefers the keeper implementation over the protobuf-generated stub.
2892
+ return [...exactMatches]
2893
+ .sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0))
2894
+ .map((r) => r.node);
3260
2895
  }
3261
2896
  /**
3262
2897
  * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
@@ -3398,9 +3033,6 @@ class ToolHandler {
3398
3033
  }
3399
3034
  return lines.join('\n');
3400
3035
  }
3401
- formatTaskContext(context) {
3402
- return context.summary || 'No context found';
3403
- }
3404
3036
  textResult(text) {
3405
3037
  return {
3406
3038
  content: [{ type: 'text', text }],