homegraph 1.2.0 → 1.4.1

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 (284) hide show
  1. package/dist/bin/homegraph.js +3 -0
  2. package/dist/bin/homegraph.js.map +1 -1
  3. package/dist/context/index.d.ts.map +1 -1
  4. package/dist/context/index.js +85 -0
  5. package/dist/context/index.js.map +1 -1
  6. package/dist/db/index.d.ts +64 -5
  7. package/dist/db/index.d.ts.map +1 -1
  8. package/dist/db/index.js +178 -14
  9. package/dist/db/index.js.map +1 -1
  10. package/dist/db/migrations.d.ts +1 -1
  11. package/dist/db/migrations.d.ts.map +1 -1
  12. package/dist/db/migrations.js +19 -1
  13. package/dist/db/migrations.js.map +1 -1
  14. package/dist/db/queries.d.ts +117 -2
  15. package/dist/db/queries.d.ts.map +1 -1
  16. package/dist/db/queries.js +299 -6
  17. package/dist/db/queries.js.map +1 -1
  18. package/dist/db/schema.sql +32 -1
  19. package/dist/db/wal-valve.d.ts +106 -0
  20. package/dist/db/wal-valve.d.ts.map +1 -0
  21. package/dist/db/wal-valve.js +208 -0
  22. package/dist/db/wal-valve.js.map +1 -0
  23. package/dist/directory.d.ts +9 -5
  24. package/dist/directory.d.ts.map +1 -1
  25. package/dist/directory.js +210 -19
  26. package/dist/directory.js.map +1 -1
  27. package/dist/extraction/cfml-extractor.d.ts +107 -0
  28. package/dist/extraction/cfml-extractor.d.ts.map +1 -0
  29. package/dist/extraction/cfml-extractor.js +494 -0
  30. package/dist/extraction/cfml-extractor.js.map +1 -0
  31. package/dist/extraction/grammars.d.ts +25 -1
  32. package/dist/extraction/grammars.d.ts.map +1 -1
  33. package/dist/extraction/grammars.js +177 -24
  34. package/dist/extraction/grammars.js.map +1 -1
  35. package/dist/extraction/index.d.ts +43 -4
  36. package/dist/extraction/index.d.ts.map +1 -1
  37. package/dist/extraction/index.js +308 -5
  38. package/dist/extraction/index.js.map +1 -1
  39. package/dist/extraction/languages/c-cpp.d.ts +42 -0
  40. package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  41. package/dist/extraction/languages/c-cpp.js +369 -4
  42. package/dist/extraction/languages/c-cpp.js.map +1 -1
  43. package/dist/extraction/languages/cfquery.d.ts +12 -0
  44. package/dist/extraction/languages/cfquery.d.ts.map +1 -0
  45. package/dist/extraction/languages/cfquery.js +28 -0
  46. package/dist/extraction/languages/cfquery.js.map +1 -0
  47. package/dist/extraction/languages/cfscript.d.ts +3 -0
  48. package/dist/extraction/languages/cfscript.d.ts.map +1 -0
  49. package/dist/extraction/languages/cfscript.js +73 -0
  50. package/dist/extraction/languages/cfscript.js.map +1 -0
  51. package/dist/extraction/languages/cobol.d.ts +33 -0
  52. package/dist/extraction/languages/cobol.d.ts.map +1 -0
  53. package/dist/extraction/languages/cobol.js +499 -0
  54. package/dist/extraction/languages/cobol.js.map +1 -0
  55. package/dist/extraction/languages/erlang.d.ts +3 -0
  56. package/dist/extraction/languages/erlang.d.ts.map +1 -0
  57. package/dist/extraction/languages/erlang.js +350 -0
  58. package/dist/extraction/languages/erlang.js.map +1 -0
  59. package/dist/extraction/languages/index.d.ts.map +1 -1
  60. package/dist/extraction/languages/index.js +16 -0
  61. package/dist/extraction/languages/index.js.map +1 -1
  62. package/dist/extraction/languages/nix.d.ts +3 -0
  63. package/dist/extraction/languages/nix.d.ts.map +1 -0
  64. package/dist/extraction/languages/nix.js +294 -0
  65. package/dist/extraction/languages/nix.js.map +1 -0
  66. package/dist/extraction/languages/solidity.d.ts +3 -0
  67. package/dist/extraction/languages/solidity.d.ts.map +1 -0
  68. package/dist/extraction/languages/solidity.js +293 -0
  69. package/dist/extraction/languages/solidity.js.map +1 -0
  70. package/dist/extraction/languages/terraform.d.ts +3 -0
  71. package/dist/extraction/languages/terraform.d.ts.map +1 -0
  72. package/dist/extraction/languages/terraform.js +641 -0
  73. package/dist/extraction/languages/terraform.js.map +1 -0
  74. package/dist/extraction/languages/vbnet.d.ts +11 -0
  75. package/dist/extraction/languages/vbnet.d.ts.map +1 -0
  76. package/dist/extraction/languages/vbnet.js +141 -0
  77. package/dist/extraction/languages/vbnet.js.map +1 -0
  78. package/dist/extraction/mybatis-extractor.d.ts +30 -10
  79. package/dist/extraction/mybatis-extractor.d.ts.map +1 -1
  80. package/dist/extraction/mybatis-extractor.js +140 -38
  81. package/dist/extraction/mybatis-extractor.js.map +1 -1
  82. package/dist/extraction/parse-pool.d.ts +27 -1
  83. package/dist/extraction/parse-pool.d.ts.map +1 -1
  84. package/dist/extraction/parse-pool.js +75 -7
  85. package/dist/extraction/parse-pool.js.map +1 -1
  86. package/dist/extraction/parse-worker.js +9 -2
  87. package/dist/extraction/parse-worker.js.map +1 -1
  88. package/dist/extraction/tree-sitter-helpers.d.ts.map +1 -1
  89. package/dist/extraction/tree-sitter-helpers.js +1 -0
  90. package/dist/extraction/tree-sitter-helpers.js.map +1 -1
  91. package/dist/extraction/tree-sitter-types.d.ts +3 -1
  92. package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  93. package/dist/extraction/tree-sitter.d.ts +38 -0
  94. package/dist/extraction/tree-sitter.d.ts.map +1 -1
  95. package/dist/extraction/tree-sitter.js +754 -10
  96. package/dist/extraction/tree-sitter.js.map +1 -1
  97. package/dist/extraction/wasm/tree-sitter-arkts.wasm +0 -0
  98. package/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  99. package/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  100. package/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  101. package/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  102. package/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  103. package/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  104. package/dist/extraction/wasm/tree-sitter-terraform.wasm +0 -0
  105. package/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  106. package/dist/graph/traversal.d.ts.map +1 -1
  107. package/dist/graph/traversal.js +1 -1
  108. package/dist/graph/traversal.js.map +1 -1
  109. package/dist/index.d.ts +60 -2
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +348 -5
  112. package/dist/index.js.map +1 -1
  113. package/dist/installer/index.d.ts +42 -0
  114. package/dist/installer/index.d.ts.map +1 -1
  115. package/dist/installer/index.js +97 -2
  116. package/dist/installer/index.js.map +1 -1
  117. package/dist/mcp/daemon.d.ts +25 -3
  118. package/dist/mcp/daemon.d.ts.map +1 -1
  119. package/dist/mcp/daemon.js +62 -8
  120. package/dist/mcp/daemon.js.map +1 -1
  121. package/dist/mcp/dynamic-boundaries.d.ts.map +1 -1
  122. package/dist/mcp/dynamic-boundaries.js +2 -1
  123. package/dist/mcp/dynamic-boundaries.js.map +1 -1
  124. package/dist/mcp/early-ppid.d.ts +26 -0
  125. package/dist/mcp/early-ppid.d.ts.map +1 -0
  126. package/dist/mcp/early-ppid.js +29 -0
  127. package/dist/mcp/early-ppid.js.map +1 -0
  128. package/dist/mcp/engine.d.ts +4 -5
  129. package/dist/mcp/engine.d.ts.map +1 -1
  130. package/dist/mcp/engine.js +27 -1
  131. package/dist/mcp/engine.js.map +1 -1
  132. package/dist/mcp/index.d.ts.map +1 -1
  133. package/dist/mcp/index.js +41 -6
  134. package/dist/mcp/index.js.map +1 -1
  135. package/dist/mcp/liveness-watchdog.d.ts +18 -1
  136. package/dist/mcp/liveness-watchdog.d.ts.map +1 -1
  137. package/dist/mcp/liveness-watchdog.js +73 -13
  138. package/dist/mcp/liveness-watchdog.js.map +1 -1
  139. package/dist/mcp/memory-budget.d.ts +30 -0
  140. package/dist/mcp/memory-budget.d.ts.map +1 -0
  141. package/dist/mcp/memory-budget.js +123 -0
  142. package/dist/mcp/memory-budget.js.map +1 -0
  143. package/dist/mcp/proxy.d.ts.map +1 -1
  144. package/dist/mcp/proxy.js +88 -4
  145. package/dist/mcp/proxy.js.map +1 -1
  146. package/dist/mcp/query-cache.d.ts +35 -4
  147. package/dist/mcp/query-cache.d.ts.map +1 -1
  148. package/dist/mcp/query-cache.js +89 -19
  149. package/dist/mcp/query-cache.js.map +1 -1
  150. package/dist/mcp/query-pool.d.ts +46 -12
  151. package/dist/mcp/query-pool.d.ts.map +1 -1
  152. package/dist/mcp/query-pool.js +100 -23
  153. package/dist/mcp/query-pool.js.map +1 -1
  154. package/dist/mcp/server-instructions.d.ts +4 -27
  155. package/dist/mcp/server-instructions.d.ts.map +1 -1
  156. package/dist/mcp/server-instructions.js +39 -84
  157. package/dist/mcp/server-instructions.js.map +1 -1
  158. package/dist/mcp/session.d.ts +14 -0
  159. package/dist/mcp/session.d.ts.map +1 -1
  160. package/dist/mcp/session.js +28 -1
  161. package/dist/mcp/session.js.map +1 -1
  162. package/dist/mcp/startup-handshake.d.ts +44 -0
  163. package/dist/mcp/startup-handshake.d.ts.map +1 -0
  164. package/dist/mcp/startup-handshake.js +73 -0
  165. package/dist/mcp/startup-handshake.js.map +1 -0
  166. package/dist/mcp/tools.d.ts +104 -4
  167. package/dist/mcp/tools.d.ts.map +1 -1
  168. package/dist/mcp/tools.js +2390 -191
  169. package/dist/mcp/tools.js.map +1 -1
  170. package/dist/mcp/transport.d.ts.map +1 -1
  171. package/dist/mcp/transport.js +18 -0
  172. package/dist/mcp/transport.js.map +1 -1
  173. package/dist/project-config.d.ts +38 -0
  174. package/dist/project-config.d.ts.map +1 -1
  175. package/dist/project-config.js +101 -2
  176. package/dist/project-config.js.map +1 -1
  177. package/dist/resolution/c-fnptr-synthesizer.d.ts +2 -1
  178. package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
  179. package/dist/resolution/c-fnptr-synthesizer.js +178 -146
  180. package/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
  181. package/dist/resolution/callback-synthesizer.d.ts +9 -1
  182. package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  183. package/dist/resolution/callback-synthesizer.js +1164 -176
  184. package/dist/resolution/callback-synthesizer.js.map +1 -1
  185. package/dist/resolution/frameworks/cics.d.ts +20 -0
  186. package/dist/resolution/frameworks/cics.d.ts.map +1 -0
  187. package/dist/resolution/frameworks/cics.js +90 -0
  188. package/dist/resolution/frameworks/cics.js.map +1 -0
  189. package/dist/resolution/frameworks/index.d.ts.map +1 -1
  190. package/dist/resolution/frameworks/index.js +3 -0
  191. package/dist/resolution/frameworks/index.js.map +1 -1
  192. package/dist/resolution/frameworks/java.d.ts.map +1 -1
  193. package/dist/resolution/frameworks/java.js +14 -6
  194. package/dist/resolution/frameworks/java.js.map +1 -1
  195. package/dist/resolution/frameworks/terraform.d.ts +38 -0
  196. package/dist/resolution/frameworks/terraform.d.ts.map +1 -0
  197. package/dist/resolution/frameworks/terraform.js +277 -0
  198. package/dist/resolution/frameworks/terraform.js.map +1 -0
  199. package/dist/resolution/goframe-synthesizer.d.ts +2 -1
  200. package/dist/resolution/goframe-synthesizer.d.ts.map +1 -1
  201. package/dist/resolution/goframe-synthesizer.js +8 -3
  202. package/dist/resolution/goframe-synthesizer.js.map +1 -1
  203. package/dist/resolution/import-resolver.d.ts +7 -0
  204. package/dist/resolution/import-resolver.d.ts.map +1 -1
  205. package/dist/resolution/import-resolver.js +129 -4
  206. package/dist/resolution/import-resolver.js.map +1 -1
  207. package/dist/resolution/index.d.ts +60 -7
  208. package/dist/resolution/index.d.ts.map +1 -1
  209. package/dist/resolution/index.js +430 -80
  210. package/dist/resolution/index.js.map +1 -1
  211. package/dist/resolution/name-matcher.d.ts +0 -3
  212. package/dist/resolution/name-matcher.d.ts.map +1 -1
  213. package/dist/resolution/name-matcher.js +354 -33
  214. package/dist/resolution/name-matcher.js.map +1 -1
  215. package/dist/resolution/strip-comments.d.ts +1 -1
  216. package/dist/resolution/strip-comments.d.ts.map +1 -1
  217. package/dist/resolution/strip-comments.js +49 -0
  218. package/dist/resolution/strip-comments.js.map +1 -1
  219. package/dist/resolution/types.d.ts +29 -0
  220. package/dist/resolution/types.d.ts.map +1 -1
  221. package/dist/resolution/workspace-packages.d.ts +10 -0
  222. package/dist/resolution/workspace-packages.d.ts.map +1 -1
  223. package/dist/resolution/workspace-packages.js +142 -4
  224. package/dist/resolution/workspace-packages.js.map +1 -1
  225. package/dist/search/identifier-segments.d.ts +60 -0
  226. package/dist/search/identifier-segments.d.ts.map +1 -0
  227. package/dist/search/identifier-segments.js +176 -0
  228. package/dist/search/identifier-segments.js.map +1 -0
  229. package/dist/search/query-utils.d.ts +201 -0
  230. package/dist/search/query-utils.d.ts.map +1 -1
  231. package/dist/search/query-utils.js +919 -2
  232. package/dist/search/query-utils.js.map +1 -1
  233. package/dist/sync/git-hooks.d.ts.map +1 -1
  234. package/dist/sync/git-hooks.js +2 -0
  235. package/dist/sync/git-hooks.js.map +1 -1
  236. package/dist/sync/watcher.d.ts +10 -5
  237. package/dist/sync/watcher.d.ts.map +1 -1
  238. package/dist/sync/watcher.js +51 -14
  239. package/dist/sync/watcher.js.map +1 -1
  240. package/dist/sync/worktree.d.ts.map +1 -1
  241. package/dist/sync/worktree.js +5 -0
  242. package/dist/sync/worktree.js.map +1 -1
  243. package/dist/types.d.ts +19 -1
  244. package/dist/types.d.ts.map +1 -1
  245. package/dist/types.js +10 -0
  246. package/dist/types.js.map +1 -1
  247. package/dist/upgrade/index.d.ts +32 -0
  248. package/dist/upgrade/index.d.ts.map +1 -1
  249. package/dist/upgrade/index.js +162 -14
  250. package/dist/upgrade/index.js.map +1 -1
  251. package/dist/upgrade/remove-binary.d.ts +87 -0
  252. package/dist/upgrade/remove-binary.d.ts.map +1 -0
  253. package/dist/upgrade/remove-binary.js +289 -0
  254. package/dist/upgrade/remove-binary.js.map +1 -0
  255. package/dist/upgrade/update-check.d.ts +92 -0
  256. package/dist/upgrade/update-check.d.ts.map +1 -0
  257. package/dist/upgrade/update-check.js +258 -0
  258. package/dist/upgrade/update-check.js.map +1 -0
  259. package/package.json +2 -2
  260. package/scripts/agent-eval/run-all.sh +6 -0
  261. package/scripts/build-bundle.sh +5 -0
  262. package/scripts/npm-shim.js +8 -1
  263. package/scripts/exp_boundary_eval/__pycache__/_utils.cpython-310.pyc +0 -0
  264. package/scripts/exp_boundary_eval/__pycache__/analyze.cpython-310.pyc +0 -0
  265. package/scripts/exp_boundary_eval/__pycache__/deveco_arm.cpython-310.pyc +0 -0
  266. package/scripts/exp_boundary_eval/__pycache__/run_one.cpython-310.pyc +0 -0
  267. package/scripts/exp_boundary_eval/__pycache__/run_session.cpython-310.pyc +0 -0
  268. package/scripts/exp_boundary_eval/__pycache__/setup.cpython-310.pyc +0 -0
  269. package/scripts/exp_boundary_eval/data/agents.json +0 -109
  270. package/scripts/exp_boundary_eval/data/experiments.json +0 -140
  271. package/scripts/qa_eval/README.md +0 -407
  272. package/scripts/qa_eval/_test_deveco_probe.py +0 -41
  273. package/scripts/qa_eval/agent_runner.py +0 -526
  274. package/scripts/qa_eval/data/.gitignore +0 -4
  275. package/scripts/qa_eval/data/test-set.jsonl +0 -2
  276. package/scripts/qa_eval/eval_metrics.py +0 -274
  277. package/scripts/qa_eval/external_agent.py +0 -976
  278. package/scripts/qa_eval/llm_config.py +0 -92
  279. package/scripts/qa_eval/memory_monitor.py +0 -132
  280. package/scripts/qa_eval/my_answer_accuracy.py +0 -187
  281. package/scripts/qa_eval/requirements.txt +0 -2
  282. package/scripts/qa_eval/run_pipeline.py +0 -804
  283. package/scripts/qa_eval/stats_efficiency.py +0 -279
  284. package/scripts/qa_eval/stats_scores.py +0 -207
package/dist/mcp/tools.js CHANGED
@@ -6,12 +6,16 @@
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.ToolHandler = exports.tools = exports.PathRefusalError = exports.NotIndexedError = void 0;
9
+ exports.normalizeQuerySpelling = normalizeQuerySpelling;
9
10
  exports.getExploreBudget = getExploreBudget;
10
11
  exports.getExploreOutputBudget = getExploreOutputBudget;
12
+ exports.tightenExploreBudgetForQuery = tightenExploreBudgetForQuery;
11
13
  exports.formatStaleBanner = formatStaleBanner;
12
14
  exports.formatStaleFooter = formatStaleFooter;
13
15
  exports.formatDegradedBanner = formatDegradedBanner;
14
16
  exports.getStaticTools = getStaticTools;
17
+ const query_pool_1 = require("./query-pool");
18
+ const memory_budget_1 = require("./memory-budget");
15
19
  const directory_1 = require("../directory");
16
20
  // Lazy-load the heavy HomeGraph chain off the MCP startup path — see the same
17
21
  // helper in engine.ts. ToolHandler must load to answer tools/list (static
@@ -96,6 +100,25 @@ function lastQualifierPart(symbol) {
96
100
  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
97
101
  return parts[parts.length - 1] ?? symbol;
98
102
  }
103
+ /**
104
+ * Normalize Erlang-native symbol spellings in an explore query into the shapes
105
+ * the rest of the pipeline already understands. Agents working Erlang code
106
+ * name symbols the way the language spells them — `mod:fn/3`, `init/2` — and
107
+ * those tokens previously died in both consumers: the flow-builder's token
108
+ * filter rejects `:` and `/arity` outright, and the search-side field parser
109
+ * eats `mod:fn` as an unknown `field:value`.
110
+ *
111
+ * - `fn/3` → `fn` (arity tail after an identifier)
112
+ * - `mod:fn` → `mod.fn` (exactly one colon between identifiers)
113
+ *
114
+ * Safe cross-language: Lua's `t:m` spelling maps to the same `t.m` its
115
+ * qualified names use.
116
+ */
117
+ function normalizeQuerySpelling(query) {
118
+ return query
119
+ .replace(/\b([A-Za-z_][\w@]*)\/(\d{1,3})(?=$|[\s,()[\]/])/g, '$1')
120
+ .replace(/(^|[\s,()[\]])(?!(?:kind|lang|language|path|name):)([a-z_][\w@]*):([A-Za-z_][\w@]*)(?=$|[\s,()[\]])/g, '$1$2.$3');
121
+ }
99
122
  /**
100
123
  * Calculate the recommended number of homegraph_explore calls based on project size.
101
124
  * Larger codebases need more exploration calls to cover their surface area,
@@ -211,6 +234,46 @@ function getExploreOutputBudget(fileCount) {
211
234
  excludeLowValueFiles: false,
212
235
  };
213
236
  }
237
+ /**
238
+ * Shrink explore ceilings for local-detail / no-flow named-symbol questions.
239
+ * Large repos otherwise dump ~24K related source that the agent then still
240
+ * greps/reads — the main token regression vs without-homegraph.
241
+ */
242
+ function tightenExploreBudgetForQuery(budget, query, opts) {
243
+ const hasFlow = opts?.hasFlowPath === true;
244
+ if (hasFlow && ((0, query_utils_1.queryAsMechanismSurvey)(query) || (0, query_utils_1.queryAsCrossModuleFlowSurvey)(query))) {
245
+ return budget;
246
+ }
247
+ const local = (0, query_utils_1.queryAsLocalSymbolDetail)(query);
248
+ const compact = (0, query_utils_1.shouldUseCompactExploreBudget)(query);
249
+ if (!local && !compact && hasFlow)
250
+ return budget;
251
+ if (!local && !compact && hasFlow === false) {
252
+ // Generic no-flow explore: still trim meta + file count a bit.
253
+ return {
254
+ ...budget,
255
+ maxOutputChars: Math.min(budget.maxOutputChars, 14000),
256
+ defaultMaxFiles: Math.min(budget.defaultMaxFiles, 3),
257
+ maxCharsPerFile: Math.min(budget.maxCharsPerFile, 5000),
258
+ includeRelationships: false,
259
+ includeAdditionalFiles: false,
260
+ includeCompletenessSignal: false,
261
+ includeBudgetNote: false,
262
+ };
263
+ }
264
+ if (!local && !compact)
265
+ return budget;
266
+ return {
267
+ ...budget,
268
+ maxOutputChars: Math.min(budget.maxOutputChars, local ? 9000 : 12000),
269
+ defaultMaxFiles: Math.min(budget.defaultMaxFiles, local ? 2 : 3),
270
+ maxCharsPerFile: Math.min(budget.maxCharsPerFile, local ? 4000 : 5000),
271
+ includeRelationships: false,
272
+ includeAdditionalFiles: false,
273
+ includeCompletenessSignal: false,
274
+ includeBudgetNote: false,
275
+ };
276
+ }
214
277
  /**
215
278
  * Whether `homegraph_explore` should prefix source lines with their line
216
279
  * numbers (cat -n style: `<num>\t<code>`).
@@ -329,18 +392,26 @@ function fileSectionHeader(filePath, suffix) {
329
392
  * The agent uses this to fall back to Read for those specific files
330
393
  * without waiting for the debounced sync (issue #403).
331
394
  */
332
- function formatStaleBanner(stale) {
395
+ function formatStaleBanner(stale, opts) {
333
396
  const now = Date.now();
334
397
  const lines = stale.map((p) => {
335
398
  const ageMs = Math.max(0, now - p.lastSeenMs);
336
399
  const label = p.indexing ? 'indexing in progress' : 'pending sync';
337
400
  return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
338
401
  });
402
+ // On large indexes catch-up is skipped for RSS — "Read them" teaches the agent
403
+ // to abandon homegraph after every save and burn tokens on whole-file Reads.
404
+ const guidance = opts?.catchUpDeferred
405
+ ? 'Callers / defs / explore locations above are still usable for structural answers. ' +
406
+ 'Read a listed file only if you need byte-exact content that may have changed mid-session — ' +
407
+ 'do not re-read the whole file just because of this notice.'
408
+ : 'For accurate content of those specific files, Read them directly. ' +
409
+ 'The rest of this response is fresh.';
339
410
  return ('⚠️ Some files referenced below were edited since the last index sync — ' +
340
411
  'their homegraph entries may be stale:\n' +
341
412
  lines.join('\n') +
342
- '\nFor accurate content of those specific files, Read them directly. ' +
343
- 'The rest of this response is fresh.');
413
+ '\n' +
414
+ guidance);
344
415
  }
345
416
  /**
346
417
  * Compact footer listing pending files that are NOT referenced in this
@@ -409,7 +480,7 @@ const READ_ONLY_ANNOTATIONS = {
409
480
  exports.tools = [
410
481
  {
411
482
  name: 'homegraph_search',
412
- description: 'Quick symbol search by name. Returns locations only (no code). Use homegraph_explore instead to get the actual source / understand an area in one call.',
483
+ description: 'LAST RESORT spelling lookup locations only, no source. Prefer homegraph_explore whenever the question already names a symbol/file/@kit. If you do call search with a bare name, HomeGraph may answer with a compact explore result instead of locations.',
413
484
  inputSchema: {
414
485
  type: 'object',
415
486
  properties: {
@@ -435,7 +506,7 @@ exports.tools = [
435
506
  },
436
507
  {
437
508
  name: 'homegraph_callers',
438
- description: 'List functions that call <symbol>. For the full flow, use homegraph_explore.',
509
+ description: 'Compact caller list for a NAMED in-repo symbol (no bodies). Use after you know the exact name. For full flows use homegraph_explore. DO NOT call for SDK catalogs, one-function semantics, or "what if X fails" hypothetics — Read that function instead. Prefer one explore over parallel callers+callees+node.',
439
510
  inputSchema: {
440
511
  type: 'object',
441
512
  properties: {
@@ -460,7 +531,7 @@ exports.tools = [
460
531
  },
461
532
  {
462
533
  name: 'homegraph_callees',
463
- description: 'List functions that <symbol> calls. For the full flow, use homegraph_explore.',
534
+ description: 'Compact callee list for a NAMED in-repo symbol (no bodies). For full flows use homegraph_explore. DO NOT use for out-of-repo SDK internals or counterfactual analysis. Prefer one explore over parallel node+callers+callees.',
464
535
  inputSchema: {
465
536
  type: 'object',
466
537
  properties: {
@@ -485,7 +556,7 @@ exports.tools = [
485
556
  },
486
557
  {
487
558
  name: 'homegraph_impact',
488
- description: 'List symbols affected by changing <symbol>. Use before a refactor.',
559
+ description: 'Blast radius for a NAMED in-repo symbol before a refactor. Not for SDK docs, permission judgments, or hypothetical failure effects — those need Read/Grep, not impact.',
489
560
  inputSchema: {
490
561
  type: 'object',
491
562
  properties: {
@@ -510,7 +581,7 @@ exports.tools = [
510
581
  },
511
582
  {
512
583
  name: 'homegraph_node',
513
- description: 'Two modes. (1) READ A FILE use INSTEAD of the Read tool: pass `file` (a path or basename) with no `symbol` and it returns that file\'s current on-disk source with line numbers, exactly the shape Read gives you (`<n>\\t<line>`, safe to Edit from), narrowable with `offset`/`limit` just like Read — PLUS a one-line note of which files depend on it. Same bytes as Read, faster (served from the index), with the blast radius attached. Use it whenever you would Read a source file. (2) ONE SYMBOL you can name its location, signature, verbatim source (includeCode=true) and caller/callee trail in one call, so before changing it you see what calls it and what your edit would break. For an AMBIGUOUS name it returns EVERY matching definition\'s body in one call (so you never Read a file to find the right overload); pass `file`/`line` to pin one. Use homegraph_explore for several related symbols or the full flow.',
584
+ description: 'Depth on ONE known in-repo symbol or indexed file not a survey tool. (1) FILE: pass `file` alone bounded line-numbered source + dependents. (2) SYMBOL: body (includeCode) + short trail; overloads return every body. USE after explore named the symbol and you still need one body. DO NOT call repeatedly to crawl a feature (prefer one explore). DO NOT call for: @kit/SDK catalogs, broad "how does X work", or after explore already returned that symbol\'s source. Treat returned source as already Read do not grep/read the same path.',
514
585
  inputSchema: {
515
586
  type: 'object',
516
587
  properties: {
@@ -552,13 +623,13 @@ exports.tools = [
552
623
  },
553
624
  {
554
625
  name: 'homegraph_explore',
555
- description: 'PRIMARY TOOL call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.',
626
+ description: 'PRIMARY tool for THIS REPO\'s symbol graph (call paths + often line-numbered source). CALL when you need in-repo structure: how a named feature/component is wired, A→B path, callers/callees, Type.member who uses it, click/handler flow, in-repo @kit import usages put concrete symbol/file/@kit names in query; skip search. If the question does not need that graph, do not call. One explore; answer from returned Source + trail; treat as already Read; do not re-grep/search the same names. Busy/partial retry same explore once.',
556
627
  inputSchema: {
557
628
  type: 'object',
558
629
  properties: {
559
630
  query: {
560
631
  type: 'string',
561
- description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too no prior homegraph_search needed.',
632
+ description: 'In-repo symbols, file basenames, or @kit names for USAGE questions (import/call sites), not for asking the kit\'s official full API list. For flows, name both endpoints. Prefer concrete names from the question.',
562
633
  },
563
634
  maxFiles: {
564
635
  type: 'number',
@@ -584,7 +655,7 @@ exports.tools = [
584
655
  },
585
656
  {
586
657
  name: 'homegraph_files',
587
- description: 'Indexed file tree with language + symbol counts. Faster than Glob for project layout.',
658
+ description: 'Indexed directory tree (paths and symbol counts only — NO source code). Do NOT use to answer where/what/how code questions; use homegraph_explore. Only for coarse folder layout when explore cannot help.',
588
659
  inputSchema: {
589
660
  type: 'object',
590
661
  properties: {
@@ -1064,6 +1135,7 @@ class ToolHandler {
1064
1135
  freshen(cg) {
1065
1136
  try {
1066
1137
  if (cg.reopenIfReplaced()) {
1138
+ (0, query_cache_1.getMcpQueryCacheIndex)(cg.getProjectRoot()).reset();
1067
1139
  process.stderr.write('[HomeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
1068
1140
  'recreated at the same path); reopened the live database in place.\n');
1069
1141
  }
@@ -1270,7 +1342,18 @@ class ToolHandler {
1270
1342
  }
1271
1343
  let banner = '';
1272
1344
  if (inResponse.length > 0) {
1273
- banner = formatStaleBanner(inResponse);
1345
+ let dbPath = null;
1346
+ try {
1347
+ // Large indexes skip catch-up — soft banner so agents don't abandon HG for Read.
1348
+ const root = cg.getProjectRoot();
1349
+ dbPath = (0, path_1.resolve)(root, '.homegraph', 'homegraph.db');
1350
+ }
1351
+ catch {
1352
+ dbPath = null;
1353
+ }
1354
+ banner = formatStaleBanner(inResponse, {
1355
+ catchUpDeferred: (0, memory_budget_1.shouldSkipCatchUpSync)(dbPath),
1356
+ });
1274
1357
  }
1275
1358
  let footer = '';
1276
1359
  if (elsewhere.length > 0) {
@@ -1299,6 +1382,11 @@ class ToolHandler {
1299
1382
  this.catchUpGate = null;
1300
1383
  await this.awaitCatchUpGate(gate);
1301
1384
  }
1385
+ // Hard process RSS ceiling — any tool/path that already pushed us over must
1386
+ // stop with success-shaped Partial (never OOM / multi-GB growth).
1387
+ if ((0, memory_budget_1.isOverRssBudget)()) {
1388
+ return (0, memory_budget_1.rssBudgetPartialResult)(toolName);
1389
+ }
1302
1390
  // Honor the optional tool allowlist (HOMEGRAPH_MCP_TOOLS): a trimmed
1303
1391
  // surface rejects ablated tools defensively even if a client cached them.
1304
1392
  if (!this.isToolAllowed(toolName)) {
@@ -1328,11 +1416,13 @@ class ToolHandler {
1328
1416
  const cacheEnabled = (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
1329
1417
  let cacheKey;
1330
1418
  let cacheQueries;
1419
+ let cacheIndex;
1331
1420
  if (cacheEnabled) {
1332
1421
  try {
1333
1422
  const cacheCg = this.getHomeGraph(projectPath);
1334
1423
  cacheQueries = cacheCg.getQueryBuilder();
1335
- (0, query_cache_1.ensureMcpQueryCacheValid)(cacheQueries, () => cacheCg.getLastIndexedAt());
1424
+ cacheIndex = (0, query_cache_1.getMcpQueryCacheIndex)(cacheCg.getProjectRoot());
1425
+ cacheIndex.ensureValid(cacheQueries, () => cacheCg.getLastIndexedAt());
1336
1426
  let fileCount;
1337
1427
  try {
1338
1428
  fileCount = cacheCg.getStats().fileCount;
@@ -1341,7 +1431,7 @@ class ToolHandler {
1341
1431
  fileCount = undefined;
1342
1432
  }
1343
1433
  cacheKey = (0, query_cache_1.buildMcpQueryCacheKey)(toolName, args, fileCount);
1344
- const cached = (0, query_cache_1.getMcpQueryCacheEntry)(cacheQueries, cacheKey);
1434
+ const cached = cacheIndex.getEntry(cacheQueries, cacheKey);
1345
1435
  if (cached) {
1346
1436
  const withWorktree = this.withWorktreeNotice(cached, projectPath);
1347
1437
  return this.withStalenessNotice(withWorktree, projectPath);
@@ -1359,20 +1449,43 @@ class ToolHandler {
1359
1449
  if (toolName === 'homegraph_status') {
1360
1450
  return await this.handleStatus(args);
1361
1451
  }
1362
- // Read tools: off-load the CPU-heavy dispatch to the worker pool when one
1363
- // is attached and healthy (daemon mode), so the daemon's single event loop
1364
- // stays free for the MCP transport under concurrent load otherwise N
1365
- // concurrent explores serialize AND starve the transport until the whole
1366
- // batch drains (clients then time out). With no pool (direct mode) or a
1367
- // degraded one, dispatch runs in-process exactly as before. Either way the
1368
- // result flows through the cross-cutting notices worktree-index mismatch
1369
- // (#155) and per-file staleness (#403) — which need the watched MAIN
1370
- // instance and so are always applied here, never in the worker.
1371
- const result = (this.queryPool && this.queryPool.healthy)
1372
- ? await this.queryPool.run(toolName, args)
1373
- : await this.executeReadTool(toolName, args);
1374
- if (cacheEnabled && cacheKey && cacheQueries && !result.isError) {
1375
- (0, query_cache_1.setMcpQueryCacheEntry)(cacheQueries, cacheKey, toolName, result);
1452
+ // Every read tool races a deadline MCP client ~60s hard timeout and
1453
+ // Named-member / local-compact questions finish in tens of ms on the warm
1454
+ // main connection. Serving them here before the query-pool offload
1455
+ // avoids cold-worker / wedged-daemon paths that otherwise surface as empty
1456
+ // MCP client `-32001` (the handler itself is fine; the transport times out).
1457
+ if (toolName === 'homegraph_explore' || toolName === 'homegraph_search') {
1458
+ const q = typeof args.query === 'string' ? args.query : '';
1459
+ if (q) {
1460
+ try {
1461
+ const cgFast = this.getHomeGraph(projectPath);
1462
+ const rootFast = cgFast.getProjectRoot();
1463
+ const fast = (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
1464
+ ? this.tryFastInventoryExplore(cgFast, q, rootFast)
1465
+ : null)
1466
+ ?? this.tryCompactLocalSymbolExplore(cgFast, q, rootFast)
1467
+ ?? (toolName === 'homegraph_explore'
1468
+ ? this.tryLightMechanismExplore(cgFast, q, rootFast)
1469
+ : null);
1470
+ if (fast) {
1471
+ if (cacheEnabled && cacheKey && cacheQueries && cacheIndex && !fast.isError) {
1472
+ cacheIndex.setEntry(cacheQueries, cacheKey, toolName, fast);
1473
+ }
1474
+ const withWorktree = this.withWorktreeNotice(fast, projectPath);
1475
+ return this.withStalenessNotice(withWorktree, projectPath);
1476
+ }
1477
+ }
1478
+ catch {
1479
+ // Not indexed / path issue — fall through to normal dispatch.
1480
+ }
1481
+ }
1482
+ }
1483
+ // prefers the query pool so sync SQLite/CPU cannot freeze the transport
1484
+ // (a frozen main loop prevents setTimeout deadlines from firing → empty
1485
+ // `-32001`). Fast-path surveys run inside the worker via executeReadTool.
1486
+ const result = await this.runReadToolWithDeadline(toolName, args);
1487
+ if (cacheEnabled && cacheKey && cacheQueries && cacheIndex && !result.isError) {
1488
+ cacheIndex.setEntry(cacheQueries, cacheKey, toolName, result);
1376
1489
  }
1377
1490
  const withWorktree = this.withWorktreeNotice(result, projectPath);
1378
1491
  return this.withStalenessNotice(withWorktree, projectPath);
@@ -1393,6 +1506,64 @@ class ToolHandler {
1393
1506
  'continue without homegraph for this task.');
1394
1507
  }
1395
1508
  }
1509
+ /**
1510
+ * Dispatch a read tool with a hard deadline under the typical ~60s MCP client
1511
+ * timeout.
1512
+ *
1513
+ * - **Heavy** tools (explore/impact) → query pool (keeps transport free).
1514
+ * - **Light** tools (search/node/callers/…) → warm main connection (pool cold
1515
+ * open of a large WAL index routinely outruns the client; search used to
1516
+ * finish in <1s on the already-open main DB).
1517
+ *
1518
+ * Soft/deadline replies are **static busy text only** — never FTS / explore
1519
+ * on the main thread. A previous path called `searchNodes` from the timeout
1520
+ * callback with the full natural-language query, freezing the event loop so
1521
+ * the success-shaped reply never flushed → empty client `-32001`.
1522
+ */
1523
+ async runReadToolWithDeadline(toolName, args) {
1524
+ const deadlineMs = (0, query_pool_1.resolveToolDeadlineMs)();
1525
+ const light = toolName === 'homegraph_search'
1526
+ || toolName === 'homegraph_node'
1527
+ || toolName === 'homegraph_callers'
1528
+ || toolName === 'homegraph_callees'
1529
+ || toolName === 'homegraph_files';
1530
+ const work = () => {
1531
+ if (!light && this.queryPool && this.queryPool.healthy) {
1532
+ return this.queryPool.run(toolName, args, {
1533
+ // Static Partial only — never DB/FTS on the soft-timeout callback
1534
+ // (any sync work here can freeze the MCP transport → empty -32001).
1535
+ onSoftTimeout: () => this.deadlineBusyResult(deadlineMs),
1536
+ });
1537
+ }
1538
+ return (async () => {
1539
+ await new Promise((r) => setImmediate(r));
1540
+ return this.executeReadTool(toolName, args);
1541
+ })();
1542
+ };
1543
+ let timer;
1544
+ const deadline = new Promise((resolve) => {
1545
+ // Keep this timer ref'd so a blocked sync stretch still flushes Partial
1546
+ // once the event loop runs again — do not call wrapPartialBusyResult here.
1547
+ timer = setTimeout(() => {
1548
+ resolve(this.deadlineBusyResult(deadlineMs));
1549
+ }, deadlineMs);
1550
+ });
1551
+ try {
1552
+ return await Promise.race([work(), deadline]);
1553
+ }
1554
+ finally {
1555
+ if (timer)
1556
+ clearTimeout(timer);
1557
+ }
1558
+ }
1559
+ /** Success-shaped busy note — no DB, never blocks the transport. */
1560
+ deadlineBusyResult(deadlineMs) {
1561
+ const secs = Math.max(1, Math.round(deadlineMs / 1000));
1562
+ return this.textResult(`⚠️ **Partial result** — HomeGraph hit its ${secs}s response deadline / is busy ` +
1563
+ `(MCP clients typically kill the call at ~60s with an empty timeout). ` +
1564
+ `This is NOT an error. Retry ONE \`homegraph_explore\` with concrete symbol/file names from the question — ` +
1565
+ `do not fire search+explore or node+callers+callees in parallel, and do not grep/read symbols you already named.`);
1566
+ }
1396
1567
  /**
1397
1568
  * Run a single read tool to completion and return its raw {@link ToolResult},
1398
1569
  * classifying expected failures the same way {@link execute}'s catch does so
@@ -1408,6 +1579,12 @@ class ToolHandler {
1408
1579
  */
1409
1580
  async executeReadTool(toolName, args) {
1410
1581
  try {
1582
+ // Compact inventory / one-symbol surveys — safe on the worker (keeps the
1583
+ // daemon main loop free). Never run these unprotected on the MCP transport
1584
+ // thread: they can block long enough for the client to emit empty `-32001`.
1585
+ const fastPath = this.tryFastPathResult(toolName, args);
1586
+ if (fastPath)
1587
+ return fastPath;
1411
1588
  return await this.dispatchTool(toolName, args);
1412
1589
  }
1413
1590
  catch (err) {
@@ -1443,14 +1620,31 @@ class ToolHandler {
1443
1620
  default: return this.errorResult(`Unknown tool: ${toolName}`);
1444
1621
  }
1445
1622
  }
1446
- /**
1447
- * Handle homegraph_search
1448
- */
1449
1623
  async handleSearch(args) {
1450
1624
  const query = this.validateString(args.query, 'query');
1451
1625
  if (typeof query !== 'string')
1452
1626
  return query;
1453
1627
  const cg = this.getHomeGraph(args.projectPath);
1628
+ // Explore redirect is best-effort — incomplete/faked graphs (or missing
1629
+ // getProjectRoot) must fall through to FTS search rather than error.
1630
+ try {
1631
+ const projectRoot = cg.getProjectRoot();
1632
+ if ((0, query_utils_1.queryShouldPreferExploreOverSearch)(query)) {
1633
+ const exploreRedirect = this.tryFastInventoryExplore(cg, query, projectRoot)
1634
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
1635
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot);
1636
+ if (exploreRedirect)
1637
+ return exploreRedirect;
1638
+ }
1639
+ const exploreRedirect = this.tryFastInventoryExplore(cg, query, projectRoot)
1640
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
1641
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot);
1642
+ if (exploreRedirect)
1643
+ return exploreRedirect;
1644
+ }
1645
+ catch {
1646
+ // Fall through to FTS search.
1647
+ }
1454
1648
  const rawKind = args.kind;
1455
1649
  // The schema enum says 'type' (what agents naturally reach for); the
1456
1650
  // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
@@ -1474,7 +1668,9 @@ class ToolHandler {
1474
1668
  return aGen - bGen;
1475
1669
  });
1476
1670
  const formatted = this.formatSearchResults(ranked);
1477
- return this.textResult(this.truncateOutput(formatted));
1671
+ const steer = '\n\n> Locations only. For source / callers / how it works, call `homegraph_explore` ' +
1672
+ 'with these symbol names next — do not re-search or grep the same names.';
1673
+ return this.textResult(this.truncateOutput(formatted + steer));
1478
1674
  }
1479
1675
  /**
1480
1676
  * Group symbol matches into DISTINCT DEFINITIONS — one group per
@@ -1536,6 +1732,8 @@ class ToolHandler {
1536
1732
  const callers = [];
1537
1733
  const labels = new Map();
1538
1734
  for (const node of defNodes) {
1735
+ if ((0, memory_budget_1.isOverRssBudget)())
1736
+ break;
1539
1737
  for (const c of cg.getCallers(node.id)) {
1540
1738
  if (!seen.has(c.node.id)) {
1541
1739
  seen.add(c.node.id);
@@ -1544,6 +1742,8 @@ class ToolHandler {
1544
1742
  if (label)
1545
1743
  labels.set(c.node.id, label);
1546
1744
  }
1745
+ if (callers.length >= limit)
1746
+ return { callers, labels };
1547
1747
  }
1548
1748
  }
1549
1749
  return { callers, labels };
@@ -1557,7 +1757,9 @@ class ToolHandler {
1557
1757
  // A successful `file` narrowing makes the multi-symbol aggregation note
1558
1758
  // stale — suppress it.
1559
1759
  const note = fileFilter && !filteredOut ? '' : allMatches.note;
1560
- const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote;
1760
+ const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels)
1761
+ + '\n\n> Caller listing complete — answer from this list; no read/grep needed.'
1762
+ + note + filterNote;
1561
1763
  return this.textResult(this.truncateOutput(formatted));
1562
1764
  }
1563
1765
  // Multiple DISTINCT definitions (#764): one section per definition so an
@@ -1579,7 +1781,7 @@ class ToolHandler {
1579
1781
  lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
1580
1782
  }
1581
1783
  }
1582
- return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
1784
+ return this.textResult(this.truncateOutput(lines.join('\n') + filterNote + '\n\n> Caller listing complete — answer from this list; no read/grep needed.'));
1583
1785
  }
1584
1786
  /**
1585
1787
  * Handle homegraph_callees
@@ -1603,6 +1805,12 @@ class ToolHandler {
1603
1805
  const seen = new Set();
1604
1806
  const callees = [];
1605
1807
  const labels = new Map();
1808
+ const defPaths = defNodes.map((n) => n.filePath);
1809
+ const affinity = (fp) => defPaths.some((dp) => {
1810
+ const a = dp.replace(/\\/g, '/').split('/');
1811
+ const b = fp.replace(/\\/g, '/').split('/');
1812
+ return a.length >= 2 && b.length >= 2 && a[0] === b[0] && a[1] === b[1];
1813
+ });
1606
1814
  for (const node of defNodes) {
1607
1815
  for (const c of cg.getCallees(node.id)) {
1608
1816
  if (!seen.has(c.node.id)) {
@@ -1614,7 +1822,10 @@ class ToolHandler {
1614
1822
  }
1615
1823
  }
1616
1824
  }
1617
- return { callees, labels };
1825
+ // Prefer same-package callees when present — cross-package logError/logInfo
1826
+ // homonyms poison upstream/downstream answers.
1827
+ const near = callees.filter((n) => affinity(n.filePath));
1828
+ return { callees: near.length > 0 ? near : callees, labels };
1618
1829
  };
1619
1830
  if (groups.length === 1) {
1620
1831
  const { callees, labels } = collect(groups[0]);
@@ -2275,130 +2486,1704 @@ class ToolHandler {
2275
2486
  supers.set(s.id, e);
2276
2487
  }
2277
2488
  }
2278
- // Pick the supertype with the most TRUE implementers (graph-wide), among
2279
- // those genuinely shared by the token's definers.
2280
- let best = null;
2281
- for (const { node, count, targets } of supers.values()) {
2282
- if (count < MIN_SUPPORT)
2489
+ // Pick the supertype with the most TRUE implementers (graph-wide), among
2490
+ // those genuinely shared by the token's definers.
2491
+ let best = null;
2492
+ for (const { node, count, targets } of supers.values()) {
2493
+ if (count < MIN_SUPPORT)
2494
+ continue;
2495
+ let impl = 0;
2496
+ try {
2497
+ impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length;
2498
+ }
2499
+ catch { /* leave 0 — gated out below */ }
2500
+ if (impl < MIN_IMPL)
2501
+ continue;
2502
+ if (!best || impl > best.impl)
2503
+ best = { node, impl, targets };
2504
+ }
2505
+ if (!best || seenSuper.has(best.node.id))
2506
+ continue;
2507
+ seenSuper.add(best.node.id);
2508
+ const namedNames = new Set([...named.values()].map((n) => n.name));
2509
+ const eg = best.targets.slice(0, 4).map((m) => {
2510
+ const cont = containerOf(m);
2511
+ const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
2512
+ const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
2513
+ return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
2514
+ });
2515
+ const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
2516
+ notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
2517
+ }
2518
+ if (notes.length === 0)
2519
+ return '';
2520
+ return [
2521
+ '**Interface dispatch (a named method has many implementations)**',
2522
+ '',
2523
+ ...notes,
2524
+ '',
2525
+ '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run homegraph_explore on a listed target.',
2526
+ '',
2527
+ ].join('\n');
2528
+ }
2529
+ /**
2530
+ * Shortlist candidate runtime targets for a dispatch key surfaced by
2531
+ * {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
2532
+ * `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
2533
+ * normalized-containment post-filter (FTS camel-splitting is fuzzier than a
2534
+ * candidate list should be). Symbols the agent already named sort first and
2535
+ * are marked — that's the "you were right, here's the wiring" case.
2536
+ */
2537
+ boundaryCandidates(cg, key, keyIsType, named, selfId) {
2538
+ const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
2539
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
2540
+ const keyNorm = norm(key);
2541
+ if (keyNorm.length < 3)
2542
+ return '';
2543
+ const cands = new Map();
2544
+ const consider = (n) => {
2545
+ if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id))
2546
+ return;
2547
+ const nameNorm = norm(n.name || '');
2548
+ if (nameNorm.length < 3)
2549
+ return;
2550
+ if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm))
2551
+ return;
2552
+ cands.set(n.id, n);
2553
+ };
2554
+ const cap = key.charAt(0).toUpperCase() + key.slice(1);
2555
+ const probes = keyIsType
2556
+ ? [`${key}Handler`, key]
2557
+ : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
2558
+ for (const p of probes) {
2559
+ try {
2560
+ for (const n of cg.getNodesByName(p))
2561
+ consider(n);
2562
+ }
2563
+ catch { /* exact probe miss is fine */ }
2564
+ }
2565
+ let raw = 0;
2566
+ try {
2567
+ const results = cg.searchNodes(key, { limit: 12 });
2568
+ raw = results.length;
2569
+ for (const r of results)
2570
+ consider(r.node);
2571
+ }
2572
+ catch { /* FTS syntax edge — exact probes already ran */ }
2573
+ if (cands.size === 0) {
2574
+ return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
2575
+ }
2576
+ // A constructor candidate duplicates its class: extractors emit ctors as
2577
+ // METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
2578
+ const all = [...cands.values()];
2579
+ const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
2580
+ const namedNames = new Set([...named.values()].map((n) => n.name));
2581
+ const isNamed = (n) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
2582
+ const list = all
2583
+ .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
2584
+ .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
2585
+ .slice(0, 4)
2586
+ .map((n) => {
2587
+ // Typed-bus convention: the runtime target is the candidate class's
2588
+ // Handle/Execute/Consume method — name the exact node, not just the class.
2589
+ let display = n.qualifiedName || n.name;
2590
+ let at = `${n.filePath}:${n.startLine}`;
2591
+ if (keyIsType && n.kind === 'class') {
2592
+ try {
2593
+ const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
2594
+ const method = cg.getOutgoingEdges(n.id)
2595
+ .filter((e) => e.kind === 'contains')
2596
+ .map((e) => { try {
2597
+ return cg.getNode(e.target);
2598
+ }
2599
+ catch {
2600
+ return null;
2601
+ } })
2602
+ .find((c) => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
2603
+ if (method) {
2604
+ display = `${n.name}.${method.name}`;
2605
+ at = `${method.filePath}:${method.startLine}`;
2606
+ }
2607
+ }
2608
+ catch { /* class without resolvable members — show the class itself */ }
2609
+ }
2610
+ return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
2611
+ });
2612
+ return `candidates for key \`${key}\`: ${list.join(', ')}`;
2613
+ }
2614
+ /**
2615
+ * Import sites for @kit.* / *Kit module names — surfaces full `import { … } from '@kit.X'`
2616
+ * lines. When the query also names a symbol (taskpool), only matching imports are listed.
2617
+ */
2618
+ buildImportSitesSection(cg, query, projectRoot) {
2619
+ const kitTerms = (0, query_utils_1.extractKitModuleNamesFromQuery)(query);
2620
+ const depSymbols = (0, query_utils_1.extractDependencySymbolsFromQuery)(query);
2621
+ const kitSearchTerms = (0, query_utils_1.extractImportSearchTerms)(query);
2622
+ const seen = new Set();
2623
+ const sites = [];
2624
+ const tryAdd = (node, lineText) => {
2625
+ const lineLc = lineText.toLowerCase();
2626
+ if (kitTerms.length > 0) {
2627
+ const matchesKit = kitTerms.some((k) => lineLc.includes(`@kit.${k.toLowerCase()}`));
2628
+ if (!matchesKit)
2629
+ return;
2630
+ }
2631
+ if (depSymbols.length > 0) {
2632
+ const matchesSym = depSymbols.some((s) => lineLc.includes(s.toLowerCase()));
2633
+ if (!matchesSym)
2634
+ return;
2635
+ }
2636
+ if (depSymbols.includes('taskpool')) {
2637
+ if (!lineLc.includes('taskpool') || !lineLc.includes('@kit.arkts'))
2638
+ return;
2639
+ }
2640
+ const key = `${node.filePath}:${node.startLine}`;
2641
+ if (seen.has(key))
2642
+ return;
2643
+ seen.add(key);
2644
+ sites.push({ file: node.filePath, line: node.startLine, lineText });
2645
+ };
2646
+ const resolveImportLine = (node) => (0, query_utils_1.resolveImportLineFromNode)(node, projectRoot);
2647
+ const importLimit = depSymbols.includes('taskpool') ? 200
2648
+ : depSymbols.length > 0 ? 60 : 20;
2649
+ // Symbol-first search: "taskpool" hits `import { taskpool } from '@kit.ArkTS'`.
2650
+ for (const sym of depSymbols) {
2651
+ let hits = [];
2652
+ try {
2653
+ hits = cg.searchNodes(sym, { kinds: ['import'], limit: importLimit });
2654
+ }
2655
+ catch {
2656
+ continue;
2657
+ }
2658
+ for (const r of hits) {
2659
+ tryAdd(r.node, resolveImportLine(r.node));
2660
+ }
2661
+ }
2662
+ // Kit-module search (when no symbol filter, or to catch re-exports).
2663
+ if (sites.length === 0 || depSymbols.length === 0) {
2664
+ for (const term of kitSearchTerms) {
2665
+ const termLc = term.toLowerCase().replace(/^@kit\./, '');
2666
+ let hits = [];
2667
+ try {
2668
+ hits = cg.searchNodes(term, { kinds: ['import'], limit: importLimit });
2669
+ }
2670
+ catch {
2671
+ continue;
2672
+ }
2673
+ for (const r of hits) {
2674
+ const lineText = resolveImportLine(r.node);
2675
+ if (!lineText.toLowerCase().includes(termLc))
2676
+ continue;
2677
+ if (depSymbols.length > 0) {
2678
+ const matchesSym = depSymbols.some((s) => lineText.toLowerCase().includes(s.toLowerCase()));
2679
+ if (!matchesSym)
2680
+ continue;
2681
+ }
2682
+ tryAdd(r.node, lineText);
2683
+ }
2684
+ }
2685
+ }
2686
+ if (sites.length === 0) {
2687
+ return { section: '', siteCount: 0, compactListing: false };
2688
+ }
2689
+ sites.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
2690
+ const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
2691
+ const taskpoolInventory = depSymbols.includes('taskpool');
2692
+ const compactListing = taskpoolInventory || (0, query_utils_1.shouldCompactImportListing)(sites.length, importInventoryFilter);
2693
+ const cap = compactListing ? (taskpoolInventory ? sites.length : 40) : 15;
2694
+ const lines = compactListing
2695
+ ? ['**Dependency list**', '', `Files importing the queried symbol(s) (${sites.length} total):`, '']
2696
+ : ['**Import sites**', ''];
2697
+ for (const s of sites.slice(0, cap)) {
2698
+ if (compactListing) {
2699
+ lines.push(`- \`${s.file}\` (line ${s.line})`);
2700
+ }
2701
+ else {
2702
+ lines.push(`- \`${s.file}:${s.line}\` — \`${s.lineText}\``);
2703
+ }
2704
+ }
2705
+ if (sites.length > cap) {
2706
+ lines.push(`- … and ${sites.length - cap} more`);
2707
+ }
2708
+ if (compactListing) {
2709
+ lines.push('');
2710
+ if (taskpoolInventory) {
2711
+ lines.push(`> Complete list of **${sites.length}** \`import { taskpool } from '@kit.ArkTS'\` site(s) — answer from this section; no grep/read needed.`);
2712
+ }
2713
+ else {
2714
+ lines.push('> Listing complete — answer from this section; no grep/read needed for the dependency set.');
2715
+ }
2716
+ }
2717
+ lines.push('');
2718
+ return { section: lines.join('\n'), siteCount: sites.length, compactListing };
2719
+ }
2720
+ /**
2721
+ * External-caller inventory for a named class — methods → who calls them (paths only).
2722
+ */
2723
+ buildCallerListingSection(cg, query) {
2724
+ const typeNames = (0, query_utils_1.extractCallerSurveySymbols)(query);
2725
+ if (typeNames.length === 0)
2726
+ return '';
2727
+ const rel = (p) => p.replace(/\\/g, '/');
2728
+ // Only filter to other files when the user asked for "external" callers.
2729
+ // Applying this to every caller-survey query wiped same-file call sites
2730
+ // (common in C++/ARK .cpp units) and fell through to a fat full explore.
2731
+ const externalOnly = /\bexternal\b/i.test(query) || /外部/.test(query);
2732
+ const lines = ['**Caller inventory**', ''];
2733
+ let substantive = 0;
2734
+ // Function / method symbols named directly (e.g. SortWidgets).
2735
+ for (const sym of typeNames.slice(0, 4)) {
2736
+ const funcs = cg.getNodesByName(sym).filter((n) => (n.kind === 'function' || n.kind === 'method') && !(0, query_utils_1.isTestFile)(n.filePath));
2737
+ for (const fn of funcs.slice(0, 3)) {
2738
+ let callers = [];
2739
+ try {
2740
+ callers = cg.getCallers(fn.id);
2741
+ }
2742
+ catch {
2743
+ continue;
2744
+ }
2745
+ const uniq = new Map();
2746
+ for (const c of callers) {
2747
+ if (!c?.node)
2748
+ continue;
2749
+ if (externalOnly && c.node.filePath === fn.filePath)
2750
+ continue;
2751
+ if ((0, query_utils_1.isTestFile)(c.node.filePath))
2752
+ continue;
2753
+ uniq.set(c.node.id, c.node);
2754
+ }
2755
+ if (uniq.size === 0)
2756
+ continue;
2757
+ const callerList = [...uniq.values()].slice(0, 12)
2758
+ .map((n) => `\`${rel(n.filePath)}:${n.startLine}\` (\`${n.name}\`)`)
2759
+ .join(', ');
2760
+ const more = uniq.size > 12 ? ` +${uniq.size - 12} more` : '';
2761
+ lines.push(`- \`${fn.name}\` (\`${rel(fn.filePath)}:${fn.startLine}\`) ← ${callerList}${more}`);
2762
+ substantive++;
2763
+ }
2764
+ }
2765
+ for (const typeName of typeNames.slice(0, 3)) {
2766
+ const classes = cg.getNodesByName(typeName)
2767
+ .filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'component') && !(0, query_utils_1.isTestFile)(n.filePath));
2768
+ for (const cls of classes.slice(0, 2)) {
2769
+ const methods = [];
2770
+ for (const e of cg.getOutgoingEdges(cls.id)) {
2771
+ if (e.kind !== 'contains')
2772
+ continue;
2773
+ const m = cg.getNode(e.target);
2774
+ if (m && (m.kind === 'method' || m.kind === 'function'))
2775
+ methods.push(m);
2776
+ }
2777
+ if (methods.length === 0) {
2778
+ for (const n of cg.getNodesByName(typeName)) {
2779
+ if (n.filePath === cls.filePath && (n.kind === 'method' || n.kind === 'function')) {
2780
+ methods.push(n);
2781
+ }
2782
+ }
2783
+ }
2784
+ const methodLines = [];
2785
+ for (const method of methods.slice(0, 25)) {
2786
+ let callers = [];
2787
+ try {
2788
+ callers = cg.getCallers(method.id);
2789
+ }
2790
+ catch {
2791
+ continue;
2792
+ }
2793
+ const uniq = new Map();
2794
+ for (const c of callers) {
2795
+ if (!c?.node)
2796
+ continue;
2797
+ if (externalOnly && c.node.filePath === cls.filePath)
2798
+ continue;
2799
+ if ((0, query_utils_1.isTestFile)(c.node.filePath))
2800
+ continue;
2801
+ uniq.set(c.node.id, c.node);
2802
+ }
2803
+ if (uniq.size === 0)
2804
+ continue;
2805
+ const callerList = [...uniq.values()].slice(0, 8)
2806
+ .map((n) => `\`${rel(n.filePath)}:${n.startLine}\` (\`${n.name}\`)`)
2807
+ .join(', ');
2808
+ const more = uniq.size > 8 ? ` +${uniq.size - 8} more` : '';
2809
+ methodLines.push(`- \`${method.name}\` ← ${callerList}${more}`);
2810
+ substantive++;
2811
+ }
2812
+ if (methodLines.length === 0)
2813
+ continue;
2814
+ lines.push(`### \`${cls.name}\` (\`${rel(cls.filePath)}\`)`);
2815
+ lines.push(...methodLines);
2816
+ lines.push('');
2817
+ }
2818
+ }
2819
+ if (substantive === 0)
2820
+ return '';
2821
+ lines.push('> Caller inventory complete — answer from this section.');
2822
+ lines.push('');
2823
+ return lines.join('\n');
2824
+ }
2825
+ /**
2826
+ * Upstream data-source survey — which services/symbols feed a Manager (e.g. BadgeManager → notification).
2827
+ */
2828
+ buildDataSourceSection(cg, query) {
2829
+ const anchors = (0, query_utils_1.extractTypeNamesFromQuery)(query).filter((n) => /Manager|Service|Handler|Store|Provider|Controller/i.test(n));
2830
+ if (anchors.length === 0)
2831
+ return { section: '', edgeCount: 0 };
2832
+ const rel = (p) => p.replace(/\\/g, '/');
2833
+ // Prefer system/SDK / service APIs — not local UI helpers (getBadgeOffsetX).
2834
+ const SERVICE_RE = /notification|@ohos|ans|subscribe|publish|bundle|ability|vibrator|telephony|NumBadge|notificationManager|badgeManager|wantAgent|distributed/i;
2835
+ const lines = ['**Data sources / upstream services**', ''];
2836
+ let edgeCount = 0;
2837
+ const addEdge = (bucket, node, via, rank) => {
2838
+ if ((0, query_utils_1.isTestFile)(node.filePath))
2839
+ return;
2840
+ const sig = `${node.name} ${node.filePath} ${node.signature || ''}`;
2841
+ if (!SERVICE_RE.test(sig))
2842
+ return;
2843
+ const prev = bucket.get(node.id);
2844
+ if (!prev || rank < prev.rank) {
2845
+ bucket.set(node.id, { name: node.name, file: rel(node.filePath), line: node.startLine, via, rank });
2846
+ if (!prev)
2847
+ edgeCount++;
2848
+ }
2849
+ };
2850
+ for (const name of anchors.slice(0, 3)) {
2851
+ const classes = cg.getNodesByName(name).filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'component') && !(0, query_utils_1.isTestFile)(n.filePath));
2852
+ for (const cls of classes.slice(0, 2)) {
2853
+ const upstream = new Map();
2854
+ const methods = [];
2855
+ for (const e of cg.getOutgoingEdges(cls.id)) {
2856
+ if (e.kind !== 'contains')
2857
+ continue;
2858
+ const m = cg.getNode(e.target);
2859
+ if (m && (m.kind === 'method' || m.kind === 'function'))
2860
+ methods.push(m);
2861
+ }
2862
+ if (methods.length === 0) {
2863
+ for (const n of cg.getNodesByName(name)) {
2864
+ if (n.filePath === cls.filePath && (n.kind === 'method' || n.kind === 'function'))
2865
+ methods.push(n);
2866
+ }
2867
+ }
2868
+ // 1) Same-file @ohos / kit imports — the usual system-service surface.
2869
+ try {
2870
+ for (const r of cg.searchNodes('@ohos', { kinds: ['import'], limit: 40 })) {
2871
+ if (r.node.filePath !== cls.filePath)
2872
+ continue;
2873
+ addEdge(upstream, r.node, 'import', 0);
2874
+ }
2875
+ for (const term of ['notification', 'badge', 'NumBadge', 'subscribe', 'bundle', 'ability']) {
2876
+ for (const r of cg.searchNodes(term, { kinds: ['import'], limit: 30 })) {
2877
+ if (r.node.filePath !== cls.filePath)
2878
+ continue;
2879
+ addEdge(upstream, r.node, 'import', 1);
2880
+ }
2881
+ }
2882
+ }
2883
+ catch { /* */ }
2884
+ // 2) Callees that look like services/APIs.
2885
+ for (const method of methods.slice(0, 25)) {
2886
+ let callees = [];
2887
+ try {
2888
+ callees = cg.getCallees(method.id);
2889
+ }
2890
+ catch {
2891
+ continue;
2892
+ }
2893
+ for (const c of callees) {
2894
+ if (c?.node)
2895
+ addEdge(upstream, c.node, method.name, 2);
2896
+ }
2897
+ }
2898
+ if (upstream.size === 0)
2899
+ continue;
2900
+ lines.push(`### \`${cls.name}\` (\`${rel(cls.filePath)}\`)`);
2901
+ const ranked = [...upstream.values()].sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name));
2902
+ for (const u of ranked.slice(0, 15)) {
2903
+ lines.push(`- \`${u.name}\` at \`${u.file}:${u.line}\` (via \`${u.via}\`)`);
2904
+ }
2905
+ if (ranked.length > 15)
2906
+ lines.push(`- … and ${ranked.length - 15} more upstream symbol(s)`);
2907
+ lines.push('');
2908
+ lines.push('> Prefer `@ohos.*` / notification / bundle imports as the system-service answer; local helpers are secondary.');
2909
+ lines.push('');
2910
+ }
2911
+ }
2912
+ if (edgeCount === 0)
2913
+ return { section: '', edgeCount: 0 };
2914
+ lines.push('> Data-source survey — answer from upstream symbols above; source bodies omitted.');
2915
+ lines.push('');
2916
+ return { section: lines.join('\n'), edgeCount };
2917
+ }
2918
+ /**
2919
+ * Main-thread fast path for inventory surveys — skips the worker queue.
2920
+ */
2921
+ tryFastPathResult(toolName, args) {
2922
+ const query = args.query;
2923
+ if (typeof query !== 'string')
2924
+ return null;
2925
+ try {
2926
+ const cg = this.getHomeGraph(args.projectPath);
2927
+ const projectRoot = cg.getProjectRoot();
2928
+ if (toolName === 'homegraph_explore') {
2929
+ return this.tryFastInventoryExplore(cg, query, projectRoot)
2930
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
2931
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot);
2932
+ }
2933
+ if (toolName === 'homegraph_search') {
2934
+ return this.tryFastInventoryExplore(cg, query, projectRoot)
2935
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
2936
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot);
2937
+ }
2938
+ }
2939
+ catch {
2940
+ return null;
2941
+ }
2942
+ return null;
2943
+ }
2944
+ /**
2945
+ * Fast inventory-only explore — skips findRelevantContext for survey/caller/dependency queries.
2946
+ */
2947
+ tryFastInventoryExplore(cg, query, projectRoot) {
2948
+ if (!(0, query_utils_1.shouldTryFastInventoryExplore)(query))
2949
+ return null;
2950
+ const lines = [`**Exploration: ${query}**`, '', ''];
2951
+ const summaryLineIdx = 2;
2952
+ const inheritanceOnly = (0, query_utils_1.shouldBuildInheritanceSurvey)(query);
2953
+ const typeSurface = inheritanceOnly
2954
+ || (0, query_utils_1.shouldBuildCallerInventory)(query)
2955
+ || (0, query_utils_1.queryIsTypeNameFocus)(query);
2956
+ // Type / hierarchy / method-caller surveys: return inventory first — never a
2957
+ // definition dump or full-graph explore. Agent already named the type.
2958
+ // Exception: caller + co-named "definition visible" needs bodies (compact path).
2959
+ if (typeSurface && !(0, query_utils_1.queryNeedsCoNamedUseBridge)(query)) {
2960
+ const inheritanceSection = this.buildInheritanceSurveySection(cg, query);
2961
+ const callerSection = (0, query_utils_1.shouldBuildCallerInventory)(query)
2962
+ ? this.buildCallerListingSection(cg, query)
2963
+ : '';
2964
+ const inheritanceListed = inheritanceSection
2965
+ ? inheritanceSection.split('\n').filter((l) => l.startsWith('- `')).length > 0
2966
+ : false;
2967
+ const callerBulletCount = callerSection
2968
+ ? callerSection.split('\n').filter((l) => l.startsWith('- ') && l.includes(' ← ')).length
2969
+ : 0;
2970
+ if (inheritanceListed || callerBulletCount > 0) {
2971
+ const parts = [
2972
+ `**Exploration: ${query}**`,
2973
+ '',
2974
+ inheritanceListed && callerBulletCount > 0
2975
+ ? 'Type surface: inheritance + method caller inventory below — answer from it; do not grep `extends` and do not `homegraph_callers` each method.'
2976
+ : inheritanceListed
2977
+ ? 'Inheritance survey above lists all direct subtypes found.'
2978
+ : 'Caller inventory lists method→caller sites — answer from it; do not fan out `homegraph_callers` per method.',
2979
+ ];
2980
+ if (inheritanceSection)
2981
+ parts.push(inheritanceSection);
2982
+ if (callerSection)
2983
+ parts.push(callerSection);
2984
+ return this.textResult(parts.join('\n'));
2985
+ }
2986
+ // Nothing graph-visible — fall through; import/domain may still help.
2987
+ }
2988
+ const importResult = (inheritanceOnly || (0, query_utils_1.queryIsTypeNameFocus)(query) || (0, query_utils_1.queryAsInheritanceSurvey)(query))
2989
+ ? { section: '', siteCount: 0, compactListing: false }
2990
+ : this.buildImportSitesSection(cg, query, projectRoot);
2991
+ if (importResult.section)
2992
+ lines.push(importResult.section);
2993
+ const kitUsageResult = (0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
2994
+ ? this.buildKitModuleUsageSection(cg, query, projectRoot)
2995
+ : { section: '', symbolCount: 0 };
2996
+ if (kitUsageResult.section)
2997
+ lines.push(kitUsageResult.section);
2998
+ const domainFileResult = (0, query_utils_1.shouldBuildDomainFileSurvey)(query)
2999
+ ? this.buildDomainFileSurveySection(cg, query)
3000
+ : { section: '', fileCount: 0 };
3001
+ if (domainFileResult.section)
3002
+ lines.push(domainFileResult.section);
3003
+ const apiUsageResult = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
3004
+ ? this.buildApiUsageSection(cg, query, projectRoot)
3005
+ : { section: '', fileCount: 0 };
3006
+ if (apiUsageResult.section)
3007
+ lines.push(apiUsageResult.section);
3008
+ const dataSourceResult = (0, query_utils_1.queryAsDataSourceSurvey)(query)
3009
+ ? this.buildDataSourceSection(cg, query)
3010
+ : { section: '', edgeCount: 0 };
3011
+ if (dataSourceResult.section)
3012
+ lines.push(dataSourceResult.section);
3013
+ const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
3014
+ const multiAnchor = (0, query_utils_1.queryNamesMultipleExploreAnchors)(query);
3015
+ // Type / caller surveys: skip expensive named-symbol flow synthesize —
3016
+ // it wanders unrelated dynamic edges and burns tokens.
3017
+ const skipFlow = typeSurface || (0, query_utils_1.queryAsCallerOrMethodSurvey)(query);
3018
+ const flow = skipFlow
3019
+ ? { pathNodeIds: new Set(), text: '' }
3020
+ : this.buildFlowFromNamedSymbols(cg, query);
3021
+ const hasFlowPath = flow.pathNodeIds.size > 0;
3022
+ const inheritanceSection = !hasFlowPath && !multiAnchor
3023
+ ? this.buildInheritanceSurveySection(cg, query) : '';
3024
+ const callerSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildCallerInventory)(query)
3025
+ ? this.buildCallerListingSection(cg, query) : '';
3026
+ const memberSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildMemberSurvey)(query)
3027
+ ? this.buildMemberSurveySection(cg, query, projectRoot) : '';
3028
+ const configSection = (0, query_utils_1.shouldBuildConfigSection)(query)
3029
+ ? this.buildConfigFileSection(cg, query, projectRoot) : '';
3030
+ if (inheritanceSection)
3031
+ lines.push(inheritanceSection);
3032
+ if (callerSection)
3033
+ lines.push(callerSection);
3034
+ if (memberSection)
3035
+ lines.push(memberSection);
3036
+ if (configSection)
3037
+ lines.push(configSection);
3038
+ const finishCompact = (summary) => {
3039
+ lines[summaryLineIdx] = summary;
3040
+ return this.textResult(lines.join('\n'));
3041
+ };
3042
+ const memberFileCount = memberSection
3043
+ ? memberSection.split('\n').filter((l) => l.startsWith('- ')).length
3044
+ : 0;
3045
+ const callerBulletCount = callerSection
3046
+ ? callerSection.split('\n').filter((l) => l.startsWith('- ') && l.includes(' ← ')).length
3047
+ : 0;
3048
+ const inheritanceListed = inheritanceSection
3049
+ ? inheritanceSection.split('\n').filter((l) => l.startsWith('- `')).length > 0
3050
+ : false;
3051
+ const hasAnySection = importResult.section || kitUsageResult.section || domainFileResult.section
3052
+ || apiUsageResult.section || dataSourceResult.section || inheritanceSection
3053
+ || callerSection || memberSection || configSection;
3054
+ if (!hasAnySection)
3055
+ return null;
3056
+ const omitSource = (0, query_utils_1.shouldOmitSourceBodies)({
3057
+ importSiteCount: importResult.siteCount,
3058
+ hasFilteredImports: importInventoryFilter && importResult.siteCount > 0,
3059
+ callerBulletCount,
3060
+ memberFileCount,
3061
+ apiUsageFileCount: apiUsageResult.fileCount,
3062
+ configRendered: !!configSection,
3063
+ kitModuleSurveyRendered: !!kitUsageResult.section,
3064
+ inheritanceListed,
3065
+ domainFileCount: domainFileResult.fileCount,
3066
+ dataSourceEdgeCount: dataSourceResult.edgeCount,
3067
+ }, hasFlowPath, multiAnchor);
3068
+ if (!omitSource)
3069
+ return null;
3070
+ if (configSection)
3071
+ return finishCompact('Config/manifest content above — answer from it directly.');
3072
+ if (kitUsageResult.section) {
3073
+ return finishCompact(`Kit module usage survey — **${kitUsageResult.symbolCount}** imported symbol(s) across the repo.`);
3074
+ }
3075
+ if (domainFileResult.fileCount > 0) {
3076
+ return finishCompact(`Domain file survey — **${domainFileResult.fileCount}** related file(s) listed above. ` +
3077
+ 'This is the exhaustive related-file inventory; no glob/search needed.');
3078
+ }
3079
+ if (apiUsageResult.fileCount > 0) {
3080
+ return finishCompact(`API usage survey — **${apiUsageResult.fileCount}** file(s) referencing the queried symbol(s).`);
3081
+ }
3082
+ if (dataSourceResult.edgeCount > 0) {
3083
+ return finishCompact(`Data-source survey — **${dataSourceResult.edgeCount}** upstream symbol(s) listed above.`);
3084
+ }
3085
+ if (inheritanceListed)
3086
+ return finishCompact('Inheritance survey above lists all direct subtypes found.');
3087
+ if (importResult.compactListing) {
3088
+ return finishCompact(`Listed **${importResult.siteCount}** import site(s) for the queried symbol(s).`);
3089
+ }
3090
+ if (callerBulletCount >= 1) {
3091
+ return finishCompact(`Caller inventory lists **${callerBulletCount}** call site(s) — answer from the section above.`);
3092
+ }
3093
+ if (memberFileCount >= 2) {
3094
+ return finishCompact(`Member/pattern usage in **${memberFileCount}** file(s) — answer from the inventory above.`);
3095
+ }
3096
+ return finishCompact('Inventory sections above are complete for this query.');
3097
+ }
3098
+ /**
3099
+ * Render a compact symbol-bounded slice of one file (lightweight mechanism path).
3100
+ */
3101
+ renderLightMechanismSource(projectRoot, filePath, nodes, maxChars) {
3102
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, filePath);
3103
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
3104
+ return null;
3105
+ const relevant = nodes.filter((n) => n.startLine > 0 && n.kind !== 'import' && n.kind !== 'export');
3106
+ if (relevant.length === 0)
3107
+ return null;
3108
+ let content;
3109
+ try {
3110
+ content = (0, fs_1.readFileSync)(absPath, 'utf-8');
3111
+ }
3112
+ catch {
3113
+ return null;
3114
+ }
3115
+ const fileLines = content.split('\n');
3116
+ const start = Math.max(1, Math.min(...relevant.map((n) => n.startLine)) - 8);
3117
+ const end = Math.min(fileLines.length, Math.max(...relevant.map((n) => n.endLine ?? n.startLine)) + 8);
3118
+ const lang = relevant[0]?.language || '';
3119
+ const names = [...new Set(relevant.map((n) => n.name))].slice(0, 4).join(', ');
3120
+ const out = [fileSectionHeader(filePath, names), '', '```' + lang];
3121
+ for (let i = start; i <= end; i++) {
3122
+ out.push(`${i}\t${fileLines[i - 1] ?? ''}`);
3123
+ }
3124
+ out.push('```', '');
3125
+ let text = out.join('\n');
3126
+ if (text.length > maxChars) {
3127
+ text = `${text.slice(0, maxChars)}\n... (section trimmed — another explore with a specific symbol is cheaper than grep/read)\n\`\`\`\n`;
3128
+ }
3129
+ return text;
3130
+ }
3131
+ /**
3132
+ * Lightweight mechanism explore — seed entry symbols + flow spine, skip
3133
+ * findRelevantContext. Fast enough for MCP budget; complete enough to avoid
3134
+ * agent grep/read loops (token savings).
3135
+ */
3136
+ tryLightMechanismExplore(cg, query, projectRoot) {
3137
+ if (!(0, query_utils_1.shouldTryLightMechanismExplore)(query))
3138
+ return null;
3139
+ const STRUCTURE_KINDS = new Set(['class', 'struct', 'interface', 'component', 'method', 'function']);
3140
+ const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
3141
+ const fileNodes = new Map();
3142
+ const seedIds = new Set();
3143
+ const addNode = (n) => {
3144
+ if (isTestPath(n.filePath))
3145
+ return;
3146
+ if (n.kind !== 'import' && !STRUCTURE_KINDS.has(n.kind))
3147
+ return;
3148
+ seedIds.add(n.id);
3149
+ const list = fileNodes.get(n.filePath) ?? [];
3150
+ if (!list.some((x) => x.id === n.id))
3151
+ list.push(n);
3152
+ fileNodes.set(n.filePath, list);
3153
+ };
3154
+ for (const seed of (0, query_utils_1.extractMechanismEntrySeeds)(query)) {
3155
+ if (seed.startsWith('@')) {
3156
+ let hits = [];
3157
+ try {
3158
+ hits = cg.searchNodes(seed, { kinds: ['import'], limit: 12 });
3159
+ }
3160
+ catch {
3161
+ continue;
3162
+ }
3163
+ for (const r of hits)
3164
+ addNode(r.node);
3165
+ continue;
3166
+ }
3167
+ for (const n of cg.getNodesByName(seed)) {
3168
+ if (STRUCTURE_KINDS.has(n.kind))
3169
+ addNode(n);
3170
+ }
3171
+ }
3172
+ if (seedIds.size < 2) {
3173
+ const domainTerms = (0, query_utils_1.extractDomainSearchTerms)(query);
3174
+ const asciiTerms = domainTerms.filter((t) => /^[\x00-\x7F]+$/.test(t));
3175
+ for (const term of asciiTerms.slice(0, 8)) {
3176
+ let hits = [];
3177
+ try {
3178
+ hits = cg.searchNodes(term, {
3179
+ kinds: ['class', 'struct', 'interface', 'function', 'method', 'import'],
3180
+ limit: 16,
3181
+ });
3182
+ }
3183
+ catch {
3184
+ continue;
3185
+ }
3186
+ for (const r of hits) {
3187
+ // English FTS expansions from Chinese concepts need not be *Manager.
3188
+ const nameHit = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(r.node.name);
3189
+ const entryOk = (0, query_utils_1.isImplementationEntrySymbol)(r.node.name, domainTerms)
3190
+ || r.node.kind === 'import'
3191
+ || ((r.node.kind === 'function' || r.node.kind === 'method') && nameHit);
3192
+ if (!entryOk)
3193
+ continue;
3194
+ addNode(r.node);
3195
+ if (seedIds.size >= 10)
3196
+ break;
3197
+ }
3198
+ if (seedIds.size >= 10)
3199
+ break;
3200
+ }
3201
+ }
3202
+ if (seedIds.size === 0)
3203
+ return null;
3204
+ const seeds = (0, query_utils_1.extractMechanismEntrySeeds)(query);
3205
+ const flow = this.buildFlowFromNamedSymbols(cg, `${query} ${seeds.join(' ')}`);
3206
+ const lines = [
3207
+ `**Exploration: ${query}**`,
3208
+ '',
3209
+ `Mechanism anchors: **${seedIds.size}** symbol(s) — lightweight explore (seed + flow spine).`,
3210
+ '',
3211
+ ];
3212
+ const importResult = this.buildImportSitesSection(cg, query, projectRoot);
3213
+ if (importResult.section)
3214
+ lines.push(importResult.section);
3215
+ if (flow.text)
3216
+ lines.push(flow.text);
3217
+ const fileScores = new Map();
3218
+ for (const [fp, nodes] of fileNodes) {
3219
+ let score = 0;
3220
+ for (const n of nodes) {
3221
+ if (flow.pathNodeIds.has(n.id))
3222
+ score += 20;
3223
+ else if (flow.uniqueNamedNodeIds.has(n.id))
3224
+ score += 10;
3225
+ else
3226
+ score += 5;
3227
+ }
3228
+ fileScores.set(fp, score);
3229
+ }
3230
+ const sortedFiles = [...fileScores.entries()]
3231
+ .filter(([, s]) => s > 0)
3232
+ .sort((a, b) => b[1] - a[1])
3233
+ .slice(0, 4);
3234
+ lines.push('**Source Code**', '');
3235
+ lines.push('> Line-numbered source below — treat as already Read. Do not grep/read these files again; ' +
3236
+ 'another `homegraph_explore` with more symbol names is cheaper if you need more.');
3237
+ lines.push('');
3238
+ let totalChars = lines.join('\n').length;
3239
+ let filesRendered = 0;
3240
+ for (const [fp] of sortedFiles) {
3241
+ if (filesRendered >= 4 || totalChars > 14_000)
3242
+ break;
3243
+ const chunk = this.renderLightMechanismSource(projectRoot, fp, fileNodes.get(fp) ?? [], 4000);
3244
+ if (!chunk)
3245
+ continue;
3246
+ lines.push(chunk);
3247
+ totalChars += chunk.length;
3248
+ filesRendered++;
3249
+ }
3250
+ if (filesRendered === 0 && !importResult.section && !flow.text)
3251
+ return null;
3252
+ lines.push('---');
3253
+ lines.push('> **Mechanism explore complete** for anchored symbols — answer from sections above. ' +
3254
+ 'Retry explore with more names before grep/read (grep duplicates token cost).');
3255
+ lines.push('');
3256
+ return this.textResult(lines.join('\n'));
3257
+ }
3258
+ /**
3259
+ * Compact explore for local-symbol behavior questions — skips findRelevantContext
3260
+ * and caps to 1–2 defining files (avoids the ~24K related-file dump).
3261
+ */
3262
+ tryCompactLocalSymbolExplore(cg, query, projectRoot) {
3263
+ // Inventory runs *before* this on the call sites. Do not refuse compact
3264
+ // merely because inventory *intent* matched — empty inventory must fall
3265
+ // through here (bare callbacks like OnSurfaceChangedCB).
3266
+ // Hard rule: 1–3 named anchors → compact for local-detail / bare-id shapes.
3267
+ // Multi-anchor flow bags (routeSave/onSave, aboutToAppear/build, thunk→thunk)
3268
+ // must fall through to full explore so Flow / Dynamic-dispatch / adaptive
3269
+ // sizing still surface — compact trail is not a substitute.
3270
+ const bareId = /^[A-Za-z_][\w]*$/.test(query.trim());
3271
+ if (!(0, query_utils_1.queryAsLocalSymbolDetail)(query) && !bareId && !(0, query_utils_1.queryHasFocusedNamedAnchors)(query)) {
3272
+ return null;
3273
+ }
3274
+ const STRUCTURE_KINDS = new Set([
3275
+ 'class', 'struct', 'interface', 'component', 'method', 'function', 'constant', 'variable', 'property', 'field',
3276
+ ]);
3277
+ const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
3278
+ const names = (0, query_utils_1.extractLocalDetailAnchors)(query).slice(0, 4);
3279
+ if (names.length === 0)
3280
+ return null;
3281
+ // Multi-anchor bags that form a Flow / Dynamic-dispatch section must use
3282
+ // full explore (synth notes, adaptive sizing, boundary announcements).
3283
+ // Compact trail alone loses those sections. Probe using the raw query —
3284
+ // extractLocalDetailAnchors can drop snake_case tokens (handle_save) that
3285
+ // buildFlowFromNamedSymbols still resolves.
3286
+ const rawTokens = query.split(/[\s,()[\]]+/).filter((t) => t.length >= 3 && /^[A-Za-z_][\w]*$/.test(t));
3287
+ if (names.length >= 2 || rawTokens.length >= 2) {
3288
+ try {
3289
+ const flow = this.buildFlowFromNamedSymbols(cg, query);
3290
+ if (flow.text.length > 0)
3291
+ return null;
3292
+ }
3293
+ catch {
3294
+ // Probe is best-effort — stay on compact if flow build fails.
3295
+ }
3296
+ }
3297
+ const fileNodes = new Map();
3298
+ const seedIds = new Set();
3299
+ const addNode = (n) => {
3300
+ if (isTestPath(n.filePath) && !(0, query_utils_1.queryAsTestOnlyInterpretation)(query))
3301
+ return;
3302
+ if (!STRUCTURE_KINDS.has(n.kind))
3303
+ return;
3304
+ seedIds.add(n.id);
3305
+ const list = fileNodes.get(n.filePath) ?? [];
3306
+ if (!list.some((x) => x.id === n.id))
3307
+ list.push(n);
3308
+ fileNodes.set(n.filePath, list);
3309
+ };
3310
+ for (const seed of names) {
3311
+ for (const n of cg.getNodesByName(seed)) {
3312
+ addNode(n);
3313
+ if (seedIds.size >= 12)
3314
+ break;
3315
+ }
3316
+ if (seedIds.size >= 12)
3317
+ break;
3318
+ }
3319
+ if (seedIds.size === 0)
3320
+ return null;
3321
+ const pathAffinity = (seedPath, otherPath) => {
3322
+ const a = seedPath.replace(/\\/g, '/').split('/');
3323
+ const b = otherPath.replace(/\\/g, '/').split('/');
3324
+ // Same package only (e.g. feature/foldeffect/…). Do NOT fallback to
3325
+ // top-level alone — that keeps cross-feature logError homonyms.
3326
+ return a.length >= 2 && b.length >= 2 && a[0] === b[0] && a[1] === b[1];
3327
+ };
3328
+ const seedPaths = [...seedIds].map((id) => {
3329
+ try {
3330
+ return cg.getNode(id)?.filePath;
3331
+ }
3332
+ catch {
3333
+ return undefined;
3334
+ }
3335
+ }).filter((p) => !!p);
3336
+ const nearSeed = (fp) => seedPaths.some((sp) => sp === fp || pathAffinity(sp, fp));
3337
+ // Neighbor policy (agents often search("Foo") alone — do not dump 11 callees):
3338
+ // - bareId / caller-bridge: callers of PRIMARY name only, no callee fan-out
3339
+ // - UI action / pinpoint local-detail: callers + callees
3340
+ const bridge = (0, query_utils_1.queryNeedsCoNamedUseBridge)(query);
3341
+ const callersOnly = bareId
3342
+ || bridge
3343
+ || ((0, query_utils_1.shouldBuildCallerInventory)(query) && !(0, query_utils_1.queryAsNamedComponentAction)(query));
3344
+ const primaryName = names[0];
3345
+ const expandIds = [...seedIds].filter((id) => {
3346
+ try {
3347
+ return cg.getNode(id)?.name === primaryName;
3348
+ }
3349
+ catch {
3350
+ return false;
3351
+ }
3352
+ });
3353
+ // Pull one-hop neighbors into the focus set so the answer is graph-complete
3354
+ // without dumping an import inventory for isExpired/onClick.
3355
+ const neighborIds = new Set();
3356
+ for (const id of expandIds) {
3357
+ let seedPath = '';
3358
+ try {
3359
+ seedPath = cg.getNode(id)?.filePath ?? '';
3360
+ }
3361
+ catch { /* */ }
3362
+ try {
3363
+ for (const { node: c } of cg.getCallers(id).slice(0, callersOnly ? 8 : 10)) {
3364
+ if (!nearSeed(c.filePath) && seedPath && !pathAffinity(seedPath, c.filePath))
3365
+ continue;
3366
+ if (!isTestPath(c.filePath) || (0, query_utils_1.queryAsTestOnlyInterpretation)(query)) {
3367
+ neighborIds.add(c.id);
3368
+ // List callers in the trail, but don't pull their whole files into Source
3369
+ // for bare-name / caller-bridge shapes (that ballooned search("SortWidgets")).
3370
+ if (!callersOnly)
3371
+ addNode(c);
3372
+ }
3373
+ }
3374
+ if (!callersOnly) {
3375
+ for (const { node: c } of cg.getCallees(id).slice(0, 10)) {
3376
+ if (!nearSeed(c.filePath) && seedPath && !pathAffinity(seedPath, c.filePath))
3377
+ continue;
3378
+ if (!isTestPath(c.filePath) || (0, query_utils_1.queryAsTestOnlyInterpretation)(query)) {
3379
+ neighborIds.add(c.id);
3380
+ addNode(c);
3381
+ }
3382
+ }
3383
+ }
3384
+ }
3385
+ catch {
3386
+ // skip edge trail for this seed
3387
+ }
3388
+ }
3389
+ const lines = [
3390
+ `**Exploration: ${query}**`,
3391
+ '',
3392
+ `Local-symbol focus: **${seedIds.size}** seed(s)` +
3393
+ (neighborIds.size > 0 ? `, **${neighborIds.size}** caller/callee neighbor(s)` : '') +
3394
+ ' — compact explore (definition + edge trail, no related-file dump).',
3395
+ '',
3396
+ ];
3397
+ // Blast radius for entry seeds (locations only) — same signal full explore
3398
+ // always-on section gives, so bare-name edits know what to update/verify.
3399
+ {
3400
+ const nodes = new Map();
3401
+ for (const id of seedIds) {
3402
+ try {
3403
+ const n = cg.getNode(id);
3404
+ if (n)
3405
+ nodes.set(id, n);
3406
+ }
3407
+ catch { /* skip */ }
3408
+ }
3409
+ const blast = this.buildBlastRadiusSection(cg, {
3410
+ nodes,
3411
+ edges: [],
3412
+ roots: [...seedIds],
3413
+ });
3414
+ if (blast)
3415
+ lines.push(blast);
3416
+ }
3417
+ // Always list a short edge trail — property→UI and button→action questions
3418
+ // need callers/callees; agents otherwise re-grep the same names.
3419
+ {
3420
+ const trail = ['**Call / use trail**', ''];
3421
+ let trailBullets = 0;
3422
+ const trailIds = (callersOnly ? expandIds : [...seedIds]).slice(0, 6);
3423
+ for (const id of trailIds) {
3424
+ let seedNode;
3425
+ try {
3426
+ seedNode = cg.getNode(id);
3427
+ }
3428
+ catch {
3429
+ continue;
3430
+ }
3431
+ if (!seedNode)
3432
+ continue;
3433
+ let callers = [];
3434
+ let callees = [];
3435
+ try {
3436
+ callers = cg.getCallers(id).slice(0, 8);
3437
+ }
3438
+ catch { /* */ }
3439
+ if (!callersOnly) {
3440
+ try {
3441
+ callees = cg.getCallees(id).slice(0, 8);
3442
+ }
3443
+ catch { /* */ }
3444
+ }
3445
+ callers = callers.filter((c) => nearSeed(c.node.filePath) || pathAffinity(seedNode.filePath, c.node.filePath));
3446
+ callees = callees.filter((c) => nearSeed(c.node.filePath) || pathAffinity(seedNode.filePath, c.node.filePath));
3447
+ if (callers.length === 0 && callees.length === 0)
3448
+ continue;
3449
+ trail.push(`- \`${seedNode.name}\` (${seedNode.kind}) — ${seedNode.filePath}:${seedNode.startLine}`);
3450
+ for (const c of callers) {
3451
+ const loc = c.node.startLine ? `:${c.node.startLine}` : '';
3452
+ trail.push(` ← used by \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
3453
+ trailBullets++;
3454
+ }
3455
+ for (const c of callees) {
3456
+ const loc = c.node.startLine ? `:${c.node.startLine}` : '';
3457
+ trail.push(` → calls \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
3458
+ trailBullets++;
3459
+ }
3460
+ }
3461
+ if (trailBullets > 0) {
3462
+ trail.push('');
3463
+ trail.push('> Prefer answering from this trail + Source below. Do not grep/search the same symbols again.');
3464
+ trail.push('');
3465
+ lines.push(...trail);
3466
+ }
3467
+ }
3468
+ // When the graph has no call edges (common for static Type.member reads),
3469
+ // fall back to a bounded indexed-file text scan for `name(` usages so the
3470
+ // agent does not pay a Grep round-trip for the same anchors.
3471
+ const textUsageHits = [];
3472
+ // Also scan when the agent asks upstream/registration — callee neighbors
3473
+ // alone do not show Export / OH_NativeXComponent wiring sites.
3474
+ const needUsageScan = neighborIds.size === 0
3475
+ || /上下游|上游|下游|注册|挂到|callback|upstream|downstream/i.test(query)
3476
+ || names.every((n) => /(?:CB|Callback)$/i.test(n));
3477
+ if (needUsageScan) {
3478
+ // Scan ALL focused anchors (incl. PascalCase callables) — previously only
3479
+ // lowercase names were scanned, so OnSurfaceChangedCB registration sites
3480
+ // never surfaced and agents fell through to callers/grep/read.
3481
+ const typeSeeds = (0, query_utils_1.extractTypeNamesFromQuery)(query);
3482
+ const scanNames = names.slice(0, 3);
3483
+ if (scanNames.length > 0) {
3484
+ try {
3485
+ const defLineKeys = new Set();
3486
+ for (const nodes of fileNodes.values()) {
3487
+ for (const n of nodes) {
3488
+ if (scanNames.includes(n.name))
3489
+ defLineKeys.add(`${n.filePath}:${n.startLine}`);
3490
+ }
3491
+ }
3492
+ const defDirs = new Set([...fileNodes.keys()].map((fp) => fp.split('/').slice(0, 4).join('/')));
3493
+ const files = cg.getFiles()
3494
+ .map((f) => f.path)
3495
+ .filter((fp) => {
3496
+ if (isTestPath(fp))
3497
+ return false;
3498
+ if (defDirs.size === 0)
3499
+ return true;
3500
+ return [...defDirs].some((d) => fp === d || fp.startsWith(`${d}/`));
3501
+ })
3502
+ .slice(0, 120);
3503
+ for (const fp of files) {
3504
+ if (textUsageHits.length >= 12)
3505
+ break;
3506
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
3507
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
3508
+ continue;
3509
+ let content;
3510
+ try {
3511
+ content = (0, fs_1.readFileSync)(absPath, 'utf-8');
3512
+ }
3513
+ catch {
3514
+ continue;
3515
+ }
3516
+ // Prefer files that also mention a co-named type when both present
3517
+ // (skip for single-anchor upstream/registration scans).
3518
+ if (typeSeeds.length > 1
3519
+ && names.length > 1
3520
+ && !typeSeeds.some((t) => content.includes(t))) {
3521
+ continue;
3522
+ }
3523
+ const fileLines = content.split('\n');
3524
+ for (const sym of scanNames) {
3525
+ const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3526
+ // Match call OR bare identifier use (callback registration tables).
3527
+ const re = new RegExp(`\\b${escaped}\\b`);
3528
+ for (let i = 0; i < fileLines.length; i++) {
3529
+ const lineText = fileLines[i] ?? '';
3530
+ if (!re.test(lineText))
3531
+ continue;
3532
+ if (defLineKeys.has(`${fp}:${i + 1}`))
3533
+ continue;
3534
+ textUsageHits.push({ filePath: fp, line: i + 1, text: lineText.trim(), symbol: sym });
3535
+ if (!fileNodes.has(fp))
3536
+ fileNodes.set(fp, []);
3537
+ if (textUsageHits.length >= 12)
3538
+ break;
3539
+ }
3540
+ if (textUsageHits.length >= 12)
3541
+ break;
3542
+ }
3543
+ }
3544
+ }
3545
+ catch {
3546
+ // text scan is best-effort
3547
+ }
3548
+ }
3549
+ if (textUsageHits.length > 0) {
3550
+ lines.push('**Text usage sites** (no call-edge indexed for these names — scanned in-repo)', '');
3551
+ for (const h of textUsageHits.slice(0, 10)) {
3552
+ lines.push(`- \`${h.symbol}\` — ${h.filePath}:${h.line} \`${h.text.slice(0, 120)}\``);
3553
+ }
3554
+ lines.push('');
3555
+ lines.push('> These lines are the in-repo use sites. Answer from them + Source; do not grep the same names again.');
3556
+ lines.push('');
3557
+ }
3558
+ }
3559
+ // Also surface #include / import lines that make a co-named type visible
3560
+ // near the primary symbol's use sites (IntGrid ↔ SortWidgets shapes).
3561
+ if (names.length >= 2
3562
+ && /include|import|visible|定义可见|可见|头文件|definition/i.test(query)) {
3563
+ const includeHits = [];
3564
+ const primaryFiles = new Set([
3565
+ ...fileNodes.keys(),
3566
+ ...textUsageHits.map((h) => h.filePath),
3567
+ ]);
3568
+ // Files that call / define earlier anchors.
3569
+ for (const id of seedIds) {
3570
+ try {
3571
+ for (const { node: c } of cg.getCallers(id).slice(0, 8)) {
3572
+ primaryFiles.add(c.filePath);
3573
+ }
3574
+ }
3575
+ catch { /* */ }
3576
+ }
3577
+ const secondary = names.slice(1);
3578
+ for (const fp of [...primaryFiles].slice(0, 12)) {
3579
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
3580
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
3581
+ continue;
3582
+ let content;
3583
+ try {
3584
+ content = (0, fs_1.readFileSync)(absPath, 'utf-8');
3585
+ }
3586
+ catch {
3587
+ continue;
3588
+ }
3589
+ const fileLines = content.split('\n');
3590
+ for (let i = 0; i < Math.min(fileLines.length, 120); i++) {
3591
+ const lineText = fileLines[i] ?? '';
3592
+ if (!/^\s*(?:#\s*include|import\s)/.test(lineText))
3593
+ continue;
3594
+ if (!secondary.some((s) => lineText.includes(s)))
3595
+ continue;
3596
+ includeHits.push(`- \`${fp}:${i + 1}\` \`${lineText.trim().slice(0, 140)}\``);
3597
+ if (includeHits.length >= 8)
3598
+ break;
3599
+ }
3600
+ if (includeHits.length >= 8)
3601
+ break;
3602
+ }
3603
+ if (includeHits.length > 0) {
3604
+ lines.push('**Include / import visibility** (how co-named types become visible at use sites)', '');
3605
+ lines.push(...includeHits);
3606
+ lines.push('');
3607
+ lines.push('> Answer the visibility question from these include/import lines — do not re-read headers.');
3608
+ lines.push('');
3609
+ }
3610
+ }
3611
+ const callerSection = (0, query_utils_1.shouldBuildCallerInventory)(query)
3612
+ ? this.buildCallerListingSection(cg, query)
3613
+ : '';
3614
+ if (callerSection)
3615
+ lines.push(callerSection);
3616
+ // Prefer files whose nodes exactly match query names; then neighbor / usage files.
3617
+ // Primary named symbol (first anchor) always ranks first — secondary types like
3618
+ // IntGrid used to drown SortWidgets' defining .cpp via higher exact-count on the header.
3619
+ const nameSet = new Set(names);
3620
+ const usageFiles = new Set(textUsageHits.map((h) => h.filePath));
3621
+ const maxFiles = ((0, query_utils_1.queryAsNamedComponentAction)(query) || (0, query_utils_1.queryHasNamedMemberFocus)(query) || usageFiles.size > 0 || bridge)
3622
+ ? 4
3623
+ : 2;
3624
+ const ranked = [...fileNodes.entries()]
3625
+ .map(([fp, nodes]) => {
3626
+ const exact = nodes.filter((n) => nameSet.has(n.name)).length;
3627
+ const primaryHit = nodes.some((n) => n.name === primaryName) ? 100 : 0;
3628
+ const neighborHit = nodes.filter((n) => neighborIds.has(n.id)).length;
3629
+ const usageHit = usageFiles.has(fp) ? 15 : 0;
3630
+ return {
3631
+ fp,
3632
+ nodes,
3633
+ exact,
3634
+ score: primaryHit + exact * 20 + neighborHit * 5 + usageHit + nodes.length,
3635
+ };
3636
+ })
3637
+ .sort((a, b) => b.score - a.score || a.fp.localeCompare(b.fp))
3638
+ .slice(0, maxFiles);
3639
+ lines.push('**Source Code**', '');
3640
+ lines.push('> Line-numbered source — treat as already Read. Answer from it; do not re-explore/search/grep the same symbols.');
3641
+ lines.push('');
3642
+ let totalChars = lines.join('\n').length;
3643
+ const maxTotal = 9000;
3644
+ const maxPerFile = 4000;
3645
+ let rendered = 0;
3646
+ for (const { fp, nodes } of ranked) {
3647
+ if (rendered >= maxFiles || totalChars > maxTotal)
3648
+ break;
3649
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
3650
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
3651
+ continue;
3652
+ let fileContent;
3653
+ try {
3654
+ fileContent = (0, fs_1.readFileSync)(absPath, 'utf-8');
3655
+ }
3656
+ catch {
3657
+ continue;
3658
+ }
3659
+ const fileLines = fileContent.split('\n');
3660
+ const focusNodes = nodes
3661
+ .filter((n) => nameSet.has(n.name) || STRUCTURE_KINDS.has(n.kind))
3662
+ .sort((a, b) => a.startLine - b.startLine);
3663
+ // Prefer contiguous windows around named symbols; usage-only files
3664
+ // window around the first text-hit line.
3665
+ let start = 1;
3666
+ let end = Math.min(fileLines.length, 120);
3667
+ const usageLine = textUsageHits.find((h) => h.filePath === fp)?.line;
3668
+ if (focusNodes.length > 0) {
3669
+ const named = focusNodes.filter((n) => nameSet.has(n.name));
3670
+ const use = named.length > 0 ? named : focusNodes.slice(0, 3);
3671
+ start = Math.max(1, Math.min(...use.map((n) => n.startLine)) - 2);
3672
+ end = Math.min(fileLines.length, Math.max(...use.map((n) => n.endLine)) + 2);
3673
+ // Keep window bounded
3674
+ if ((end - start + 1) * 40 > maxPerFile) {
3675
+ end = Math.min(fileLines.length, start + Math.floor(maxPerFile / 40) - 1);
3676
+ }
3677
+ }
3678
+ else if (usageLine) {
3679
+ start = Math.max(1, usageLine - 15);
3680
+ end = Math.min(fileLines.length, usageLine + 25);
3681
+ }
3682
+ const header = fileSectionHeader(fp, focusNodes.map((n) => `${n.name}(${n.kind})`).slice(0, 6).join(', '));
3683
+ const withLineNumbers = exploreLineNumbersEnabled();
3684
+ const bodyLines = [header, '```' + (nodes[0]?.language || ''), ''];
3685
+ for (let i = start; i <= end; i++) {
3686
+ const code = fileLines[i - 1] ?? '';
3687
+ bodyLines.push(withLineNumbers ? `${i}\t${code}` : code);
3688
+ }
3689
+ bodyLines.push('```', '');
3690
+ let chunk = bodyLines.join('\n');
3691
+ if (chunk.length > maxPerFile) {
3692
+ chunk = `${chunk.slice(0, maxPerFile)}\n... (trimmed)\n\`\`\`\n`;
3693
+ }
3694
+ if (totalChars + chunk.length > maxTotal && rendered > 0)
3695
+ break;
3696
+ lines.push(chunk);
3697
+ totalChars += chunk.length;
3698
+ rendered++;
3699
+ }
3700
+ if (rendered === 0)
3701
+ return null;
3702
+ lines.push('---');
3703
+ lines.push('> **Compact local explore complete** — answer from the definition(s) above. ' +
3704
+ 'Do not call `homegraph_search` or another full explore for the same symbol.');
3705
+ lines.push('');
3706
+ return this.textResult(lines.join('\n'));
3707
+ }
3708
+ /**
3709
+ * @kit module capability survey — repo import/usage only (SDK defs are not indexed).
3710
+ */
3711
+ buildKitModuleUsageSection(cg, query, projectRoot) {
3712
+ const kitTerms = (0, query_utils_1.extractKitModuleNamesFromQuery)(query);
3713
+ const submodules = (0, query_utils_1.extractKitSubmoduleNamesFromQuery)(query);
3714
+ if (kitTerms.length === 0)
3715
+ return { section: '', symbolCount: 0 };
3716
+ const kitSearchTerms = (0, query_utils_1.extractImportSearchTerms)(query);
3717
+ const seen = new Set();
3718
+ const imports = [];
3719
+ const parseImportedSymbols = (lineText) => {
3720
+ const syms = new Set();
3721
+ const brace = lineText.match(/import\s*\{([^}]+)\}/);
3722
+ if (brace?.[1]) {
3723
+ for (const part of brace[1].split(',')) {
3724
+ const tok = part.trim().split(/\s+as\s+/i)[0]?.trim();
3725
+ if (tok)
3726
+ syms.add(tok);
3727
+ }
3728
+ }
3729
+ const def = lineText.match(/import\s+(\w+)\s+from\b/);
3730
+ if (def?.[1])
3731
+ syms.add(def[1]);
3732
+ const ns = lineText.match(/import\s+\*\s+as\s+(\w+)/);
3733
+ if (ns?.[1])
3734
+ syms.add(ns[1]);
3735
+ return [...syms];
3736
+ };
3737
+ const matchesSubmodule = (lineText, symbols) => {
3738
+ if (submodules.length === 0)
3739
+ return true;
3740
+ const lc = lineText.toLowerCase();
3741
+ return submodules.some((sm) => {
3742
+ const s = sm.toLowerCase();
3743
+ return symbols.some((sym) => sym.toLowerCase() === s || sym.toLowerCase().startsWith(`${s}.`))
3744
+ || lc.includes(`.${s}`) || lc.includes(`'${s}'`) || lc.includes(`"${s}"`);
3745
+ });
3746
+ };
3747
+ const resolveImportLine = (node) => (0, query_utils_1.resolveImportLineFromNode)(node, projectRoot);
3748
+ for (const term of kitSearchTerms) {
3749
+ const termLc = term.toLowerCase().replace(/^@kit\./, '');
3750
+ let hits = [];
3751
+ try {
3752
+ hits = cg.searchNodes(term, { kinds: ['import'], limit: 80 });
3753
+ }
3754
+ catch {
3755
+ continue;
3756
+ }
3757
+ for (const r of hits) {
3758
+ const lineText = resolveImportLine(r.node);
3759
+ if (!lineText.toLowerCase().includes(termLc) && !lineText.toLowerCase().includes('@kit.'))
3760
+ continue;
3761
+ const symbols = parseImportedSymbols(lineText);
3762
+ if (!matchesSubmodule(lineText, symbols))
3763
+ continue;
3764
+ const key = `${r.node.filePath}:${r.node.startLine}`;
3765
+ if (seen.has(key))
3766
+ continue;
3767
+ seen.add(key);
3768
+ imports.push({
3769
+ file: r.node.filePath,
3770
+ line: r.node.startLine,
3771
+ lineText,
3772
+ symbols,
3773
+ });
3774
+ }
3775
+ }
3776
+ if (imports.length === 0) {
3777
+ const kitLabel = kitTerms.map((k) => `@kit.${k}`).join(', ');
3778
+ const sub = submodules.length > 0 ? ` (${submodules.join(', ')} submodule)` : '';
3779
+ return {
3780
+ section: [
3781
+ '**Kit module usage (this repo)**',
3782
+ '',
3783
+ `No imports from ${kitLabel}${sub} were found in the indexed codebase.`,
3784
+ '',
3785
+ `> @kit module API definitions live in the HarmonyOS SDK, not this repository. Answer from repo imports/usages only.`,
3786
+ '',
3787
+ ].join('\n'),
3788
+ symbolCount: 0,
3789
+ };
3790
+ }
3791
+ const symbolFiles = new Map();
3792
+ for (const imp of imports) {
3793
+ for (const sym of imp.symbols) {
3794
+ const set = symbolFiles.get(sym) ?? new Set();
3795
+ set.add(imp.file);
3796
+ symbolFiles.set(sym, set);
3797
+ }
3798
+ }
3799
+ const lines = [
3800
+ '**Kit module usage (this repo)**',
3801
+ '',
3802
+ `> @kit module API definitions live in the HarmonyOS SDK, not this repository. Below: how **this project** imports and uses them.`,
3803
+ '',
3804
+ `Imports from ${kitTerms.map((k) => `\`@kit.${k}\``).join(', ')} (${imports.length} site(s)):`,
3805
+ '',
3806
+ ];
3807
+ for (const imp of imports.slice(0, 35)) {
3808
+ const symStr = imp.symbols.length > 0 ? imp.symbols.join(', ') : '(namespace)';
3809
+ lines.push(`- \`${imp.file}:${imp.line}\` — \`${imp.lineText}\` (${symStr})`);
3810
+ }
3811
+ if (imports.length > 35)
3812
+ lines.push(`- … and ${imports.length - 35} more import site(s)`);
3813
+ if (symbolFiles.size > 0) {
3814
+ lines.push('');
3815
+ lines.push('**Symbols imported (unique):**');
3816
+ for (const [sym, files] of [...symbolFiles.entries()].sort((a, b) => a[0].localeCompare(b[0])).slice(0, 40)) {
3817
+ const fileList = [...files].slice(0, 5).map((f) => `\`${f}\``).join(', ');
3818
+ const more = files.size > 5 ? ` +${files.size - 5} files` : '';
3819
+ lines.push(`- \`${sym}\` — imported in ${fileList}${more}`);
3820
+ }
3821
+ }
3822
+ lines.push('');
3823
+ lines.push('> Kit usage survey complete — answer from this section; do not search external SDK docs or grep the repo again.');
3824
+ lines.push('');
3825
+ return { section: lines.join('\n'), symbolCount: symbolFiles.size };
3826
+ }
3827
+ /** Disambiguate homonymous types (Configuration, Rectangle) when several defs exist. */
3828
+ buildHomonymDefinitionsSection(cg, query) {
3829
+ const typeNames = (0, query_utils_1.extractTypeNamesFromQuery)(query);
3830
+ const queryFiles = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
3831
+ const lines = [];
3832
+ for (const name of typeNames.slice(0, 4)) {
3833
+ const defs = cg.getNodesByName(name).filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'interface' || n.kind === 'type_alias')
3834
+ && !(0, query_utils_1.isTestFile)(n.filePath));
3835
+ if (defs.length <= 1)
3836
+ continue;
3837
+ lines.push(`**${name}** — ${defs.length} distinct definitions (pin with file basename in a follow-up explore):`);
3838
+ for (const d of defs.slice(0, 8)) {
3839
+ const pin = (0, query_utils_1.fileMatchesQueryBasename)(d.filePath, queryFiles) ? ' ← matches query file' : '';
3840
+ lines.push(`- \`${d.qualifiedName || d.name}\` (\`${d.filePath}:${d.startLine}\`)${pin}`);
3841
+ }
3842
+ if (defs.length > 8)
3843
+ lines.push(`- … and ${defs.length - 8} more`);
3844
+ lines.push('');
3845
+ }
3846
+ if (lines.length === 0)
3847
+ return '';
3848
+ return lines.join('\n');
3849
+ }
3850
+ /** Direct subclasses of a named type — paths only. */
3851
+ buildInheritanceSurveySection(cg, query) {
3852
+ if (!(0, query_utils_1.shouldBuildInheritanceSurvey)(query))
3853
+ return '';
3854
+ const rel = (p) => p.replace(/\\/g, '/');
3855
+ const lines = ['**Inheritance survey**', ''];
3856
+ let listed = 0;
3857
+ for (const typeName of (0, query_utils_1.extractTypeNamesFromQuery)(query).slice(0, 2)) {
3858
+ const bases = cg.getNodesByName(typeName).filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'interface') && !(0, query_utils_1.isTestFile)(n.filePath));
3859
+ for (const base of bases.slice(0, 2)) {
3860
+ const subs = [];
3861
+ for (const e of cg.getIncomingEdges(base.id)) {
3862
+ if (e.kind !== 'extends' && e.kind !== 'implements')
3863
+ continue;
3864
+ try {
3865
+ const child = cg.getNode(e.source);
3866
+ if (child && !(0, query_utils_1.isTestFile)(child.filePath))
3867
+ subs.push(child);
3868
+ }
3869
+ catch { /* skip */ }
3870
+ }
3871
+ if (subs.length === 0)
3872
+ continue;
3873
+ lines.push(`### Subtypes of \`${base.name}\` (\`${rel(base.filePath)}\`)`);
3874
+ for (const s of subs.slice(0, 30)) {
3875
+ lines.push(`- \`${s.name}\` (\`${rel(s.filePath)}:${s.startLine}\`)`);
3876
+ listed++;
3877
+ }
3878
+ if (subs.length > 30)
3879
+ lines.push(`- … and ${subs.length - 30} more`);
3880
+ lines.push('');
3881
+ }
3882
+ }
3883
+ if (listed === 0)
3884
+ return '';
3885
+ lines.push('> Inheritance survey complete — answer from this list; no read/grep needed for the subtype set.');
3886
+ lines.push('');
3887
+ return lines.join('\n');
3888
+ }
3889
+ /**
3890
+ * Files/lines using a member or literal pattern (.drawModifier, .width('100%')).
3891
+ */
3892
+ buildMemberSurveySection(cg, query, projectRoot) {
3893
+ const members = (0, query_utils_1.extractMemberAccessFromQuery)(query);
3894
+ const patterns = [];
3895
+ for (const ma of members) {
3896
+ if (ma.dotted.startsWith('.'))
3897
+ patterns.push(ma.dotted);
3898
+ else
3899
+ patterns.push(ma.dotted);
3900
+ }
3901
+ // Literal chains in quotes: .width('100%')
3902
+ for (const m of query.matchAll(/(\.[a-zA-Z_][\w]*\s*\([^)]*\))/g)) {
3903
+ patterns.push(m[1].replace(/\s+/g, ''));
3904
+ }
3905
+ if (patterns.length === 0)
3906
+ return '';
3907
+ const rel = (p) => p.replace(/\\/g, '/');
3908
+ const hits = new Map();
3909
+ const addHit = (file, line) => {
3910
+ const arr = hits.get(file) ?? [];
3911
+ if (!arr.includes(line))
3912
+ arr.push(line);
3913
+ hits.set(file, arr);
3914
+ };
3915
+ for (const pat of patterns) {
3916
+ const bare = pat.replace(/^\./, '');
3917
+ let nodes = [];
3918
+ try {
3919
+ nodes = cg.searchNodes(bare, { limit: 30 });
3920
+ }
3921
+ catch { /* skip */ }
3922
+ for (const r of nodes) {
3923
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
3924
+ continue;
3925
+ addHit(rel(r.node.filePath), r.node.startLine);
3926
+ }
3927
+ }
3928
+ // Scan top FTS files for literal pattern in source
3929
+ for (const pat of patterns.slice(0, 3)) {
3930
+ const literal = pat.startsWith('.') ? pat : `.${pat}`;
3931
+ const re = new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
3932
+ let files = [];
3933
+ try {
3934
+ files = cg.searchNodes(patterns[0].replace(/^\./, ''), { limit: 25 });
3935
+ }
3936
+ catch {
3937
+ continue;
3938
+ }
3939
+ for (const r of files) {
3940
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
3941
+ continue;
3942
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
3943
+ if (!abs)
3944
+ continue;
3945
+ let content;
3946
+ try {
3947
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
3948
+ }
3949
+ catch {
3950
+ continue;
3951
+ }
3952
+ const fileLines = content.split('\n');
3953
+ for (let i = 0; i < fileLines.length; i++) {
3954
+ if (re.test(fileLines[i] ?? ''))
3955
+ addHit(rel(r.node.filePath), i + 1);
3956
+ }
3957
+ }
3958
+ }
3959
+ if (hits.size === 0)
3960
+ return '';
3961
+ const lines = ['**Member / pattern usage**', ''];
3962
+ let fileCount = 0;
3963
+ for (const [fp, lineNos] of [...hits.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
3964
+ if (fileCount >= 40)
3965
+ break;
3966
+ const sorted = lineNos.sort((a, b) => a - b).slice(0, 6);
3967
+ const lineStr = sorted.join(', ');
3968
+ const more = lineNos.length > 6 ? ` +${lineNos.length - 6} lines` : '';
3969
+ lines.push(`- \`${fp}\` — lines ${lineStr}${more}`);
3970
+ fileCount++;
3971
+ }
3972
+ if (hits.size > 40)
3973
+ lines.push(`- … and ${hits.size - 40} more file(s)`);
3974
+ lines.push('');
3975
+ lines.push('> Pattern survey complete — answer from this list; run another explore naming missing symbols if needed.');
3976
+ lines.push('');
3977
+ return lines.join('\n');
3978
+ }
3979
+ /**
3980
+ * API call-site survey — where a named API/symbol (statfs, napi_*) appears in repo source.
3981
+ */
3982
+ buildApiUsageSection(cg, query, projectRoot) {
3983
+ // Include PascalCase SDK modules (Telephony) — deps-only missed them.
3984
+ const symbols = (0, query_utils_1.extractApiUsageTokens)(query).slice(0, 6);
3985
+ if (symbols.length === 0)
3986
+ return { section: '', fileCount: 0 };
3987
+ const rel = (p) => p.replace(/\\/g, '/');
3988
+ const hits = new Map();
3989
+ const addHit = (file, line) => {
3990
+ if ((0, query_utils_1.isTestFile)(file))
3991
+ return;
3992
+ const fp = rel(file);
3993
+ const arr = hits.get(fp) ?? [];
3994
+ if (!arr.includes(line))
3995
+ arr.push(line);
3996
+ hits.set(fp, arr);
3997
+ };
3998
+ for (const sym of symbols.slice(0, 4)) {
3999
+ const symLc = sym.toLowerCase();
4000
+ let nodes = [];
4001
+ try {
4002
+ nodes = cg.searchNodes(sym, { limit: 40 });
4003
+ }
4004
+ catch { /* skip */ }
4005
+ for (const r of nodes) {
4006
+ const n = r.node;
4007
+ if (n.name.toLowerCase() === symLc || (n.signature || '').toLowerCase().includes(symLc)) {
4008
+ addHit(n.filePath, n.startLine);
4009
+ }
4010
+ }
4011
+ const wordRe = new RegExp(`\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
4012
+ let scanHits = [];
4013
+ try {
4014
+ scanHits = cg.searchNodes(sym, { limit: 30 });
4015
+ }
4016
+ catch {
4017
+ continue;
4018
+ }
4019
+ const seenFiles = new Set();
4020
+ for (const r of scanHits) {
4021
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
2283
4022
  continue;
2284
- let impl = 0;
4023
+ const fp = rel(r.node.filePath);
4024
+ if (seenFiles.has(fp))
4025
+ continue;
4026
+ seenFiles.add(fp);
4027
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
4028
+ if (!abs)
4029
+ continue;
4030
+ let content;
2285
4031
  try {
2286
- impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length;
4032
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
2287
4033
  }
2288
- catch { /* leave 0 — gated out below */ }
2289
- if (impl < MIN_IMPL)
4034
+ catch {
2290
4035
  continue;
2291
- if (!best || impl > best.impl)
2292
- best = { node, impl, targets };
4036
+ }
4037
+ const fileLines = content.split('\n');
4038
+ for (let i = 0; i < fileLines.length; i++) {
4039
+ if (wordRe.test(fileLines[i] ?? ''))
4040
+ addHit(fp, i + 1);
4041
+ }
2293
4042
  }
2294
- if (!best || seenSuper.has(best.node.id))
2295
- continue;
2296
- seenSuper.add(best.node.id);
2297
- const namedNames = new Set([...named.values()].map((n) => n.name));
2298
- const eg = best.targets.slice(0, 4).map((m) => {
2299
- const cont = containerOf(m);
2300
- const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
2301
- const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
2302
- return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
2303
- });
2304
- const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
2305
- notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
2306
4043
  }
2307
- if (notes.length === 0)
2308
- return '';
2309
- return [
2310
- '**Interface dispatch (a named method has many implementations)**',
2311
- '',
2312
- ...notes,
4044
+ if (hits.size === 0)
4045
+ return { section: '', fileCount: 0 };
4046
+ const lines = [
4047
+ '**API usage sites**',
2313
4048
  '',
2314
- '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run homegraph_explore on a listed target.',
4049
+ `> Files referencing \`${symbols.join('`, `')}\` in this repo (${hits.size} file(s)). Answer from this list; source bodies omitted.`,
2315
4050
  '',
2316
- ].join('\n');
4051
+ ];
4052
+ let shown = 0;
4053
+ for (const [fp, lineNos] of [...hits.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
4054
+ if (shown >= 45)
4055
+ break;
4056
+ const sortedLines = [...lineNos].sort((a, b) => a - b).slice(0, 6);
4057
+ const lineStr = sortedLines.map((l) => `L${l}`).join(', ');
4058
+ const more = lineNos.length > sortedLines.length ? ` +${lineNos.length - sortedLines.length} more` : '';
4059
+ lines.push(`- \`${fp}\` (${lineStr}${more})`);
4060
+ shown++;
4061
+ }
4062
+ if (hits.size > shown)
4063
+ lines.push(`- … and ${hits.size - shown} more file(s)`);
4064
+ lines.push('');
4065
+ lines.push(`> API usage survey complete — **${hits.size}** file(s) listed.`);
4066
+ lines.push('');
4067
+ return { section: lines.join('\n'), fileCount: hits.size };
2317
4068
  }
2318
4069
  /**
2319
- * Shortlist candidate runtime targets for a dispatch key surfaced by
2320
- * {@link buildDynamicBoundaries}. Exact conventional names first (`save`
2321
- * `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
2322
- * normalized-containment post-filter (FTS camel-splitting is fuzzier than a
2323
- * candidate list should be). Symbols the agent already named sort first and
2324
- * are marked — that's the "you were right, here's the wiring" case.
4070
+ * Domain file inventory related files, usage existence, or concept comparison.
4071
+ * Paths + top symbols only; source bodies omitted when this section renders.
2325
4072
  */
2326
- boundaryCandidates(cg, key, keyIsType, named, selfId) {
2327
- const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
2328
- const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
2329
- const keyNorm = norm(key);
2330
- if (keyNorm.length < 3)
2331
- return '';
2332
- const cands = new Map();
2333
- const consider = (n) => {
2334
- if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id))
2335
- return;
2336
- const nameNorm = norm(n.name || '');
2337
- if (nameNorm.length < 3)
2338
- return;
2339
- if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm))
4073
+ buildDomainFileSurveySection(cg, query) {
4074
+ const terms = (0, query_utils_1.extractDomainSearchTerms)(query);
4075
+ if (terms.length === 0)
4076
+ return { section: '', fileCount: 0 };
4077
+ const rel = (p) => p.replace(/\\/g, '/');
4078
+ const fileScores = new Map();
4079
+ const addFile = (filePath, score, symbol) => {
4080
+ if ((0, query_utils_1.isTestFile)(filePath))
2340
4081
  return;
2341
- cands.set(n.id, n);
4082
+ const fp = rel(filePath);
4083
+ const existing = fileScores.get(fp) ?? { score: 0, symbols: [] };
4084
+ existing.score += score;
4085
+ if (symbol && existing.symbols.length < 5 && !existing.symbols.includes(symbol)) {
4086
+ existing.symbols.push(symbol);
4087
+ }
4088
+ fileScores.set(fp, existing);
2342
4089
  };
2343
- const cap = key.charAt(0).toUpperCase() + key.slice(1);
2344
- const probes = keyIsType
2345
- ? [`${key}Handler`, key]
2346
- : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
2347
- for (const p of probes) {
4090
+ for (const term of terms) {
4091
+ let hits = [];
2348
4092
  try {
2349
- for (const n of cg.getNodesByName(p))
2350
- consider(n);
4093
+ hits = cg.searchNodes(term, { limit: 45 });
4094
+ }
4095
+ catch {
4096
+ continue;
4097
+ }
4098
+ for (const r of hits) {
4099
+ addFile(r.node.filePath, (r.score ?? 1) + 2, r.node.name);
2351
4100
  }
2352
- catch { /* exact probe miss is fine */ }
2353
4101
  }
2354
- let raw = 0;
2355
4102
  try {
2356
- const results = cg.searchNodes(key, { limit: 12 });
2357
- raw = results.length;
2358
- for (const r of results)
2359
- consider(r.node);
4103
+ for (const f of cg.getFiles()) {
4104
+ const fpLc = f.path.toLowerCase();
4105
+ for (const term of terms) {
4106
+ if (term.length >= 3 && fpLc.includes(term.toLowerCase())) {
4107
+ addFile(f.path, 6);
4108
+ }
4109
+ }
4110
+ }
2360
4111
  }
2361
- catch { /* FTS syntax edge — exact probes already ran */ }
2362
- if (cands.size === 0) {
2363
- return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
4112
+ catch { /* getFiles unavailable */ }
4113
+ if (fileScores.size === 0)
4114
+ return { section: '', fileCount: 0 };
4115
+ const sorted = [...fileScores.entries()]
4116
+ .sort((a, b) => b[1].score - a[1].score)
4117
+ .slice(0, 50);
4118
+ const lines = [
4119
+ '**Related files (domain survey)**',
4120
+ '',
4121
+ `> File paths matching the query domain (${terms.map((t) => `\`${t}\``).join(', ')}). Answer from this inventory; source bodies omitted.`,
4122
+ '',
4123
+ ];
4124
+ for (const [fp, info] of sorted.slice(0, 40)) {
4125
+ const symStr = info.symbols.length > 0 ? ` — ${info.symbols.join(', ')}` : '';
4126
+ lines.push(`- \`${fp}\`${symStr}`);
2364
4127
  }
2365
- // A constructor candidate duplicates its class: extractors emit ctors as
2366
- // METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
2367
- const all = [...cands.values()];
2368
- const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
2369
- const namedNames = new Set([...named.values()].map((n) => n.name));
2370
- const isNamed = (n) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
2371
- const list = all
2372
- .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
2373
- .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
2374
- .slice(0, 4)
2375
- .map((n) => {
2376
- // Typed-bus convention: the runtime target is the candidate class's
2377
- // Handle/Execute/Consume method — name the exact node, not just the class.
2378
- let display = n.qualifiedName || n.name;
2379
- let at = `${n.filePath}:${n.startLine}`;
2380
- if (keyIsType && n.kind === 'class') {
4128
+ if (sorted.length > 40)
4129
+ lines.push(`- and ${sorted.length - 40} more file(s)`);
4130
+ lines.push('');
4131
+ lines.push(`> Domain file survey complete **${sorted.length}** file(s) listed. ` +
4132
+ 'Exhaustive related-file inventory; no glob/search needed.');
4133
+ lines.push('');
4134
+ return { section: lines.join('\n'), fileCount: sorted.length };
4135
+ }
4136
+ /**
4137
+ * Render a small config/manifest file named in the query (build-profile.json5, ).
4138
+ */
4139
+ buildConfigFileSection(cg, query, projectRoot) {
4140
+ const basenames = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
4141
+ if (basenames.length === 0)
4142
+ return '';
4143
+ const CONFIG_EXT = /\.(?:json5?|ya?ml|toml|xml|ini|properties)$/i;
4144
+ const rel = (p) => p.replace(/\\/g, '/');
4145
+ const lines = ['**Config / manifest**', ''];
4146
+ let any = false;
4147
+ for (const name of basenames.slice(0, 3)) {
4148
+ let files = [];
4149
+ try {
4150
+ files = cg.searchNodes(name, { kinds: ['file'], limit: 15 });
4151
+ }
4152
+ catch {
4153
+ continue;
4154
+ }
4155
+ for (const r of files) {
4156
+ const base = rel(r.node.filePath).split('/').pop() ?? '';
4157
+ if (!base.toLowerCase().includes(name.toLowerCase()))
4158
+ continue;
4159
+ if (!CONFIG_EXT.test(base))
4160
+ continue;
4161
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
4162
+ if (!abs)
4163
+ continue;
4164
+ let content;
2381
4165
  try {
2382
- const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
2383
- const method = cg.getOutgoingEdges(n.id)
2384
- .filter((e) => e.kind === 'contains')
2385
- .map((e) => { try {
2386
- return cg.getNode(e.target);
2387
- }
2388
- catch {
2389
- return null;
2390
- } })
2391
- .find((c) => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
2392
- if (method) {
2393
- display = `${n.name}.${method.name}`;
2394
- at = `${method.filePath}:${method.startLine}`;
2395
- }
4166
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
2396
4167
  }
2397
- catch { /* class without resolvable members — show the class itself */ }
4168
+ catch {
4169
+ continue;
4170
+ }
4171
+ if (content.length > 12000)
4172
+ continue;
4173
+ any = true;
4174
+ lines.push(`**\`${rel(r.node.filePath)}\`**`);
4175
+ lines.push('');
4176
+ lines.push('```json5');
4177
+ lines.push(content.replace(/\n+$/, ''));
4178
+ lines.push('```');
4179
+ lines.push('');
2398
4180
  }
2399
- return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
2400
- });
2401
- return `candidates for key \`${key}\`: ${list.join(', ')}`;
4181
+ }
4182
+ if (!any)
4183
+ return '';
4184
+ lines.push('> Config content above is verbatim — answer from it; no broad grep needed.');
4185
+ lines.push('');
4186
+ return lines.join('\n');
2402
4187
  }
2403
4188
  /**
2404
4189
  * Compact "blast radius" for the entry symbols of an explore result: who
@@ -2553,12 +4338,22 @@ class ToolHandler {
2553
4338
  * tax on small projects while earning its keep on large ones.
2554
4339
  */
2555
4340
  async handleExplore(args) {
2556
- const query = this.validateString(args.query, 'query');
2557
- if (typeof query !== 'string')
2558
- return query;
4341
+ const rawQuery = this.validateString(args.query, 'query');
4342
+ if (typeof rawQuery !== 'string')
4343
+ return rawQuery;
4344
+ // One normalization point so the flow-builder, relevance search, and
4345
+ // ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
4346
+ const query = normalizeQuerySpelling(rawQuery);
2559
4347
  const cg = this.getHomeGraph(args.projectPath);
2560
4348
  const projectRoot = cg.getProjectRoot();
2561
- // Resolve adaptive output budget from project size. Falls back to the
4349
+ const compactLocal = this.tryFastInventoryExplore(cg, query, projectRoot)
4350
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
4351
+ if (compactLocal)
4352
+ return compactLocal;
4353
+ const lightMechanism = this.tryLightMechanismExplore(cg, query, projectRoot);
4354
+ if (lightMechanism)
4355
+ return lightMechanism;
4356
+ // Resolve adaptive output budget from project size.
2562
4357
  // largest-tier defaults if stats aren't available, which preserves
2563
4358
  // pre-#185 behavior for callers that hit the rare stats failure.
2564
4359
  let budget;
@@ -2568,20 +4363,72 @@ class ToolHandler {
2568
4363
  catch {
2569
4364
  budget = getExploreOutputBudget(Infinity);
2570
4365
  }
2571
- const maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
4366
+ budget = tightenExploreBudgetForQuery(budget, query);
4367
+ const explicitMaxFiles = typeof args.maxFiles === 'number' && !Number.isNaN(args.maxFiles);
4368
+ let maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
4369
+ const queryFileBasenames = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
4370
+ const interpretationQuery = (0, query_utils_1.queryAsInterpretationSurvey)(query);
4371
+ const testOnlyInterpretation = (0, query_utils_1.queryAsTestOnlyInterpretation)(query);
4372
+ const crossModuleFlow = (0, query_utils_1.queryAsCrossModuleFlowSurvey)(query);
2572
4373
  // Step 1: Find relevant context with generous parameters.
2573
- // Use a large maxNodes budget explore has its own 35k char output limit
2574
- // that prevents context bloat, so more nodes just means better coverage
2575
- // across entry points (especially for large files like Svelte components).
2576
- const subgraph = await cg.findRelevantContext(query, {
2577
- searchLimit: 8,
2578
- traversalDepth: 3,
2579
- maxNodes: 200,
2580
- minScore: 0.2,
2581
- });
4374
+ const contextOpts = interpretationQuery && queryFileBasenames.length === 1
4375
+ ? { searchLimit: 6, traversalDepth: 2, maxNodes: 60, minScore: 0.25 }
4376
+ : { searchLimit: 8, traversalDepth: 3, maxNodes: 200, minScore: 0.2 };
4377
+ const contextQuery = interpretationQuery && queryFileBasenames.length === 1
4378
+ ? `${queryFileBasenames[0]} ${query}`
4379
+ : query;
4380
+ const subgraph = await cg.findRelevantContext(contextQuery, contextOpts);
2582
4381
  if (subgraph.nodes.size === 0) {
2583
4382
  return this.textResult(`No relevant code found for "${query}"`);
2584
4383
  }
4384
+ // Seed import nodes for @kit.* / *Kit names (and named symbols like taskpool).
4385
+ const importTerms = (0, query_utils_1.extractImportSearchTerms)(query);
4386
+ const depSymbols = (0, query_utils_1.extractDependencySymbolsFromQuery)(query);
4387
+ const seedImport = (r, lineText) => {
4388
+ const lineLc = lineText.toLowerCase();
4389
+ if (importTerms.length > 0) {
4390
+ const kitNames = (0, query_utils_1.extractKitModuleNamesFromQuery)(query);
4391
+ if (kitNames.length > 0 && !kitNames.some((k) => lineLc.includes(`@kit.${k.toLowerCase()}`))) {
4392
+ return;
4393
+ }
4394
+ }
4395
+ if (depSymbols.length > 0 && !depSymbols.some((s) => lineLc.includes(s.toLowerCase()))) {
4396
+ return;
4397
+ }
4398
+ if (!subgraph.nodes.has(r.node.id)) {
4399
+ subgraph.nodes.set(r.node.id, r.node);
4400
+ subgraph.roots.push(r.node.id);
4401
+ }
4402
+ };
4403
+ for (const sym of depSymbols) {
4404
+ let hits = [];
4405
+ try {
4406
+ hits = cg.searchNodes(sym, { kinds: ['import'], limit: 40 });
4407
+ }
4408
+ catch {
4409
+ continue;
4410
+ }
4411
+ for (const r of hits) {
4412
+ const sig = (r.node.signature || r.node.name || '').trim();
4413
+ seedImport(r, sig);
4414
+ }
4415
+ }
4416
+ for (const term of importTerms) {
4417
+ const termLc = term.toLowerCase().replace(/^@kit\./, '');
4418
+ let hits = [];
4419
+ try {
4420
+ hits = cg.searchNodes(term, { kinds: ['import'], limit: 12 });
4421
+ }
4422
+ catch {
4423
+ continue;
4424
+ }
4425
+ for (const r of hits) {
4426
+ const sig = (r.node.signature || r.node.name || '').trim();
4427
+ if (!sig.toLowerCase().includes(termLc))
4428
+ continue;
4429
+ seedImport(r, sig);
4430
+ }
4431
+ }
2585
4432
  // Graph-aware glue: findRelevantContext builds the subgraph from name/text
2586
4433
  // search, so a method that BRIDGES named symbols — e.g. App.tsx's
2587
4434
  // triggerRender, which calls the named triggerUpdate — is never a search hit
@@ -2639,6 +4486,13 @@ class ToolHandler {
2639
4486
  {
2640
4487
  const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro)$/i;
2641
4488
  const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
4489
+ // Named types (BridgeInterceptor, ResponseFormatter) must also seed — the
4490
+ // adaptive sibling-skeleton path needs those files in the subgraph even
4491
+ // when they hold no callable the agent also named.
4492
+ const SEED_KINDS = new Set([
4493
+ ...CALLABLE,
4494
+ 'class', 'struct', 'interface', 'trait', 'protocol', 'constant', 'variable',
4495
+ ]);
2642
4496
  const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
2643
4497
  const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
2644
4498
  const callerCount = (n) => { try {
@@ -2647,9 +4501,28 @@ class ToolHandler {
2647
4501
  catch {
2648
4502
  return 0;
2649
4503
  } };
2650
- const tokens = [...new Set(query.split(/[\s,()[\]]+/)
2651
- .map((t) => t.replace(FILE_EXT, '').trim())
2652
- .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
4504
+ const namedParts = [];
4505
+ for (const kit of (0, query_utils_1.extractKitModuleNamesFromQuery)(query))
4506
+ namedParts.push(kit);
4507
+ for (const base of (0, query_utils_1.extractFileBasenamesFromQuery)(query))
4508
+ namedParts.push(base);
4509
+ for (const ma of (0, query_utils_1.extractMemberAccessFromQuery)(query)) {
4510
+ namedParts.push(ma.member);
4511
+ if (ma.receiver)
4512
+ namedParts.push(ma.receiver);
4513
+ }
4514
+ for (const m of query.matchAll(/\b([A-Za-z_][\w]*)(::)([A-Za-z_][\w]*)\b/g)) {
4515
+ if (m[1])
4516
+ namedParts.push(m[1]);
4517
+ if (m[3])
4518
+ namedParts.push(m[3]);
4519
+ }
4520
+ const tokens = [...new Set([
4521
+ ...namedParts,
4522
+ ...query.split(/[\s,()[\]]+/)
4523
+ .map((t) => t.replace(FILE_EXT, '').trim())
4524
+ .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)),
4525
+ ])].slice(0, 16);
2653
4526
  // PascalCase tokens in the query are type/file disambiguators — when the
2654
4527
  // agent writes "DataRequest task validate", the `task`/`validate` it wants
2655
4528
  // are DataRequest's, NOT the same-named overloads in Validation.swift /
@@ -2673,8 +4546,15 @@ class ToolHandler {
2673
4546
  const isQual = /[.\/]|::/.test(t);
2674
4547
  const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
2675
4548
  const cands = raw
2676
- .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
2677
- .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
4549
+ .filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath))
4550
+ .sort((a, b) => {
4551
+ // Prefer callables over types when both share a name, then body size.
4552
+ const ac = CALLABLE.has(a.kind) ? 1 : 0;
4553
+ const bc = CALLABLE.has(b.kind) ? 1 : 0;
4554
+ if (ac !== bc)
4555
+ return bc - ac;
4556
+ return (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a);
4557
+ });
2678
4558
  // A specific name (<=3 defs) injects all its defs. An overloaded name
2679
4559
  // (`validate` = 10, `request` = 44) would flood the subgraph, so inject
2680
4560
  // only: the overloads whose file/class the query ALSO names (the agent
@@ -2713,6 +4593,68 @@ class ToolHandler {
2713
4593
  for (const n of tierPicks)
2714
4594
  tierSeedIds.add(n.id);
2715
4595
  }
4596
+ // Mechanism survey: seed query-shaped entry symbols first, then domain search.
4597
+ if ((0, query_utils_1.queryAsMechanismSurvey)(query)) {
4598
+ const STRUCTURE_KINDS = new Set(['class', 'struct', 'interface', 'component']);
4599
+ const implEntryNames = [];
4600
+ for (const seed of (0, query_utils_1.extractMechanismEntrySeeds)(query)) {
4601
+ if (seed.startsWith('@')) {
4602
+ let hits = [];
4603
+ try {
4604
+ hits = cg.searchNodes(seed, { kinds: ['import'], limit: 20 });
4605
+ }
4606
+ catch {
4607
+ continue;
4608
+ }
4609
+ for (const r of hits) {
4610
+ if (isTestPath(r.node.filePath))
4611
+ continue;
4612
+ if (!subgraph.nodes.has(r.node.id))
4613
+ subgraph.nodes.set(r.node.id, r.node);
4614
+ namedSeedIds.add(r.node.id);
4615
+ tierSeedIds.add(r.node.id);
4616
+ }
4617
+ continue;
4618
+ }
4619
+ const nodes = cg.getNodesByName(seed).filter((n) => STRUCTURE_KINDS.has(n.kind) && !isTestPath(n.filePath));
4620
+ for (const n of nodes.slice(0, 3)) {
4621
+ if (!subgraph.nodes.has(n.id))
4622
+ subgraph.nodes.set(n.id, n);
4623
+ namedSeedIds.add(n.id);
4624
+ tierSeedIds.add(n.id);
4625
+ if (!implEntryNames.includes(n.name))
4626
+ implEntryNames.push(n.name);
4627
+ }
4628
+ }
4629
+ if (implEntryNames.length < 2) {
4630
+ const domainTerms = (0, query_utils_1.extractDomainSearchTerms)(query);
4631
+ for (const term of domainTerms.slice(0, 5)) {
4632
+ let hits = [];
4633
+ try {
4634
+ hits = cg.searchNodes(term, { kinds: ['class', 'struct', 'interface'], limit: 30 });
4635
+ }
4636
+ catch {
4637
+ continue;
4638
+ }
4639
+ for (const r of hits) {
4640
+ if (isTestPath(r.node.filePath))
4641
+ continue;
4642
+ if (!(0, query_utils_1.isImplementationEntrySymbol)(r.node.name, domainTerms))
4643
+ continue;
4644
+ if (!subgraph.nodes.has(r.node.id))
4645
+ subgraph.nodes.set(r.node.id, r.node);
4646
+ namedSeedIds.add(r.node.id);
4647
+ tierSeedIds.add(r.node.id);
4648
+ if (!implEntryNames.includes(r.node.name))
4649
+ implEntryNames.push(r.node.name);
4650
+ if (implEntryNames.length >= 6)
4651
+ break;
4652
+ }
4653
+ if (implEntryNames.length >= 6)
4654
+ break;
4655
+ }
4656
+ }
4657
+ }
2716
4658
  }
2717
4659
  // Step 2: Group nodes by file, score by relevance
2718
4660
  const fileGroups = new Map();
@@ -2797,6 +4739,14 @@ class ToolHandler {
2797
4739
  }
2798
4740
  fileGroups.set(node.filePath, group);
2799
4741
  }
4742
+ if (testOnlyInterpretation) {
4743
+ for (const [, group] of fileGroups) {
4744
+ const fp = group.nodes[0]?.filePath ?? '';
4745
+ if (fp && !(0, query_utils_1.isTestFile)(fp) && !(0, query_utils_1.fileMatchesQueryBasename)(fp, queryFileBasenames)) {
4746
+ group.score = Math.max(0, group.score - 50);
4747
+ }
4748
+ }
4749
+ }
2800
4750
  // Only include files that have entry points or nodes directly connected to entry points
2801
4751
  let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
2802
4752
  // Extract query terms for relevance checking
@@ -2974,9 +4924,16 @@ class ToolHandler {
2974
4924
  const isCorroborated = (fp) => !MULTITERM_OFF &&
2975
4925
  (fileTermHits.get(fp) ?? 0) >= 2 &&
2976
4926
  (entryFiles.has(fp) || centralFiles.has(fp));
4927
+ const queryMemberAccesses = (0, query_utils_1.extractMemberAccessFromQuery)(query);
2977
4928
  const sortedFiles = relevantFiles.sort((a, b) => {
2978
4929
  const aPath = a[0].toLowerCase();
2979
4930
  const bPath = b[0].toLowerCase();
4931
+ // Query-named file (LocationController.ets in the question) before partial
4932
+ // substring matches (control.ets matching "Controller" inside LocationController).
4933
+ const aExactBase = (0, query_utils_1.fileMatchesQueryBasename)(a[0], queryFileBasenames) ? 1 : 0;
4934
+ const bExactBase = (0, query_utils_1.fileMatchesQueryBasename)(b[0], queryFileBasenames) ? 1 : 0;
4935
+ if (aExactBase !== bExactBase)
4936
+ return bExactBase - aExactBase;
2980
4937
  // Agent-named files first (it asked for a symbol defined here by name).
2981
4938
  const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
2982
4939
  const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
@@ -3029,16 +4986,154 @@ class ToolHandler {
3029
4986
  '',
3030
4987
  ];
3031
4988
  const summaryLineIdx = 2;
3032
- // Blast radius (always-on, compact): for the entry symbols, who depends on
3033
- // them + which tests cover them locations only, no source so the agent
3034
- // knows what to update/verify before editing without a separate call.
3035
- const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
3036
- if (blastRadius)
3037
- lines.push(blastRadius);
3038
- // Relationship map — show how symbols connect
4989
+ if (testOnlyInterpretation) {
4990
+ lines.push('> **Test-file scope only** answer from the named `.test.ets` file below; ' +
4991
+ 'production handlers are out of scope unless explicitly referenced in the test.');
4992
+ lines.push('');
4993
+ }
4994
+ const importResult = this.buildImportSitesSection(cg, query, projectRoot);
4995
+ if (importResult.section)
4996
+ lines.push(importResult.section);
4997
+ const homonymSection = this.buildHomonymDefinitionsSection(cg, query);
4998
+ if (homonymSection)
4999
+ lines.push(homonymSection);
5000
+ const kitUsageResult = (0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
5001
+ ? this.buildKitModuleUsageSection(cg, query, projectRoot)
5002
+ : { section: '', symbolCount: 0 };
5003
+ if (kitUsageResult.section)
5004
+ lines.push(kitUsageResult.section);
5005
+ const domainFileResult = (0, query_utils_1.shouldBuildDomainFileSurvey)(query)
5006
+ ? this.buildDomainFileSurveySection(cg, query)
5007
+ : { section: '', fileCount: 0 };
5008
+ if (domainFileResult.section)
5009
+ lines.push(domainFileResult.section);
5010
+ const apiUsageResult = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
5011
+ ? this.buildApiUsageSection(cg, query, projectRoot)
5012
+ : { section: '', fileCount: 0 };
5013
+ if (apiUsageResult.section)
5014
+ lines.push(apiUsageResult.section);
5015
+ const dataSourceResult = (0, query_utils_1.queryAsDataSourceSurvey)(query)
5016
+ ? this.buildDataSourceSection(cg, query)
5017
+ : { section: '', edgeCount: 0 };
5018
+ if (dataSourceResult.section)
5019
+ lines.push(dataSourceResult.section);
5020
+ const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
5021
+ const multiAnchor = (0, query_utils_1.queryNamesMultipleExploreAnchors)(query) || crossModuleFlow;
5022
+ const mechanismSurvey = (0, query_utils_1.queryAsMechanismSurvey)(query);
5023
+ // Flow path — computed before omit-source so graph connectivity drives the decision,
5024
+ // not question-text keyword matching. Mechanism/cross-module surveys augment the
5025
+ // query with seeded entry symbol names so buildFlowFromNamedSymbols can connect them.
5026
+ let flowQuery = query;
5027
+ if (crossModuleFlow) {
5028
+ flowQuery = `${query} ${(0, query_utils_1.extractTypeNamesFromQuery)(query).join(' ')}`;
5029
+ }
5030
+ else if (mechanismSurvey) {
5031
+ const seeds = (0, query_utils_1.extractMechanismEntrySeeds)(query);
5032
+ if (seeds.length >= 2) {
5033
+ flowQuery = `${query} ${seeds.join(' ')}`;
5034
+ }
5035
+ else {
5036
+ const entryNames = [...subgraph.nodes.values()]
5037
+ .filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'interface')
5038
+ && (0, query_utils_1.isImplementationEntrySymbol)(n.name, (0, query_utils_1.extractDomainSearchTerms)(query)))
5039
+ .map((n) => n.name)
5040
+ .slice(0, 6);
5041
+ if (entryNames.length >= 2)
5042
+ flowQuery = `${query} ${entryNames.join(' ')}`;
5043
+ }
5044
+ }
5045
+ const flow = this.buildFlowFromNamedSymbols(cg, flowQuery);
5046
+ const hasFlowPath = flow.pathNodeIds.size > 0;
5047
+ budget = tightenExploreBudgetForQuery(budget, query, { hasFlowPath });
5048
+ // Honor an explicit maxFiles from the caller — budget.defaultMaxFiles is only
5049
+ // a default when the agent didn't ask for more (adaptive sibling tests pass 12).
5050
+ if (!explicitMaxFiles) {
5051
+ maxFiles = Math.min(maxFiles, (0, utils_1.clamp)(budget.defaultMaxFiles, 1, 20));
5052
+ }
5053
+ const localDetail = (0, query_utils_1.queryAsLocalSymbolDetail)(query);
5054
+ const inheritanceSection = !hasFlowPath && !multiAnchor
5055
+ ? this.buildInheritanceSurveySection(cg, query) : '';
5056
+ const callerSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildCallerInventory)(query)
5057
+ ? this.buildCallerListingSection(cg, query) : '';
5058
+ const memberSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildMemberSurvey)(query)
5059
+ ? this.buildMemberSurveySection(cg, query, projectRoot) : '';
5060
+ const configSection = (0, query_utils_1.shouldBuildConfigSection)(query)
5061
+ ? this.buildConfigFileSection(cg, query, projectRoot) : '';
5062
+ if (inheritanceSection)
5063
+ lines.push(inheritanceSection);
5064
+ if (callerSection)
5065
+ lines.push(callerSection);
5066
+ if (memberSection)
5067
+ lines.push(memberSection);
5068
+ if (configSection)
5069
+ lines.push(configSection);
5070
+ const finishCompact = (summary) => {
5071
+ lines[summaryLineIdx] = summary;
5072
+ return this.textResult(lines.join('\n'));
5073
+ };
5074
+ const memberFileCount = memberSection
5075
+ ? memberSection.split('\n').filter((l) => l.startsWith('- ')).length
5076
+ : 0;
5077
+ const callerBulletCount = callerSection
5078
+ ? callerSection.split('\n').filter((l) => l.startsWith('- ') && l.includes(' ← ')).length
5079
+ : 0;
5080
+ const inheritanceListed = inheritanceSection
5081
+ ? inheritanceSection.split('\n').filter((l) => l.startsWith('- `')).length > 0
5082
+ : false;
5083
+ const omitSource = (0, query_utils_1.shouldOmitSourceBodies)({
5084
+ importSiteCount: importResult.siteCount,
5085
+ hasFilteredImports: importInventoryFilter && importResult.siteCount > 0,
5086
+ callerBulletCount,
5087
+ memberFileCount,
5088
+ apiUsageFileCount: apiUsageResult.fileCount,
5089
+ configRendered: !!configSection,
5090
+ kitModuleSurveyRendered: !!kitUsageResult.section,
5091
+ inheritanceListed,
5092
+ domainFileCount: domainFileResult.fileCount,
5093
+ dataSourceEdgeCount: dataSourceResult.edgeCount,
5094
+ }, hasFlowPath, multiAnchor);
5095
+ if (omitSource) {
5096
+ if (configSection) {
5097
+ return finishCompact('Config/manifest content above — answer from it directly.');
5098
+ }
5099
+ if (kitUsageResult.section) {
5100
+ return finishCompact(`Kit module usage survey — **${kitUsageResult.symbolCount}** imported symbol(s) across the repo. SDK definitions are not in this project; answer from the usage list above.`);
5101
+ }
5102
+ if (domainFileResult.section && domainFileResult.fileCount > 0) {
5103
+ return finishCompact(`Domain file survey — **${domainFileResult.fileCount}** related file(s) listed above. Source bodies omitted; answer from the inventory.`);
5104
+ }
5105
+ if (apiUsageResult.section && apiUsageResult.fileCount > 0) {
5106
+ return finishCompact(`API usage survey — **${apiUsageResult.fileCount}** file(s) referencing the queried symbol(s). Source bodies omitted; answer from the list above.`);
5107
+ }
5108
+ if (dataSourceResult.section && dataSourceResult.edgeCount > 0) {
5109
+ return finishCompact(`Data-source survey — **${dataSourceResult.edgeCount}** upstream symbol(s) listed above. Source bodies omitted; answer from the inventory.`);
5110
+ }
5111
+ if (inheritanceListed) {
5112
+ return finishCompact('Inheritance survey above lists all direct subtypes found — source bodies omitted.');
5113
+ }
5114
+ if (importResult.compactListing) {
5115
+ return finishCompact(`Listed **${importResult.siteCount}** import site(s) for the queried symbol(s). Source bodies omitted — answer from the dependency list above.`);
5116
+ }
5117
+ if (callerBulletCount >= 1) {
5118
+ return finishCompact(`Caller inventory above lists **${callerBulletCount}** method(s) with external callers — source bodies omitted.`);
5119
+ }
5120
+ if (memberSection && memberFileCount >= 2) {
5121
+ return finishCompact(`Member/pattern usage in **${memberFileCount}** file(s) — source bodies omitted; answer from the inventory above.`);
5122
+ }
5123
+ }
5124
+ // Blast radius only for structural flow / mechanism answers — skip on local-detail
5125
+ // and no-flow dumps (it's expensive and rarely changes the answer there).
5126
+ if (!localDetail && (hasFlowPath || mechanismSurvey || crossModuleFlow)) {
5127
+ const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
5128
+ if (blastRadius)
5129
+ lines.push(blastRadius);
5130
+ }
5131
+ // Relationship map — show how symbols connect (skip when no flow path: saves
5132
+ // tokens on survey/dependency/how-to questions that don't need call graphs).
3039
5133
  const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
3040
5134
  );
3041
- if (budget.includeRelationships && significantEdges.length > 0) {
5135
+ if (budget.includeRelationships && hasFlowPath && !importResult.compactListing
5136
+ && significantEdges.length > 0) {
3042
5137
  lines.push('**Relationships**');
3043
5138
  lines.push('');
3044
5139
  // Group edges by kind for readability
@@ -3066,10 +5161,7 @@ class ToolHandler {
3066
5161
  }
3067
5162
  }
3068
5163
  // Step 4: Read contiguous file sections
3069
- // Compute the flow spine once used both to prepend the Flow section (below)
3070
- // and to gate adaptive source sizing: files on the spine get full source,
3071
- // off-spine peers skeletonize.
3072
- const flow = this.buildFlowFromNamedSymbols(cg, query);
5164
+ // (flow already computed above for relationship gating and adaptive sizing)
3073
5165
  // Polymorphic-sibling detector for adaptive sizing. A class that implements/
3074
5166
  // extends a supertype shared by >= MIN_SIBLINGS classes is one of many
3075
5167
  // INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
@@ -3126,7 +5218,9 @@ class ToolHandler {
3126
5218
  };
3127
5219
  lines.push('**Source Code**');
3128
5220
  lines.push('');
3129
- lines.push('> The code below is the **verbatim, current on-disk source** of these files re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
5221
+ lines.push('> Line-numbered source — treat as already Read. Answer from Flow + Source below when you can; ' +
5222
+ 'do not re-read/grep these files, and do not fan out `homegraph_node` / search for symbols already shown. ' +
5223
+ 'Another `homegraph_explore` with tighter names only if a needed symbol is missing.');
3130
5224
  lines.push('');
3131
5225
  let totalChars = lines.join('\n').length;
3132
5226
  let filesIncluded = 0;
@@ -3134,8 +5228,51 @@ class ToolHandler {
3134
5228
  // (#1046) — it must reflect what we show, not the raw candidate gather.
3135
5229
  const renderedFilePaths = [];
3136
5230
  let anyFileTrimmed = false;
3137
- for (const [filePath, group] of sortedFiles) {
3138
- if (filesIncluded >= maxFiles)
5231
+ const limitSingleFile = (0, query_utils_1.shouldLimitToQueryNamedFile)(query, hasFlowPath, multiAnchor)
5232
+ || (interpretationQuery && queryFileBasenames.length === 1);
5233
+ let filesToRender = sortedFiles;
5234
+ if (limitSingleFile) {
5235
+ const anchored = sortedFiles.filter(([fp]) => (0, query_utils_1.fileMatchesQueryBasename)(fp, queryFileBasenames));
5236
+ if (anchored.length > 0)
5237
+ filesToRender = anchored.slice(0, 1);
5238
+ }
5239
+ else if ((0, query_utils_1.shouldFocusOnNamedTypeFile)(query, hasFlowPath, multiAnchor)) {
5240
+ const typeName = (0, query_utils_1.extractTypeNamesFromQuery)(query)[0];
5241
+ const anchored = sortedFiles.filter(([, group]) => group.nodes.some((n) => n.name === typeName && (n.kind === 'class' || n.kind === 'interface' || n.kind === 'struct')));
5242
+ if (anchored.length > 0)
5243
+ filesToRender = anchored.slice(0, 1);
5244
+ }
5245
+ else if ((0, query_utils_1.shouldFocusOnQueryNamedDefs)(query, hasFlowPath, multiAnchor)) {
5246
+ const nameSet = new Set([
5247
+ ...(0, query_utils_1.extractTypeNamesFromQuery)(query),
5248
+ ...(0, query_utils_1.extractDependencySymbolsFromQuery)(query),
5249
+ ...(0, query_utils_1.extractMemberAccessFromQuery)(query).map((m) => m.member),
5250
+ ]);
5251
+ const anchored = sortedFiles.filter(([, group]) => group.nodes.some((n) => nameSet.has(n.name)));
5252
+ if (anchored.length > 0) {
5253
+ filesToRender = anchored.slice(0, localDetail ? 2 : 3);
5254
+ }
5255
+ }
5256
+ else if (crossModuleFlow && hasFlowPath) {
5257
+ const onSpine = sortedFiles.filter(([, group]) => group.nodes.some((n) => flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)));
5258
+ if (onSpine.length > 0)
5259
+ filesToRender = onSpine;
5260
+ }
5261
+ const sourceFileCap = limitSingleFile && filesToRender.length === 1
5262
+ ? 1
5263
+ : localDetail
5264
+ ? Math.min(maxFiles, 2)
5265
+ : crossModuleFlow && hasFlowPath
5266
+ ? Math.min(maxFiles, 5)
5267
+ : mechanismSurvey && hasFlowPath
5268
+ ? Math.min(maxFiles, 4)
5269
+ : mechanismSurvey
5270
+ ? Math.min(maxFiles, 3)
5271
+ : !hasFlowPath
5272
+ ? Math.min(maxFiles, 3)
5273
+ : maxFiles;
5274
+ for (const [filePath, group] of filesToRender) {
5275
+ if (filesIncluded >= sourceFileCap)
3139
5276
  break;
3140
5277
  // A file DEFINES a named/spine symbol (the answer) vs merely references the
3141
5278
  // flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
@@ -3431,6 +5568,39 @@ class ToolHandler {
3431
5568
  ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
3432
5569
  }
3433
5570
  }
5571
+ // Query member-access anchors: pin lines the question names (locationManager.on,
5572
+ // .drawModifier) so per-file budget gaps don't hide the exact call site.
5573
+ if (queryMemberAccesses.length > 0) {
5574
+ const anchorLines = new Set();
5575
+ for (const ma of queryMemberAccesses) {
5576
+ const patterns = [];
5577
+ if (ma.receiver) {
5578
+ const recv = ma.receiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5579
+ const mem = ma.member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5580
+ patterns.push(new RegExp(`${recv}\\.${mem}\\s*\\(`));
5581
+ patterns.push(new RegExp(`${recv}\\.${mem}\\b`));
5582
+ }
5583
+ else if (ma.dotted.startsWith('.')) {
5584
+ const lit = ma.dotted.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5585
+ patterns.push(new RegExp(`${lit}\\b`));
5586
+ }
5587
+ for (let i = 0; i < fileLines.length; i++) {
5588
+ const line = fileLines[i] ?? '';
5589
+ if (patterns.some((p) => p.test(line)))
5590
+ anchorLines.add(i + 1);
5591
+ }
5592
+ }
5593
+ for (const lineNo of anchorLines) {
5594
+ ranges.push({
5595
+ start: lineNo,
5596
+ end: lineNo,
5597
+ name: 'query-anchor',
5598
+ kind: 'anchor',
5599
+ importance: 11,
5600
+ spine: false,
5601
+ });
5602
+ }
5603
+ }
3434
5604
  ranges.sort((a, b) => a.start - b.start);
3435
5605
  if (ranges.length === 0)
3436
5606
  continue;
@@ -3713,11 +5883,17 @@ class ToolHandler {
3713
5883
  const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
3714
5884
  const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
3715
5885
  const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
3716
- finalText = 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 homegraph_explore with the specific names — do NOT Read these files.)';
5886
+ finalText = safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For uncovered files/symbols, run another homegraph_explore with their exact names — not grep/read/node for symbols already shown.)';
3717
5887
  }
3718
5888
  else {
3719
5889
  finalText = output;
3720
5890
  }
5891
+ if (!finalText.includes('**Partial result**') && !finalText.includes('> **Explore complete**')) {
5892
+ finalText +=
5893
+ '\n\n> **Explore complete — ANSWER NOW.** Flow + Source above are authoritative for this query. ' +
5894
+ 'Do **not** grep/read/`homegraph_node`/`homegraph_search` for the same symbols or files. ' +
5895
+ 'Only call one tighter `homegraph_explore` if a named symbol essential to the answer is missing.';
5896
+ }
3721
5897
  // Curated header (#1046): substitute the sentinel with the count of files
3722
5898
  // whose source SURVIVES in the final text — not `subgraph`/`fileGroups` (the
3723
5899
  // raw gather a broad query inflates) and not `filesIncluded` (which can
@@ -3807,12 +5983,12 @@ class ToolHandler {
3807
5983
  const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
3808
5984
  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')));
3809
5985
  }
3810
- const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
5986
+ const BODY_BUDGET = 10_000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
3811
5987
  // The CHAR budget is the real limiter — keep the count cap high so a set of
3812
5988
  // SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
3813
5989
  // render in full rather than relegating the one the agent wanted to a
3814
5990
  // bodiless list. Only a set of many LARGE bodies hits the char budget first.
3815
- const HARD_CAP = 16;
5991
+ const HARD_CAP = 12;
3816
5992
  const rendered = [];
3817
5993
  const listed = [];
3818
5994
  let used = 0;
@@ -3948,12 +6124,12 @@ class ToolHandler {
3948
6124
  const fileLines = content.split('\n');
3949
6125
  const total = fileLines.length;
3950
6126
  // Read-parity windowing: `offset`/`limit` mean exactly what they do on Read
3951
- // (1-based start line; max line count). Default: the whole file, capped like
3952
- // Read at 2000 lines and bounded by a char budget that tracks explore's
3953
- // proven-safe ~38k response ceiling. Overflow is stated explicitly (Read
3954
- // paginates too) never the silent 15k truncateOutput chop.
3955
- const CHAR_BUDGET = 38000;
3956
- const DEFAULT_LIMIT = 2000;
6127
+ // (1-based start line; max line count). Default window is intentionally
6128
+ // smaller than explore's flow budget dumping a whole multi-kLOC file from
6129
+ // `homegraph_node` teaches the agent to over-drill and burns tokens.
6130
+ // Overflow is stated explicitly (pass offset/limit or name a symbol).
6131
+ const CHAR_BUDGET = 12_000;
6132
+ const DEFAULT_LIMIT = 400;
3957
6133
  const offset = Math.max(1, opts.offset ?? 1);
3958
6134
  if (offset > total) {
3959
6135
  return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
@@ -3976,8 +6152,9 @@ class ToolHandler {
3976
6152
  const complete = offset === 1 && shownEnd >= total;
3977
6153
  const out = [header, '', ...numbered];
3978
6154
  if (!complete) {
3979
- out.push('', `(lines ${offset}–${shownEnd} of ${total} — pass \`offset\`/\`limit\` for another range, or \`homegraph_node <symbol>\` for one symbol in full)`);
6155
+ out.push('', `(lines ${offset}–${shownEnd} of ${total} — pass \`offset\`/\`limit\` for another range, or \`homegraph_node\` with a symbol name for one body in full)`);
3980
6156
  }
6157
+ out.push('', '> Treat this source as already Read. Prefer answering now; do not grep/read the same file or fan out more `homegraph_node` calls for symbols listed above.');
3981
6158
  // Self-bounded to CHAR_BUDGET — do NOT route through truncateOutput (15k).
3982
6159
  return this.textResult(out.join('\n'));
3983
6160
  }
@@ -4744,25 +6921,47 @@ class ToolHandler {
4744
6921
  if (tail && tail !== symbol)
4745
6922
  results = cg.searchNodes(tail, { limit: 50 });
4746
6923
  }
4747
- if (results.length === 0) {
6924
+ const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
6925
+ // Prefer exact name/qualified matches. Never fall back to the top FTS
6926
+ // hit — that resolved unrelated symbols (e.g. OnSurfaceChangedCB → a
6927
+ // function whose body merely mentions "surface") and poisoned callees.
6928
+ let pool = exactMatches.map((r) => r.node);
6929
+ if (pool.length === 0) {
6930
+ try {
6931
+ pool = cg.getNodesByName(symbol).filter((n) => this.matchesSymbol(n, symbol));
6932
+ }
6933
+ catch {
6934
+ pool = [];
6935
+ }
6936
+ }
6937
+ if (pool.length === 0 && /[.\/]|::/.test(symbol)) {
6938
+ const tail = lastQualifierPart(symbol);
6939
+ if (tail && tail !== symbol) {
6940
+ try {
6941
+ pool = cg.getNodesByName(tail).filter((n) => this.matchesSymbol(n, symbol) || this.matchesSymbol(n, tail));
6942
+ }
6943
+ catch {
6944
+ pool = [];
6945
+ }
6946
+ }
6947
+ }
6948
+ if (pool.length === 0) {
4748
6949
  return { nodes: [], note: '' };
4749
6950
  }
4750
- const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
4751
- if (exactMatches.length <= 1) {
4752
- const node = exactMatches[0]?.node ?? results[0].node;
4753
- return { nodes: [node], note: '' };
6951
+ if (pool.length === 1) {
6952
+ return { nodes: pool, note: '' };
4754
6953
  }
4755
6954
  // Same generated-file down-rank as findSymbol — keeps callers/callees
4756
6955
  // /impact aggregation aligned (a query against "Send" returns the
4757
6956
  // hand-written implementations before the protobuf scaffold).
4758
- const ranked = [...exactMatches].sort((a, b) => {
4759
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
4760
- const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
6957
+ const ranked = [...pool].sort((a, b) => {
6958
+ const aGen = (0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0;
6959
+ const bGen = (0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0;
4761
6960
  return aGen - bGen;
4762
6961
  });
4763
- const locations = ranked.map(r => `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`);
6962
+ const locations = ranked.map(n => `${n.kind} at ${n.filePath}:${n.startLine}`);
4764
6963
  const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
4765
- return { nodes: ranked.map(r => r.node), note };
6964
+ return { nodes: ranked, note };
4766
6965
  }
4767
6966
  /**
4768
6967
  * Truncate output if it exceeds the maximum length