homegraph 1.1.3 → 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 (412) hide show
  1. package/README.md +43 -18
  2. package/dist/arkts/ohos-api-index.d.ts +15 -0
  3. package/dist/arkts/ohos-api-index.d.ts.map +1 -0
  4. package/dist/arkts/ohos-api-index.js +190 -0
  5. package/dist/arkts/ohos-api-index.js.map +1 -0
  6. package/dist/arkts/ohos-sdk-input.d.ts +36 -0
  7. package/dist/arkts/ohos-sdk-input.d.ts.map +1 -0
  8. package/dist/arkts/ohos-sdk-input.js +214 -0
  9. package/dist/arkts/ohos-sdk-input.js.map +1 -0
  10. package/dist/bin/command-supervision.d.ts +12 -0
  11. package/dist/bin/command-supervision.d.ts.map +1 -0
  12. package/dist/bin/command-supervision.js +86 -0
  13. package/dist/bin/command-supervision.js.map +1 -0
  14. package/dist/bin/homegraph.d.ts +1 -1
  15. package/dist/bin/homegraph.js +709 -168
  16. package/dist/bin/homegraph.js.map +1 -1
  17. package/dist/context/index.d.ts.map +1 -1
  18. package/dist/context/index.js +85 -0
  19. package/dist/context/index.js.map +1 -1
  20. package/dist/db/index.d.ts +84 -5
  21. package/dist/db/index.d.ts.map +1 -1
  22. package/dist/db/index.js +217 -14
  23. package/dist/db/index.js.map +1 -1
  24. package/dist/db/migrations.d.ts +1 -1
  25. package/dist/db/migrations.d.ts.map +1 -1
  26. package/dist/db/migrations.js +58 -1
  27. package/dist/db/migrations.js.map +1 -1
  28. package/dist/db/queries.d.ts +131 -2
  29. package/dist/db/queries.d.ts.map +1 -1
  30. package/dist/db/queries.js +473 -9
  31. package/dist/db/queries.js.map +1 -1
  32. package/dist/db/schema.sql +52 -1
  33. package/dist/db/wal-valve.d.ts +106 -0
  34. package/dist/db/wal-valve.d.ts.map +1 -0
  35. package/dist/db/wal-valve.js +208 -0
  36. package/dist/db/wal-valve.js.map +1 -0
  37. package/dist/directory.d.ts +36 -0
  38. package/dist/directory.d.ts.map +1 -1
  39. package/dist/directory.js +274 -0
  40. package/dist/directory.js.map +1 -1
  41. package/dist/extraction/arkts-batch-worker.d.ts +2 -0
  42. package/dist/extraction/arkts-batch-worker.d.ts.map +1 -0
  43. package/dist/extraction/arkts-batch-worker.js +28 -0
  44. package/dist/extraction/arkts-batch-worker.js.map +1 -0
  45. package/dist/extraction/cfml-extractor.d.ts +107 -0
  46. package/dist/extraction/cfml-extractor.d.ts.map +1 -0
  47. package/dist/extraction/cfml-extractor.js +494 -0
  48. package/dist/extraction/cfml-extractor.js.map +1 -0
  49. package/dist/extraction/context.d.ts +11 -0
  50. package/dist/extraction/context.d.ts.map +1 -1
  51. package/dist/extraction/context.js +20 -0
  52. package/dist/extraction/context.js.map +1 -1
  53. package/dist/extraction/grammars.d.ts +25 -1
  54. package/dist/extraction/grammars.d.ts.map +1 -1
  55. package/dist/extraction/grammars.js +177 -24
  56. package/dist/extraction/grammars.js.map +1 -1
  57. package/dist/extraction/index.d.ts +55 -2
  58. package/dist/extraction/index.d.ts.map +1 -1
  59. package/dist/extraction/index.js +923 -381
  60. package/dist/extraction/index.js.map +1 -1
  61. package/dist/extraction/languages/arkts-state-decorators.d.ts +13 -0
  62. package/dist/extraction/languages/arkts-state-decorators.d.ts.map +1 -0
  63. package/dist/extraction/languages/arkts-state-decorators.js +26 -0
  64. package/dist/extraction/languages/arkts-state-decorators.js.map +1 -0
  65. package/dist/extraction/languages/arkts-viewtree.d.ts +4 -2
  66. package/dist/extraction/languages/arkts-viewtree.d.ts.map +1 -1
  67. package/dist/extraction/languages/arkts-viewtree.js +21 -6
  68. package/dist/extraction/languages/arkts-viewtree.js.map +1 -1
  69. package/dist/extraction/languages/arkts.d.ts +98 -2
  70. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  71. package/dist/extraction/languages/arkts.js +1290 -61
  72. package/dist/extraction/languages/arkts.js.map +1 -1
  73. package/dist/extraction/languages/c-cpp.d.ts +98 -0
  74. package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  75. package/dist/extraction/languages/c-cpp.js +563 -1
  76. package/dist/extraction/languages/c-cpp.js.map +1 -1
  77. package/dist/extraction/languages/cfquery.d.ts +12 -0
  78. package/dist/extraction/languages/cfquery.d.ts.map +1 -0
  79. package/dist/extraction/languages/cfquery.js +28 -0
  80. package/dist/extraction/languages/cfquery.js.map +1 -0
  81. package/dist/extraction/languages/cfscript.d.ts +3 -0
  82. package/dist/extraction/languages/cfscript.d.ts.map +1 -0
  83. package/dist/extraction/languages/cfscript.js +73 -0
  84. package/dist/extraction/languages/cfscript.js.map +1 -0
  85. package/dist/extraction/languages/cobol.d.ts +33 -0
  86. package/dist/extraction/languages/cobol.d.ts.map +1 -0
  87. package/dist/extraction/languages/cobol.js +499 -0
  88. package/dist/extraction/languages/cobol.js.map +1 -0
  89. package/dist/extraction/languages/erlang.d.ts +3 -0
  90. package/dist/extraction/languages/erlang.d.ts.map +1 -0
  91. package/dist/extraction/languages/erlang.js +350 -0
  92. package/dist/extraction/languages/erlang.js.map +1 -0
  93. package/dist/extraction/languages/index.d.ts.map +1 -1
  94. package/dist/extraction/languages/index.js +16 -0
  95. package/dist/extraction/languages/index.js.map +1 -1
  96. package/dist/extraction/languages/nix.d.ts +3 -0
  97. package/dist/extraction/languages/nix.d.ts.map +1 -0
  98. package/dist/extraction/languages/nix.js +294 -0
  99. package/dist/extraction/languages/nix.js.map +1 -0
  100. package/dist/extraction/languages/ohos-api-consumer.d.ts +34 -0
  101. package/dist/extraction/languages/ohos-api-consumer.d.ts.map +1 -0
  102. package/dist/extraction/languages/ohos-api-consumer.js +283 -0
  103. package/dist/extraction/languages/ohos-api-consumer.js.map +1 -0
  104. package/dist/extraction/languages/solidity.d.ts +3 -0
  105. package/dist/extraction/languages/solidity.d.ts.map +1 -0
  106. package/dist/extraction/languages/solidity.js +293 -0
  107. package/dist/extraction/languages/solidity.js.map +1 -0
  108. package/dist/extraction/languages/terraform.d.ts +3 -0
  109. package/dist/extraction/languages/terraform.d.ts.map +1 -0
  110. package/dist/extraction/languages/terraform.js +641 -0
  111. package/dist/extraction/languages/terraform.js.map +1 -0
  112. package/dist/extraction/languages/vbnet.d.ts +11 -0
  113. package/dist/extraction/languages/vbnet.d.ts.map +1 -0
  114. package/dist/extraction/languages/vbnet.js +141 -0
  115. package/dist/extraction/languages/vbnet.js.map +1 -0
  116. package/dist/extraction/mybatis-extractor.d.ts +30 -10
  117. package/dist/extraction/mybatis-extractor.d.ts.map +1 -1
  118. package/dist/extraction/mybatis-extractor.js +140 -38
  119. package/dist/extraction/mybatis-extractor.js.map +1 -1
  120. package/dist/extraction/parse-pool.d.ts +152 -0
  121. package/dist/extraction/parse-pool.d.ts.map +1 -0
  122. package/dist/extraction/parse-pool.js +387 -0
  123. package/dist/extraction/parse-pool.js.map +1 -0
  124. package/dist/extraction/parse-worker.js +9 -2
  125. package/dist/extraction/parse-worker.js.map +1 -1
  126. package/dist/extraction/tree-sitter-helpers.d.ts.map +1 -1
  127. package/dist/extraction/tree-sitter-helpers.js +1 -0
  128. package/dist/extraction/tree-sitter-helpers.js.map +1 -1
  129. package/dist/extraction/tree-sitter-types.d.ts +20 -1
  130. package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  131. package/dist/extraction/tree-sitter.d.ts +59 -0
  132. package/dist/extraction/tree-sitter.d.ts.map +1 -1
  133. package/dist/extraction/tree-sitter.js +952 -37
  134. package/dist/extraction/tree-sitter.js.map +1 -1
  135. package/dist/extraction/wasm/tree-sitter-arkts.wasm +0 -0
  136. package/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  137. package/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  138. package/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  139. package/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  140. package/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  141. package/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  142. package/dist/extraction/wasm/tree-sitter-terraform.wasm +0 -0
  143. package/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  144. package/dist/graph/traversal.d.ts.map +1 -1
  145. package/dist/graph/traversal.js +77 -18
  146. package/dist/graph/traversal.js.map +1 -1
  147. package/dist/index.d.ts +85 -2
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +426 -9
  150. package/dist/index.js.map +1 -1
  151. package/dist/installer/index.d.ts +42 -0
  152. package/dist/installer/index.d.ts.map +1 -1
  153. package/dist/installer/index.js +97 -2
  154. package/dist/installer/index.js.map +1 -1
  155. package/dist/mcp/daemon-paths.d.ts +30 -3
  156. package/dist/mcp/daemon-paths.d.ts.map +1 -1
  157. package/dist/mcp/daemon-paths.js +50 -10
  158. package/dist/mcp/daemon-paths.js.map +1 -1
  159. package/dist/mcp/daemon-registry.d.ts.map +1 -1
  160. package/dist/mcp/daemon-registry.js +7 -3
  161. package/dist/mcp/daemon-registry.js.map +1 -1
  162. package/dist/mcp/daemon.d.ts +73 -3
  163. package/dist/mcp/daemon.d.ts.map +1 -1
  164. package/dist/mcp/daemon.js +258 -40
  165. package/dist/mcp/daemon.js.map +1 -1
  166. package/dist/mcp/dynamic-boundaries.d.ts.map +1 -1
  167. package/dist/mcp/dynamic-boundaries.js +2 -1
  168. package/dist/mcp/dynamic-boundaries.js.map +1 -1
  169. package/dist/mcp/early-ppid.d.ts +26 -0
  170. package/dist/mcp/early-ppid.d.ts.map +1 -0
  171. package/dist/mcp/early-ppid.js +29 -0
  172. package/dist/mcp/early-ppid.js.map +1 -0
  173. package/dist/mcp/engine.d.ts +16 -0
  174. package/dist/mcp/engine.d.ts.map +1 -1
  175. package/dist/mcp/engine.js +100 -2
  176. package/dist/mcp/engine.js.map +1 -1
  177. package/dist/mcp/index.d.ts.map +1 -1
  178. package/dist/mcp/index.js +66 -49
  179. package/dist/mcp/index.js.map +1 -1
  180. package/dist/mcp/liveness-watchdog.d.ts +18 -1
  181. package/dist/mcp/liveness-watchdog.d.ts.map +1 -1
  182. package/dist/mcp/liveness-watchdog.js +73 -13
  183. package/dist/mcp/liveness-watchdog.js.map +1 -1
  184. package/dist/mcp/memory-budget.d.ts +30 -0
  185. package/dist/mcp/memory-budget.d.ts.map +1 -0
  186. package/dist/mcp/memory-budget.js +123 -0
  187. package/dist/mcp/memory-budget.js.map +1 -0
  188. package/dist/mcp/ppid-watchdog.d.ts +18 -0
  189. package/dist/mcp/ppid-watchdog.d.ts.map +1 -1
  190. package/dist/mcp/ppid-watchdog.js +37 -0
  191. package/dist/mcp/ppid-watchdog.js.map +1 -1
  192. package/dist/mcp/proxy.d.ts.map +1 -1
  193. package/dist/mcp/proxy.js +88 -4
  194. package/dist/mcp/proxy.js.map +1 -1
  195. package/dist/mcp/query-cache.d.ts +56 -0
  196. package/dist/mcp/query-cache.d.ts.map +1 -0
  197. package/dist/mcp/query-cache.js +261 -0
  198. package/dist/mcp/query-cache.js.map +1 -0
  199. package/dist/mcp/query-pool.d.ts +128 -0
  200. package/dist/mcp/query-pool.d.ts.map +1 -0
  201. package/dist/mcp/query-pool.js +374 -0
  202. package/dist/mcp/query-pool.js.map +1 -0
  203. package/dist/mcp/query-worker.d.ts +24 -0
  204. package/dist/mcp/query-worker.d.ts.map +1 -0
  205. package/dist/mcp/query-worker.js +87 -0
  206. package/dist/mcp/query-worker.js.map +1 -0
  207. package/dist/mcp/server-instructions.d.ts +4 -29
  208. package/dist/mcp/server-instructions.d.ts.map +1 -1
  209. package/dist/mcp/server-instructions.js +39 -86
  210. package/dist/mcp/server-instructions.js.map +1 -1
  211. package/dist/mcp/session.d.ts +14 -0
  212. package/dist/mcp/session.d.ts.map +1 -1
  213. package/dist/mcp/session.js +28 -1
  214. package/dist/mcp/session.js.map +1 -1
  215. package/dist/mcp/startup-handshake.d.ts +44 -0
  216. package/dist/mcp/startup-handshake.d.ts.map +1 -0
  217. package/dist/mcp/startup-handshake.js +73 -0
  218. package/dist/mcp/startup-handshake.js.map +1 -0
  219. package/dist/mcp/tools.d.ts +179 -2
  220. package/dist/mcp/tools.d.ts.map +1 -1
  221. package/dist/mcp/tools.js +3027 -192
  222. package/dist/mcp/tools.js.map +1 -1
  223. package/dist/mcp/transport.d.ts.map +1 -1
  224. package/dist/mcp/transport.js +18 -0
  225. package/dist/mcp/transport.js.map +1 -1
  226. package/dist/project-config.d.ts +58 -0
  227. package/dist/project-config.d.ts.map +1 -1
  228. package/dist/project-config.js +141 -2
  229. package/dist/project-config.js.map +1 -1
  230. package/dist/reasoning/login.js +1 -1
  231. package/dist/reasoning/login.js.map +1 -1
  232. package/dist/resolution/c-fnptr-synthesizer.d.ts +2 -29
  233. package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
  234. package/dist/resolution/c-fnptr-synthesizer.js +866 -148
  235. package/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
  236. package/dist/resolution/callback-synthesizer.d.ts +9 -1
  237. package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  238. package/dist/resolution/callback-synthesizer.js +1232 -183
  239. package/dist/resolution/callback-synthesizer.js.map +1 -1
  240. package/dist/resolution/cooperative-yield.d.ts +32 -0
  241. package/dist/resolution/cooperative-yield.d.ts.map +1 -0
  242. package/dist/resolution/cooperative-yield.js +42 -0
  243. package/dist/resolution/cooperative-yield.js.map +1 -0
  244. package/dist/resolution/frameworks/cics.d.ts +20 -0
  245. package/dist/resolution/frameworks/cics.d.ts.map +1 -0
  246. package/dist/resolution/frameworks/cics.js +90 -0
  247. package/dist/resolution/frameworks/cics.js.map +1 -0
  248. package/dist/resolution/frameworks/index.d.ts.map +1 -1
  249. package/dist/resolution/frameworks/index.js +3 -0
  250. package/dist/resolution/frameworks/index.js.map +1 -1
  251. package/dist/resolution/frameworks/java.d.ts.map +1 -1
  252. package/dist/resolution/frameworks/java.js +14 -6
  253. package/dist/resolution/frameworks/java.js.map +1 -1
  254. package/dist/resolution/frameworks/terraform.d.ts +38 -0
  255. package/dist/resolution/frameworks/terraform.d.ts.map +1 -0
  256. package/dist/resolution/frameworks/terraform.js +277 -0
  257. package/dist/resolution/frameworks/terraform.js.map +1 -0
  258. package/dist/resolution/goframe-synthesizer.d.ts +2 -1
  259. package/dist/resolution/goframe-synthesizer.d.ts.map +1 -1
  260. package/dist/resolution/goframe-synthesizer.js +8 -3
  261. package/dist/resolution/goframe-synthesizer.js.map +1 -1
  262. package/dist/resolution/import-resolver.d.ts +7 -0
  263. package/dist/resolution/import-resolver.d.ts.map +1 -1
  264. package/dist/resolution/import-resolver.js +129 -4
  265. package/dist/resolution/import-resolver.js.map +1 -1
  266. package/dist/resolution/index.d.ts +65 -3
  267. package/dist/resolution/index.d.ts.map +1 -1
  268. package/dist/resolution/index.js +477 -59
  269. package/dist/resolution/index.js.map +1 -1
  270. package/dist/resolution/name-matcher.d.ts +22 -3
  271. package/dist/resolution/name-matcher.d.ts.map +1 -1
  272. package/dist/resolution/name-matcher.js +657 -39
  273. package/dist/resolution/name-matcher.js.map +1 -1
  274. package/dist/resolution/strip-comments.d.ts +1 -1
  275. package/dist/resolution/strip-comments.d.ts.map +1 -1
  276. package/dist/resolution/strip-comments.js +49 -0
  277. package/dist/resolution/strip-comments.js.map +1 -1
  278. package/dist/resolution/types.d.ts +29 -0
  279. package/dist/resolution/types.d.ts.map +1 -1
  280. package/dist/resolution/workspace-packages.d.ts +10 -0
  281. package/dist/resolution/workspace-packages.d.ts.map +1 -1
  282. package/dist/resolution/workspace-packages.js +142 -4
  283. package/dist/resolution/workspace-packages.js.map +1 -1
  284. package/dist/search/identifier-segments.d.ts +60 -0
  285. package/dist/search/identifier-segments.d.ts.map +1 -0
  286. package/dist/search/identifier-segments.js +176 -0
  287. package/dist/search/identifier-segments.js.map +1 -0
  288. package/dist/search/query-utils.d.ts +201 -0
  289. package/dist/search/query-utils.d.ts.map +1 -1
  290. package/dist/search/query-utils.js +919 -2
  291. package/dist/search/query-utils.js.map +1 -1
  292. package/dist/spec/config.d.ts +7 -11
  293. package/dist/spec/config.d.ts.map +1 -1
  294. package/dist/spec/config.js +77 -48
  295. package/dist/spec/config.js.map +1 -1
  296. package/dist/spec/db/fragment-node.d.ts +1 -0
  297. package/dist/spec/db/fragment-node.d.ts.map +1 -1
  298. package/dist/spec/db/fragment-node.js +8 -0
  299. package/dist/spec/db/fragment-node.js.map +1 -1
  300. package/dist/spec/db/fts.d.ts +14 -0
  301. package/dist/spec/db/fts.d.ts.map +1 -1
  302. package/dist/spec/db/fts.js +43 -4
  303. package/dist/spec/db/fts.js.map +1 -1
  304. package/dist/spec/db/schema.d.ts +6 -3
  305. package/dist/spec/db/schema.d.ts.map +1 -1
  306. package/dist/spec/db/schema.js +36 -4
  307. package/dist/spec/db/schema.js.map +1 -1
  308. package/dist/spec/db/schema.sql +11 -1
  309. package/dist/spec/evolve/logic-checker.d.ts +2 -2
  310. package/dist/spec/evolve/logic-checker.d.ts.map +1 -1
  311. package/dist/spec/evolve/logic-checker.js +3 -27
  312. package/dist/spec/evolve/logic-checker.js.map +1 -1
  313. package/dist/spec/evolve/pipeline.d.ts +21 -19
  314. package/dist/spec/evolve/pipeline.d.ts.map +1 -1
  315. package/dist/spec/evolve/pipeline.js +284 -31
  316. package/dist/spec/evolve/pipeline.js.map +1 -1
  317. package/dist/spec/evolve/spec-rewriter.d.ts +2 -2
  318. package/dist/spec/evolve/spec-rewriter.d.ts.map +1 -1
  319. package/dist/spec/evolve/spec-rewriter.js +7 -31
  320. package/dist/spec/evolve/spec-rewriter.js.map +1 -1
  321. package/dist/spec/graph/queries.d.ts +97 -0
  322. package/dist/spec/graph/queries.d.ts.map +1 -1
  323. package/dist/spec/graph/queries.js +225 -0
  324. package/dist/spec/graph/queries.js.map +1 -1
  325. package/dist/spec/llm/client.d.ts +29 -0
  326. package/dist/spec/llm/client.d.ts.map +1 -0
  327. package/dist/spec/llm/client.js +123 -0
  328. package/dist/spec/llm/client.js.map +1 -0
  329. package/dist/spec/llm/index.d.ts +3 -0
  330. package/dist/spec/llm/index.d.ts.map +1 -0
  331. package/dist/spec/llm/index.js +11 -0
  332. package/dist/spec/llm/index.js.map +1 -0
  333. package/dist/spec/llm/prompts.d.ts +13 -0
  334. package/dist/spec/llm/prompts.d.ts.map +1 -0
  335. package/dist/spec/llm/prompts.js +75 -0
  336. package/dist/spec/llm/prompts.js.map +1 -0
  337. package/dist/spec/mining/git-scanner.d.ts +12 -0
  338. package/dist/spec/mining/git-scanner.d.ts.map +1 -1
  339. package/dist/spec/mining/git-scanner.js +41 -0
  340. package/dist/spec/mining/git-scanner.js.map +1 -1
  341. package/dist/spec/mining/pipeline.d.ts.map +1 -1
  342. package/dist/spec/mining/pipeline.js +14 -1
  343. package/dist/spec/mining/pipeline.js.map +1 -1
  344. package/dist/spec/utils.d.ts +2 -1
  345. package/dist/spec/utils.d.ts.map +1 -1
  346. package/dist/spec/utils.js +3 -1
  347. package/dist/spec/utils.js.map +1 -1
  348. package/dist/sync/git-hooks.d.ts.map +1 -1
  349. package/dist/sync/git-hooks.js +2 -0
  350. package/dist/sync/git-hooks.js.map +1 -1
  351. package/dist/sync/watcher.d.ts +10 -5
  352. package/dist/sync/watcher.d.ts.map +1 -1
  353. package/dist/sync/watcher.js +51 -14
  354. package/dist/sync/watcher.js.map +1 -1
  355. package/dist/sync/worktree.d.ts +9 -0
  356. package/dist/sync/worktree.d.ts.map +1 -1
  357. package/dist/sync/worktree.js +45 -0
  358. package/dist/sync/worktree.js.map +1 -1
  359. package/dist/types.d.ts +25 -2
  360. package/dist/types.d.ts.map +1 -1
  361. package/dist/types.js +10 -0
  362. package/dist/types.js.map +1 -1
  363. package/dist/ui/shimmer-progress.d.ts +2 -0
  364. package/dist/ui/shimmer-progress.d.ts.map +1 -1
  365. package/dist/ui/shimmer-progress.js +19 -2
  366. package/dist/ui/shimmer-progress.js.map +1 -1
  367. package/dist/upgrade/index.d.ts +32 -0
  368. package/dist/upgrade/index.d.ts.map +1 -1
  369. package/dist/upgrade/index.js +163 -15
  370. package/dist/upgrade/index.js.map +1 -1
  371. package/dist/upgrade/remove-binary.d.ts +87 -0
  372. package/dist/upgrade/remove-binary.d.ts.map +1 -0
  373. package/dist/upgrade/remove-binary.js +289 -0
  374. package/dist/upgrade/remove-binary.js.map +1 -0
  375. package/dist/upgrade/update-check.d.ts +92 -0
  376. package/dist/upgrade/update-check.d.ts.map +1 -0
  377. package/dist/upgrade/update-check.js +258 -0
  378. package/dist/upgrade/update-check.js.map +1 -0
  379. package/package.json +4 -4
  380. package/scripts/agent-eval/repro-concurrent-explore.mjs +119 -0
  381. package/scripts/agent-eval/repro-daemon-clients.mjs +125 -0
  382. package/scripts/agent-eval/run-all.sh +6 -0
  383. package/scripts/build-bundle.sh +5 -0
  384. package/scripts/exp_boundary_eval/README.md +247 -0
  385. package/scripts/exp_boundary_eval/_test_mcp_chain.py +78 -0
  386. package/scripts/exp_boundary_eval/_test_stdin.py +8 -0
  387. package/scripts/exp_boundary_eval/_utils.py +1116 -0
  388. package/scripts/exp_boundary_eval/analyze.py +1313 -0
  389. package/scripts/exp_boundary_eval/deveco_arm.py +519 -0
  390. package/scripts/exp_boundary_eval/run_all.py +378 -0
  391. package/scripts/exp_boundary_eval/run_one.py +165 -0
  392. package/scripts/exp_boundary_eval/run_session.py +158 -0
  393. package/scripts/exp_boundary_eval/setup.py +120 -0
  394. package/scripts/exp_boundary_eval/win_mcp_launcher.py +73 -0
  395. package/scripts/exp_boundary_eval/win_mcp_stdio_wrap.js +36 -0
  396. package/scripts/exp_boundary_eval/win_node_launcher.py +24 -0
  397. package/scripts/npm-shim.js +32 -3
  398. package/scripts/ohos-sdk-publish.mjs +133 -0
  399. package/scripts/qa_eval/README.md +0 -407
  400. package/scripts/qa_eval/_test_deveco_probe.py +0 -41
  401. package/scripts/qa_eval/agent_runner.py +0 -526
  402. package/scripts/qa_eval/data/.gitignore +0 -4
  403. package/scripts/qa_eval/data/test-set.jsonl +0 -2
  404. package/scripts/qa_eval/eval_metrics.py +0 -274
  405. package/scripts/qa_eval/external_agent.py +0 -976
  406. package/scripts/qa_eval/llm_config.py +0 -92
  407. package/scripts/qa_eval/memory_monitor.py +0 -132
  408. package/scripts/qa_eval/my_answer_accuracy.py +0 -187
  409. package/scripts/qa_eval/requirements.txt +0 -2
  410. package/scripts/qa_eval/run_pipeline.py +0 -804
  411. package/scripts/qa_eval/stats_efficiency.py +0 -279
  412. 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
@@ -24,12 +28,15 @@ const query_utils_1 = require("../search/query-utils");
24
28
  const fs_1 = require("fs");
25
29
  const utils_1 = require("../utils");
26
30
  const generated_detection_1 = require("../extraction/generated-detection");
31
+ const arkts_1 = require("../extraction/languages/arkts");
27
32
  const dynamic_boundaries_1 = require("./dynamic-boundaries");
33
+ const query_cache_1 = require("./query-cache");
28
34
  /** ViewTree structural `references` vias — not UI event bindings. */
29
35
  const VIEWTREE_STRUCTURE_VIAS = new Set([
30
36
  'child-component',
31
37
  'state-binding',
32
- 'prop-transfer',
38
+ 'Prop',
39
+ 'Link',
33
40
  'builder',
34
41
  'builder-param',
35
42
  ]);
@@ -93,6 +100,25 @@ function lastQualifierPart(symbol) {
93
100
  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
94
101
  return parts[parts.length - 1] ?? symbol;
95
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
+ }
96
122
  /**
97
123
  * Calculate the recommended number of homegraph_explore calls based on project size.
98
124
  * Larger codebases need more exploration calls to cover their surface area,
@@ -208,6 +234,46 @@ function getExploreOutputBudget(fileCount) {
208
234
  excludeLowValueFiles: false,
209
235
  };
210
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
+ }
211
277
  /**
212
278
  * Whether `homegraph_explore` should prefix source lines with their line
213
279
  * numbers (cat -n style: `<num>\t<code>`).
@@ -275,6 +341,29 @@ function numberSourceLines(slice, firstLineNumber) {
275
341
  }
276
342
  return out.join('\n');
277
343
  }
344
+ /** Primary signature line (first line when overloads are stored newline-separated). */
345
+ function primarySignatureLine(signature) {
346
+ return signature.split('\n')[0]?.trim() ?? signature.trim();
347
+ }
348
+ /** Render a stored signature (single line or newline-separated overloads) for MCP output. */
349
+ function formatNodeSignatureBlock(signature) {
350
+ const lines = signature.split('\n').map((l) => l.trim()).filter(Boolean);
351
+ if (lines.length <= 1) {
352
+ return lines.length ? [`**Signature:** \`${lines[0]}\``] : [];
353
+ }
354
+ return [
355
+ '**Signature:**',
356
+ `- \`${lines[0]}\` (primary)`,
357
+ ...lines.slice(1).map((l) => `- \`${l}\` (overload)`),
358
+ ];
359
+ }
360
+ function formatInlineSignature(signature) {
361
+ const primary = primarySignatureLine(signature);
362
+ const overloadCount = signature.split('\n').filter((l) => l.trim()).length - 1;
363
+ if (overloadCount <= 0)
364
+ return primary;
365
+ return `${primary} (+${overloadCount} overload${overloadCount === 1 ? '' : 's'})`;
366
+ }
278
367
  /**
279
368
  * Unique line-prefix for a per-file source section in homegraph_explore output.
280
369
  * Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
@@ -286,6 +375,12 @@ function numberSourceLines(slice, firstLineNumber) {
286
375
  * (`reasoning/reasoner.ts`) both key off to cut on whole file sections.
287
376
  */
288
377
  const FILE_SECTION_PREFIX = '**`';
378
+ // Placeholder for codegraph_explore's "Found N symbols across M files." line.
379
+ // The honest N/M can only be known after the final truncation drops trailing
380
+ // sections (#1046), so the header is emitted as this sentinel and substituted
381
+ // at the very end. This bracketed token never occurs in rendered source or a
382
+ // file path, so the final string-replace can't collide.
383
+ const SUMMARY_SENTINEL = '[[codegraph-explore-summary]]';
289
384
  function fileSectionHeader(filePath, suffix) {
290
385
  return suffix
291
386
  ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
@@ -297,18 +392,26 @@ function fileSectionHeader(filePath, suffix) {
297
392
  * The agent uses this to fall back to Read for those specific files
298
393
  * without waiting for the debounced sync (issue #403).
299
394
  */
300
- function formatStaleBanner(stale) {
395
+ function formatStaleBanner(stale, opts) {
301
396
  const now = Date.now();
302
397
  const lines = stale.map((p) => {
303
398
  const ageMs = Math.max(0, now - p.lastSeenMs);
304
399
  const label = p.indexing ? 'indexing in progress' : 'pending sync';
305
400
  return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
306
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.';
307
410
  return ('⚠️ Some files referenced below were edited since the last index sync — ' +
308
411
  'their homegraph entries may be stale:\n' +
309
412
  lines.join('\n') +
310
- '\nFor accurate content of those specific files, Read them directly. ' +
311
- 'The rest of this response is fresh.');
413
+ '\n' +
414
+ guidance);
312
415
  }
313
416
  /**
314
417
  * Compact footer listing pending files that are NOT referenced in this
@@ -348,6 +451,23 @@ const projectPathProperty = {
348
451
  type: 'string',
349
452
  description: 'Absolute path to the project to query (or any directory inside it) — homegraph uses the nearest .homegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
350
453
  };
454
+ /**
455
+ * EVERY homegraph tool is query-only: it reads the pre-built index and never
456
+ * mutates the workspace (indexing is the user's explicit CLI call, never the
457
+ * agent's). Advertising this read-only contract lets clients that gate on it run
458
+ * the tools where a possibly-mutating tool would be blocked — most concretely,
459
+ * Cursor's Ask mode, which rejects any MCP tool lacking `readOnlyHint: true`
460
+ * (issue #1018). `idempotentHint`: a repeated query has no additional effect.
461
+ * `openWorldHint: false`: the domain is the closed local index, not an open
462
+ * external world. Shared so the contract is declared once; a hypothetical
463
+ * mutating tool would simply not reference it.
464
+ */
465
+ const READ_ONLY_ANNOTATIONS = {
466
+ readOnlyHint: true,
467
+ destructiveHint: false,
468
+ idempotentHint: true,
469
+ openWorldHint: false,
470
+ };
351
471
  /**
352
472
  * All HomeGraph MCP tools
353
473
  *
@@ -360,7 +480,7 @@ const projectPathProperty = {
360
480
  exports.tools = [
361
481
  {
362
482
  name: 'homegraph_search',
363
- 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.',
364
484
  inputSchema: {
365
485
  type: 'object',
366
486
  properties: {
@@ -382,10 +502,11 @@ exports.tools = [
382
502
  },
383
503
  required: ['query'],
384
504
  },
505
+ annotations: READ_ONLY_ANNOTATIONS,
385
506
  },
386
507
  {
387
508
  name: 'homegraph_callers',
388
- 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.',
389
510
  inputSchema: {
390
511
  type: 'object',
391
512
  properties: {
@@ -406,10 +527,11 @@ exports.tools = [
406
527
  },
407
528
  required: ['symbol'],
408
529
  },
530
+ annotations: READ_ONLY_ANNOTATIONS,
409
531
  },
410
532
  {
411
533
  name: 'homegraph_callees',
412
- 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.',
413
535
  inputSchema: {
414
536
  type: 'object',
415
537
  properties: {
@@ -430,10 +552,11 @@ exports.tools = [
430
552
  },
431
553
  required: ['symbol'],
432
554
  },
555
+ annotations: READ_ONLY_ANNOTATIONS,
433
556
  },
434
557
  {
435
558
  name: 'homegraph_impact',
436
- 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.',
437
560
  inputSchema: {
438
561
  type: 'object',
439
562
  properties: {
@@ -454,10 +577,11 @@ exports.tools = [
454
577
  },
455
578
  required: ['symbol'],
456
579
  },
580
+ annotations: READ_ONLY_ANNOTATIONS,
457
581
  },
458
582
  {
459
583
  name: 'homegraph_node',
460
- 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.',
461
585
  inputSchema: {
462
586
  type: 'object',
463
587
  properties: {
@@ -495,16 +619,17 @@ exports.tools = [
495
619
  },
496
620
  required: [],
497
621
  },
622
+ annotations: READ_ONLY_ANNOTATIONS,
498
623
  },
499
624
  {
500
625
  name: 'homegraph_explore',
501
- 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.',
502
627
  inputSchema: {
503
628
  type: 'object',
504
629
  properties: {
505
630
  query: {
506
631
  type: 'string',
507
- 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.',
508
633
  },
509
634
  maxFiles: {
510
635
  type: 'number',
@@ -515,6 +640,7 @@ exports.tools = [
515
640
  },
516
641
  required: ['query'],
517
642
  },
643
+ annotations: READ_ONLY_ANNOTATIONS,
518
644
  },
519
645
  {
520
646
  name: 'homegraph_status',
@@ -525,10 +651,11 @@ exports.tools = [
525
651
  projectPath: projectPathProperty,
526
652
  },
527
653
  },
654
+ annotations: READ_ONLY_ANNOTATIONS,
528
655
  },
529
656
  {
530
657
  name: 'homegraph_files',
531
- 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.',
532
659
  inputSchema: {
533
660
  type: 'object',
534
661
  properties: {
@@ -558,6 +685,7 @@ exports.tools = [
558
685
  projectPath: projectPathProperty,
559
686
  },
560
687
  },
688
+ annotations: READ_ONLY_ANNOTATIONS,
561
689
  },
562
690
  {
563
691
  name: 'homegraph_spec_match',
@@ -592,8 +720,108 @@ exports.tools = [
592
720
  },
593
721
  required: ['query'],
594
722
  },
723
+ annotations: READ_ONLY_ANNOTATIONS,
724
+ },
725
+ {
726
+ name: 'homegraph_spec_find',
727
+ description: 'Find which specs are related to the given file path by matching against code-fragment file paths ' +
728
+ 'in the Commit4Spec knowledge graph. Traverses code_fragment_nodes → commit_fragment_relations ' +
729
+ '→ spec_commit_relations → spec_nodes. Useful for answering "which specs does this file affect?" ' +
730
+ 'The database defaults to .homegraph/commit4spec/commit4spec.db under the repo path.',
731
+ inputSchema: {
732
+ type: 'object',
733
+ properties: {
734
+ filePath: {
735
+ type: 'string',
736
+ description: 'File path to look up (substring LIKE match). E.g. "src/auth.ts" or "src/auth".',
737
+ },
738
+ repoPath: {
739
+ type: 'string',
740
+ description: 'Path to the repository root. Defaults to the current working directory.',
741
+ },
742
+ dbPath: {
743
+ type: 'string',
744
+ description: 'Explicit path to the Commit4Spec database. Overrides repoPath-based resolution.',
745
+ },
746
+ },
747
+ required: ['filePath'],
748
+ },
749
+ annotations: READ_ONLY_ANNOTATIONS,
750
+ },
751
+ {
752
+ name: 'homegraph_spec_trace',
753
+ description: 'Trace a code symbol (function, method, class) back to its associated design Specs in the Commit4Spec ' +
754
+ 'knowledge graph. Resolves the symbol via the HomeGraph code index, then matches against code-fragment ' +
755
+ 'records in the Spec database using five-dimensional scoring: file-path match, code-diff content search ' +
756
+ '(FTS5), Spec title/subtitle name match, Spec recency, and line-range overlap. ' +
757
+ 'Returns ranked Specs with score breakdowns even when exact line overlap is absent — code drifts over ' +
758
+ 'time, so recency and content matching compensate. ' +
759
+ 'The Spec DB defaults to .homegraph/commit4spec/commit4spec.db under the repo path.',
760
+ inputSchema: {
761
+ type: 'object',
762
+ properties: {
763
+ symbol: {
764
+ type: 'string',
765
+ description: 'Symbol name (bare or qualified). E.g. "authenticate", "AuthService.login", "auth::validate".',
766
+ },
767
+ file: {
768
+ type: 'string',
769
+ description: 'File path for disambiguation when multiple symbols share the same name (optional).',
770
+ },
771
+ line: {
772
+ type: 'number',
773
+ description: 'Line number for disambiguation (optional).',
774
+ },
775
+ repoPath: {
776
+ type: 'string',
777
+ description: 'Path to the repository root. Defaults to the current working directory.',
778
+ },
779
+ topK: {
780
+ type: 'number',
781
+ description: 'Maximum number of matching Specs to return (default: 10).',
782
+ default: 10,
783
+ },
784
+ dbPath: {
785
+ type: 'string',
786
+ description: 'Explicit path to the Commit4Spec database. Overrides repoPath-based resolution.',
787
+ },
788
+ },
789
+ required: ['symbol'],
790
+ },
791
+ annotations: READ_ONLY_ANNOTATIONS,
595
792
  },
596
793
  ];
794
+ /**
795
+ * Return `defs` with `projectPath` marked `required` in each tool's inputSchema.
796
+ *
797
+ * Used for the NO-DEFAULT-PROJECT tool surface (issue #993): when the MCP server
798
+ * has no default project to fall back to — a gateway server started outside any
799
+ * repo, or a monorepo root whose `.codegraph/` indexes live only in sub-projects
800
+ * — every call MUST carry an explicit `projectPath`, so the schema should say so.
801
+ * A `required` field is a HIGH-salience channel (MCP clients surface and often
802
+ * validate it), unlike the instructions text the reporter found too weak to stop
803
+ * the agent omitting the param. When a default project IS open, callers leave
804
+ * projectPath optional and never call this.
805
+ *
806
+ * Pure: clones each tool's schema rather than mutating the shared module-level
807
+ * `tools` array (reused by every session and the static surface). A tool that
808
+ * doesn't expose projectPath, or already requires it, is returned untouched;
809
+ * explore's `['query']` becomes `['query', 'projectPath']`, and a tool with no
810
+ * `required` list (status/files) gains `['projectPath']`.
811
+ */
812
+ function withRequiredProjectPath(defs) {
813
+ return defs.map((tool) => {
814
+ if (!tool.inputSchema.properties.projectPath)
815
+ return tool;
816
+ const required = tool.inputSchema.required ?? [];
817
+ if (required.includes('projectPath'))
818
+ return tool;
819
+ return {
820
+ ...tool,
821
+ inputSchema: { ...tool.inputSchema, required: [...required, 'projectPath'] },
822
+ };
823
+ });
824
+ }
597
825
  /**
598
826
  * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
599
827
  * proxy answers `tools/list` with before any project is open. Mirrors
@@ -637,9 +865,23 @@ class ToolHandler {
637
865
  // huge repo can't hang the first call (#905); cleared on first await so
638
866
  // subsequent calls don't pay any cost.
639
867
  catchUpGate = null;
868
+ // Optional worker-thread pool for off-loop read-tool dispatch (daemon mode).
869
+ // When set + healthy, the heavy read tools run on a worker so the daemon's
870
+ // main loop stays free for the MCP transport under concurrent load. Null in
871
+ // direct/in-process mode (one client, no concurrency to parallelize).
872
+ queryPool = null;
640
873
  constructor(cg) {
641
874
  this.cg = cg;
642
875
  }
876
+ /**
877
+ * Engine-only: attach (or detach with null) the worker-thread query pool. The
878
+ * shared daemon sets this once its default project is open; the workers each
879
+ * hold their own WAL read connection and run {@link executeReadTool}. A
880
+ * worker's own ToolHandler never has a pool, so there is no nested off-loading.
881
+ */
882
+ setQueryPool(pool) {
883
+ this.queryPool = pool;
884
+ }
643
885
  /**
644
886
  * Update the default HomeGraph instance (e.g. after lazy initialization)
645
887
  */
@@ -743,8 +985,18 @@ class ToolHandler {
743
985
  let visible = allow
744
986
  ? exports.tools.filter(t => allow.has(t.name.replace(/^homegraph_/, '')))
745
987
  : exports.tools;
988
+ // No default project loaded → no-root-index case (#993): a gateway server
989
+ // started outside any repo, or a monorepo root whose indexes live in
990
+ // sub-projects. With nothing to fall back to, EVERY call needs an explicit
991
+ // projectPath, so mark it required in the schema — a high-salience nudge the
992
+ // agent acts on, where SERVER_INSTRUCTIONS_NO_ROOT_INDEX's prose alone
993
+ // wasn't enough (the reporter had to add an AGENTS.md note). `this.cg` is
994
+ // settled by `retryInitIfNeeded()` before `handleToolsList` calls us, so a
995
+ // null here means "genuinely no default", not a startup race. When a default
996
+ // IS open we leave projectPath optional (below): a bare call falls back to
997
+ // it, exactly as in the common single-project launch.
746
998
  if (!this.cg)
747
- return visible;
999
+ return withRequiredProjectPath(visible);
748
1000
  try {
749
1001
  const stats = this.cg.getStats();
750
1002
  const budget = getExploreBudget(stats.fileCount);
@@ -883,6 +1135,7 @@ class ToolHandler {
883
1135
  freshen(cg) {
884
1136
  try {
885
1137
  if (cg.reopenIfReplaced()) {
1138
+ (0, query_cache_1.getMcpQueryCacheIndex)(cg.getProjectRoot()).reset();
886
1139
  process.stderr.write('[HomeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
887
1140
  'recreated at the same path); reopened the live database in place.\n');
888
1141
  }
@@ -1089,7 +1342,18 @@ class ToolHandler {
1089
1342
  }
1090
1343
  let banner = '';
1091
1344
  if (inResponse.length > 0) {
1092
- 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
+ });
1093
1357
  }
1094
1358
  let footer = '';
1095
1359
  if (elsewhere.length > 0) {
@@ -1118,6 +1382,11 @@ class ToolHandler {
1118
1382
  this.catchUpGate = null;
1119
1383
  await this.awaitCatchUpGate(gate);
1120
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
+ }
1121
1390
  // Honor the optional tool allowlist (HOMEGRAPH_MCP_TOOLS): a trimmed
1122
1391
  // surface rejects ablated tools defensively even if a client cached them.
1123
1392
  if (!this.isToolAllowed(toolName)) {
@@ -1143,48 +1412,83 @@ class ToolHandler {
1143
1412
  if (typeof check === 'object' && check !== undefined)
1144
1413
  return check;
1145
1414
  }
1146
- // Read tools resolve through a single result variable so cross-cutting
1147
- // notices worktree-index mismatch (issue #155) and per-file
1148
- // staleness (issue #403) — can be applied in one place. status embeds
1149
- // its own verbose worktree warning but still flows through the
1150
- // staleness wrapper so its pending-files section stays consistent
1151
- // with what the read tools surface.
1152
- let result;
1153
- switch (toolName) {
1154
- case 'homegraph_search':
1155
- result = await this.handleSearch(args);
1156
- break;
1157
- case 'homegraph_callers':
1158
- result = await this.handleCallers(args);
1159
- break;
1160
- case 'homegraph_callees':
1161
- result = await this.handleCallees(args);
1162
- break;
1163
- case 'homegraph_impact':
1164
- result = await this.handleImpact(args);
1165
- break;
1166
- case 'homegraph_explore':
1167
- result = await this.handleExplore(args);
1168
- break;
1169
- case 'homegraph_node':
1170
- result = await this.handleNode(args);
1171
- break;
1172
- case 'homegraph_status':
1173
- // status embeds the pending-files list as a first-class section
1174
- // (see handleStatus), so we skip the auto-banner wrapper here to
1175
- // avoid duplicating the same info at the top of the response.
1176
- return await this.handleStatus(args);
1177
- case 'homegraph_files':
1178
- result = await this.handleFiles(args);
1179
- break;
1180
- case 'homegraph_spec_match':
1181
- result = await this.handleSpecMatch(args);
1182
- break;
1183
- default:
1184
- return this.errorResult(`Unknown tool: ${toolName}`);
1415
+ const projectPath = args.projectPath;
1416
+ const cacheEnabled = (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
1417
+ let cacheKey;
1418
+ let cacheQueries;
1419
+ let cacheIndex;
1420
+ if (cacheEnabled) {
1421
+ try {
1422
+ const cacheCg = this.getHomeGraph(projectPath);
1423
+ cacheQueries = cacheCg.getQueryBuilder();
1424
+ cacheIndex = (0, query_cache_1.getMcpQueryCacheIndex)(cacheCg.getProjectRoot());
1425
+ cacheIndex.ensureValid(cacheQueries, () => cacheCg.getLastIndexedAt());
1426
+ let fileCount;
1427
+ try {
1428
+ fileCount = cacheCg.getStats().fileCount;
1429
+ }
1430
+ catch {
1431
+ fileCount = undefined;
1432
+ }
1433
+ cacheKey = (0, query_cache_1.buildMcpQueryCacheKey)(toolName, args, fileCount);
1434
+ const cached = cacheIndex.getEntry(cacheQueries, cacheKey);
1435
+ if (cached) {
1436
+ const withWorktree = this.withWorktreeNotice(cached, projectPath);
1437
+ return this.withStalenessNotice(withWorktree, projectPath);
1438
+ }
1439
+ }
1440
+ catch {
1441
+ // No indexed project — fall through; handler returns guidance.
1442
+ }
1443
+ }
1444
+ // homegraph_status reports watcher state (pending files, degraded mode,
1445
+ // worktree warning) and embeds its own sections — it must run on the MAIN
1446
+ // thread against the watched default instance, so it is NEVER off-loaded to
1447
+ // a worker (whose read connection has no watcher). It also skips the
1448
+ // auto-banner wrapper to avoid duplicating its own pending-files section.
1449
+ if (toolName === 'homegraph_status') {
1450
+ return await this.handleStatus(args);
1451
+ }
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);
1185
1489
  }
1186
- const withWorktree = this.withWorktreeNotice(result, args.projectPath);
1187
- return this.withStalenessNotice(withWorktree, args.projectPath);
1490
+ const withWorktree = this.withWorktreeNotice(result, projectPath);
1491
+ return this.withStalenessNotice(withWorktree, projectPath);
1188
1492
  }
1189
1493
  catch (err) {
1190
1494
  // Expected condition, not a malfunction: answer as a SUCCESS so the
@@ -1203,20 +1507,151 @@ class ToolHandler {
1203
1507
  }
1204
1508
  }
1205
1509
  /**
1206
- * Handle homegraph_search
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
+ }
1567
+ /**
1568
+ * Run a single read tool to completion and return its raw {@link ToolResult},
1569
+ * classifying expected failures the same way {@link execute}'s catch does so
1570
+ * the SHAPE is identical whether dispatch runs in-process or on a worker:
1571
+ * NotIndexed → success-shaped guidance, PathRefusal → clean error, anything
1572
+ * else → internal-error-with-retry. Never throws.
1573
+ *
1574
+ * This is the worker thread's entry point (see {@link ./query-worker}) and the
1575
+ * in-process fallback for {@link execute}. It deliberately does NOT run the
1576
+ * catch-up gate or the staleness/worktree notices — those need the daemon's
1577
+ * watched main instance and stay on the main thread. Cross-cutting allowlist +
1578
+ * path validation already ran in {@link execute} before routing here.
1579
+ */
1580
+ async executeReadTool(toolName, args) {
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;
1588
+ return await this.dispatchTool(toolName, args);
1589
+ }
1590
+ catch (err) {
1591
+ if (err instanceof NotIndexedError) {
1592
+ return this.textResult(err.message);
1593
+ }
1594
+ if (err instanceof PathRefusalError) {
1595
+ return this.errorResult(err.message);
1596
+ }
1597
+ return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
1598
+ 'This is an internal homegraph error — retry the call once; if it persists, ' +
1599
+ 'continue without homegraph for this task.');
1600
+ }
1601
+ }
1602
+ /**
1603
+ * Pure dispatch over the read tools — the switch, with no gate, no notices, no
1604
+ * allowlist/validation (the caller owns those). `homegraph_status` is handled
1605
+ * on the main thread in {@link execute} and never reaches here. May throw
1606
+ * NotIndexed/PathRefusal, which {@link executeReadTool} classifies.
1207
1607
  */
1608
+ async dispatchTool(toolName, args) {
1609
+ switch (toolName) {
1610
+ case 'homegraph_search': return await this.handleSearch(args);
1611
+ case 'homegraph_callers': return await this.handleCallers(args);
1612
+ case 'homegraph_callees': return await this.handleCallees(args);
1613
+ case 'homegraph_impact': return await this.handleImpact(args);
1614
+ case 'homegraph_explore': return await this.handleExplore(args);
1615
+ case 'homegraph_node': return await this.handleNode(args);
1616
+ case 'homegraph_files': return await this.handleFiles(args);
1617
+ case 'homegraph_spec_match': return await this.handleSpecMatch(args);
1618
+ case 'homegraph_spec_find': return await this.handleSpecFind(args);
1619
+ case 'homegraph_spec_trace': return await this.handleSpecTrace(args);
1620
+ default: return this.errorResult(`Unknown tool: ${toolName}`);
1621
+ }
1622
+ }
1208
1623
  async handleSearch(args) {
1209
1624
  const query = this.validateString(args.query, 'query');
1210
1625
  if (typeof query !== 'string')
1211
1626
  return query;
1212
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
+ }
1213
1648
  const rawKind = args.kind;
1214
1649
  // The schema enum says 'type' (what agents naturally reach for); the
1215
1650
  // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
1216
1651
  // matched nothing — a filter value we advertise must work.
1217
1652
  const kind = rawKind === 'type' ? 'type_alias' : rawKind;
1218
- const rawLimit = Number(args.limit) || 10;
1219
- const limit = (0, utils_1.clamp)(rawLimit, 1, 100);
1653
+ const rawLimit = Number(args.limit);
1654
+ const limit = (0, utils_1.clamp)(isNaN(rawLimit) ? 10 : rawLimit, 1, 100);
1220
1655
  const results = cg.searchNodes(query, {
1221
1656
  limit,
1222
1657
  kinds: kind ? [kind] : undefined,
@@ -1233,7 +1668,9 @@ class ToolHandler {
1233
1668
  return aGen - bGen;
1234
1669
  });
1235
1670
  const formatted = this.formatSearchResults(ranked);
1236
- 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));
1237
1674
  }
1238
1675
  /**
1239
1676
  * Group symbol matches into DISTINCT DEFINITIONS — one group per
@@ -1295,6 +1732,8 @@ class ToolHandler {
1295
1732
  const callers = [];
1296
1733
  const labels = new Map();
1297
1734
  for (const node of defNodes) {
1735
+ if ((0, memory_budget_1.isOverRssBudget)())
1736
+ break;
1298
1737
  for (const c of cg.getCallers(node.id)) {
1299
1738
  if (!seen.has(c.node.id)) {
1300
1739
  seen.add(c.node.id);
@@ -1303,6 +1742,8 @@ class ToolHandler {
1303
1742
  if (label)
1304
1743
  labels.set(c.node.id, label);
1305
1744
  }
1745
+ if (callers.length >= limit)
1746
+ return { callers, labels };
1306
1747
  }
1307
1748
  }
1308
1749
  return { callers, labels };
@@ -1316,7 +1757,9 @@ class ToolHandler {
1316
1757
  // A successful `file` narrowing makes the multi-symbol aggregation note
1317
1758
  // stale — suppress it.
1318
1759
  const note = fileFilter && !filteredOut ? '' : allMatches.note;
1319
- 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;
1320
1763
  return this.textResult(this.truncateOutput(formatted));
1321
1764
  }
1322
1765
  // Multiple DISTINCT definitions (#764): one section per definition so an
@@ -1338,7 +1781,7 @@ class ToolHandler {
1338
1781
  lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
1339
1782
  }
1340
1783
  }
1341
- 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.'));
1342
1785
  }
1343
1786
  /**
1344
1787
  * Handle homegraph_callees
@@ -1362,6 +1805,12 @@ class ToolHandler {
1362
1805
  const seen = new Set();
1363
1806
  const callees = [];
1364
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
+ });
1365
1814
  for (const node of defNodes) {
1366
1815
  for (const c of cg.getCallees(node.id)) {
1367
1816
  if (!seen.has(c.node.id)) {
@@ -1373,7 +1822,10 @@ class ToolHandler {
1373
1822
  }
1374
1823
  }
1375
1824
  }
1376
- 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 };
1377
1829
  };
1378
1830
  if (groups.length === 1) {
1379
1831
  const { callees, labels } = collect(groups[0]);
@@ -1526,6 +1978,20 @@ class ToolHandler {
1526
1978
  }
1527
1979
  if (m?.synthesizedBy === 'viewtree') {
1528
1980
  const via = typeof m.via === 'string' ? m.via : '';
1981
+ if (via === 'Prop') {
1982
+ return {
1983
+ label: `@Prop one-way state transfer (parent → child)`,
1984
+ compact: `state: @Prop one-way${at}`,
1985
+ registeredAt,
1986
+ };
1987
+ }
1988
+ if (via === 'Link') {
1989
+ return {
1990
+ label: `@Link two-way state transfer (parent ↔ child)`,
1991
+ compact: `state: @Link two-way${at}`,
1992
+ registeredAt,
1993
+ };
1994
+ }
1529
1995
  if (via && !VIEWTREE_STRUCTURE_VIAS.has(via)) {
1530
1996
  return {
1531
1997
  label: `ArkUI event \`.${via}\` — bound handler (dynamic dispatch)`,
@@ -2146,76 +2612,1650 @@ class ToolHandler {
2146
2612
  return `candidates for key \`${key}\`: ${list.join(', ')}`;
2147
2613
  }
2148
2614
  /**
2149
- * Compact "blast radius" for the entry symbols of an explore result: who
2150
- * depends on each (callers) and which test files cover it LOCATIONS ONLY,
2151
- * no source, so the agent knows what to update / re-verify before editing
2152
- * without reaching for a separate impact call. Always-on, but skips symbols
2153
- * that have no dependents (nothing to warn about), and returns '' when none
2154
- * qualify so a leaf-only exploration stays clean.
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.
2155
2617
  */
2156
- buildBlastRadiusSection(cg, subgraph) {
2157
- const ROOT_CAP = 5; // only the symbols the query actually targeted
2158
- const FILE_CAP = 4; // caller files listed per symbol before "+N more"
2159
- const MEANINGFUL = new Set([
2160
- 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol',
2161
- 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
2162
- ]);
2163
- const rel = (p) => p.replace(/\\/g, '/');
2164
- const roots = subgraph.roots
2165
- .map((id) => subgraph.nodes.get(id))
2166
- .filter((n) => !!n && MEANINGFUL.has(n.kind))
2167
- .slice(0, ROOT_CAP);
2168
- if (roots.length === 0)
2169
- return '';
2170
- const entries = [];
2171
- for (const root of roots) {
2172
- let callers = [];
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 = [];
2173
2652
  try {
2174
- callers = cg.getCallers(root.id);
2653
+ hits = cg.searchNodes(sym, { kinds: ['import'], limit: importLimit });
2175
2654
  }
2176
- catch { /* skip this root */ }
2177
- const seen = new Set();
2178
- const uniq = [];
2179
- for (const c of callers) {
2180
- if (c?.node && !seen.has(c.node.id)) {
2181
- seen.add(c.node.id);
2182
- uniq.push(c.node);
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);
2183
2683
  }
2184
2684
  }
2185
- if (uniq.length === 0)
2186
- continue; // no blast radius → nothing to flag
2187
- const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
2188
- const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
2189
- const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
2190
- const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
2191
- const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
2192
- const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
2193
- const tests = testFiles.length > 0
2194
- ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
2195
- : '; ⚠️ no covering tests found';
2196
- entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
2197
2685
  }
2198
- if (entries.length === 0)
2199
- return '';
2200
- return [
2201
- '**Blast radius what depends on these (update/verify before editing)**',
2202
- '',
2203
- ...entries,
2204
- '',
2205
- ].join('\n');
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 };
2206
2719
  }
2207
2720
  /**
2208
- * Graph-connectivity relevance via Random-Walk-with-Restart (personalized
2209
- * PageRank) from the query's matched SEED nodes over the call/reference graph.
2210
- *
2211
- * This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
2212
- * homegraph's home turf: relevance by STRUCTURE, not words. A file whose
2213
- * symbols are call-connected to the matched cluster accrues walk mass and
2214
- * ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
2215
- * "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
2216
- * gets only its own restart probability and ranks ~0. Immune to the
2217
- * tokenization trap that fools term matching, deterministic, no embeddings.
2218
- *
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))
4022
+ continue;
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;
4031
+ try {
4032
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
4033
+ }
4034
+ catch {
4035
+ continue;
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
+ }
4042
+ }
4043
+ }
4044
+ if (hits.size === 0)
4045
+ return { section: '', fileCount: 0 };
4046
+ const lines = [
4047
+ '**API usage sites**',
4048
+ '',
4049
+ `> Files referencing \`${symbols.join('`, `')}\` in this repo (${hits.size} file(s)). Answer from this list; source bodies omitted.`,
4050
+ '',
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 };
4068
+ }
4069
+ /**
4070
+ * Domain file inventory — related files, usage existence, or concept comparison.
4071
+ * Paths + top symbols only; source bodies omitted when this section renders.
4072
+ */
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))
4081
+ return;
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);
4089
+ };
4090
+ for (const term of terms) {
4091
+ let hits = [];
4092
+ try {
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);
4100
+ }
4101
+ }
4102
+ try {
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
+ }
4111
+ }
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}`);
4127
+ }
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;
4165
+ try {
4166
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
4167
+ }
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('');
4180
+ }
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');
4187
+ }
4188
+ /**
4189
+ * Compact "blast radius" for the entry symbols of an explore result: who
4190
+ * depends on each (callers) and which test files cover it — LOCATIONS ONLY,
4191
+ * no source, so the agent knows what to update / re-verify before editing
4192
+ * without reaching for a separate impact call. Always-on, but skips symbols
4193
+ * that have no dependents (nothing to warn about), and returns '' when none
4194
+ * qualify so a leaf-only exploration stays clean.
4195
+ */
4196
+ buildBlastRadiusSection(cg, subgraph) {
4197
+ const ROOT_CAP = 5; // only the symbols the query actually targeted
4198
+ const FILE_CAP = 4; // caller files listed per symbol before "+N more"
4199
+ const MEANINGFUL = new Set([
4200
+ 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol',
4201
+ 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
4202
+ ]);
4203
+ const rel = (p) => p.replace(/\\/g, '/');
4204
+ const roots = subgraph.roots
4205
+ .map((id) => subgraph.nodes.get(id))
4206
+ .filter((n) => !!n && MEANINGFUL.has(n.kind))
4207
+ .slice(0, ROOT_CAP);
4208
+ if (roots.length === 0)
4209
+ return '';
4210
+ const entries = [];
4211
+ for (const root of roots) {
4212
+ let callers = [];
4213
+ try {
4214
+ callers = cg.getCallers(root.id);
4215
+ }
4216
+ catch { /* skip this root */ }
4217
+ const seen = new Set();
4218
+ const uniq = [];
4219
+ for (const c of callers) {
4220
+ if (c?.node && !seen.has(c.node.id)) {
4221
+ seen.add(c.node.id);
4222
+ uniq.push(c.node);
4223
+ }
4224
+ }
4225
+ if (uniq.length === 0)
4226
+ continue; // no blast radius → nothing to flag
4227
+ const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
4228
+ const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
4229
+ const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
4230
+ const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
4231
+ const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
4232
+ const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
4233
+ const tests = testFiles.length > 0
4234
+ ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
4235
+ : '; ⚠️ no covering tests found';
4236
+ entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
4237
+ }
4238
+ if (entries.length === 0)
4239
+ return '';
4240
+ return [
4241
+ '**Blast radius — what depends on these (update/verify before editing)**',
4242
+ '',
4243
+ ...entries,
4244
+ '',
4245
+ ].join('\n');
4246
+ }
4247
+ /**
4248
+ * Graph-connectivity relevance via Random-Walk-with-Restart (personalized
4249
+ * PageRank) from the query's matched SEED nodes over the call/reference graph.
4250
+ *
4251
+ * This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
4252
+ * homegraph's home turf: relevance by STRUCTURE, not words. A file whose
4253
+ * symbols are call-connected to the matched cluster accrues walk mass and
4254
+ * ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
4255
+ * "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
4256
+ * — gets only its own restart probability and ranks ~0. Immune to the
4257
+ * tokenization trap that fools term matching, deterministic, no embeddings.
4258
+ *
2219
4259
  * Undirected adjacency (reachability both ways), restart α=0.25 to the seeds,
2220
4260
  * power iteration to convergence. Bounded to the already-relevant subgraph, so
2221
4261
  * it's a few hundred nodes × ~25 iterations — negligible cost.
@@ -2298,12 +4338,22 @@ class ToolHandler {
2298
4338
  * tax on small projects while earning its keep on large ones.
2299
4339
  */
2300
4340
  async handleExplore(args) {
2301
- const query = this.validateString(args.query, 'query');
2302
- if (typeof query !== 'string')
2303
- 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);
2304
4347
  const cg = this.getHomeGraph(args.projectPath);
2305
4348
  const projectRoot = cg.getProjectRoot();
2306
- // 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.
2307
4357
  // largest-tier defaults if stats aren't available, which preserves
2308
4358
  // pre-#185 behavior for callers that hit the rare stats failure.
2309
4359
  let budget;
@@ -2313,20 +4363,72 @@ class ToolHandler {
2313
4363
  catch {
2314
4364
  budget = getExploreOutputBudget(Infinity);
2315
4365
  }
2316
- 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);
2317
4373
  // Step 1: Find relevant context with generous parameters.
2318
- // Use a large maxNodes budget explore has its own 35k char output limit
2319
- // that prevents context bloat, so more nodes just means better coverage
2320
- // across entry points (especially for large files like Svelte components).
2321
- const subgraph = await cg.findRelevantContext(query, {
2322
- searchLimit: 8,
2323
- traversalDepth: 3,
2324
- maxNodes: 200,
2325
- minScore: 0.2,
2326
- });
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);
2327
4381
  if (subgraph.nodes.size === 0) {
2328
4382
  return this.textResult(`No relevant code found for "${query}"`);
2329
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
+ }
2330
4432
  // Graph-aware glue: findRelevantContext builds the subgraph from name/text
2331
4433
  // search, so a method that BRIDGES named symbols — e.g. App.tsx's
2332
4434
  // triggerRender, which calls the named triggerUpdate — is never a search hit
@@ -2374,14 +4476,53 @@ class ToolHandler {
2374
4476
  // trace endpoint picker uses) and inject it as an entry, so every symbol the
2375
4477
  // agent explicitly named is in the subgraph and its file is scored.
2376
4478
  const namedSeedIds = new Set();
4479
+ // The subset of named seeds that earns the named-FIRST sort tier. We still
4480
+ // SEED every ≤3-def name (so RWR / flow ranking is unchanged), but only the
4481
+ // most-substantive def is tiered — a bare name's unrelated namesakes (Go's
4482
+ // `NewClient` = real client + test fake + xds pool) must not fill the tier
4483
+ // and crowd out the real answer file (grpc's `dialoptions.go`). Corroborated
4484
+ // overloads (the query also named the type) all earn it. (#1064)
4485
+ const tierSeedIds = new Set();
2377
4486
  {
2378
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;
2379
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
+ ]);
2380
4496
  const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
2381
4497
  const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
2382
- const tokens = [...new Set(query.split(/[\s,()[\]]+/)
2383
- .map((t) => t.replace(FILE_EXT, '').trim())
2384
- .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
4498
+ const callerCount = (n) => { try {
4499
+ return cg.getCallers(n.id).length;
4500
+ }
4501
+ catch {
4502
+ return 0;
4503
+ } };
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);
2385
4526
  // PascalCase tokens in the query are type/file disambiguators — when the
2386
4527
  // agent writes "DataRequest task validate", the `task`/`validate` it wants
2387
4528
  // are DataRequest's, NOT the same-named overloads in Validation.swift /
@@ -2405,8 +4546,15 @@ class ToolHandler {
2405
4546
  const isQual = /[.\/]|::/.test(t);
2406
4547
  const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
2407
4548
  const cands = raw
2408
- .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
2409
- .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
+ });
2410
4558
  // A specific name (<=3 defs) injects all its defs. An overloaded name
2411
4559
  // (`validate` = 10, `request` = 44) would flood the subgraph, so inject
2412
4560
  // only: the overloads whose file/class the query ALSO names (the agent
@@ -2414,12 +4562,23 @@ class ToolHandler {
2414
4562
  // capped; else fall back to the single most-substantive def. This is the
2415
4563
  // explore-side mirror of homegraph_node's overload disambiguation.
2416
4564
  let picks;
4565
+ let tierPicks; // subset that earns the named-first tier (#1064)
2417
4566
  if (cands.length <= 3) {
2418
4567
  picks = cands;
4568
+ // Centrality de-noise: tier the most-substantive def PLUS any co-named
4569
+ // def of comparable centrality (a real overload/wrapper — excalidraw's
4570
+ // `mutateElement` lives in mutateElement.ts, App.tsx AND Scene.ts, all
4571
+ // within ~2x callers). EXCLUDE a vastly-less-central namesake (Go's
4572
+ // `NewClient`: real client 492 callers vs xds-pool 11, test-fake 3 →
4573
+ // ratio <0.025) so it doesn't fill the tier and crowd out the answer.
4574
+ const counts = new Map(cands.map((c) => [c.id, callerCount(c)]));
4575
+ const maxCallers = Math.max(1, ...counts.values());
4576
+ tierPicks = cands.filter((c, i) => i === 0 || (counts.get(c.id) ?? 0) >= maxCallers * 0.25);
2419
4577
  }
2420
4578
  else {
2421
4579
  const ctx = cands.filter(inNamedContext);
2422
4580
  picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
4581
+ tierPicks = picks; // corroborated overloads (or the single fallback) all earn it
2423
4582
  }
2424
4583
  for (const n of picks) {
2425
4584
  if (!subgraph.nodes.has(n.id))
@@ -2431,6 +4590,70 @@ class ToolHandler {
2431
4590
  // so a named symbol FTS already gathered never sorted to the top.)
2432
4591
  namedSeedIds.add(n.id);
2433
4592
  }
4593
+ for (const n of tierPicks)
4594
+ tierSeedIds.add(n.id);
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
+ }
2434
4657
  }
2435
4658
  }
2436
4659
  // Step 2: Group nodes by file, score by relevance
@@ -2444,6 +4667,46 @@ class ToolHandler {
2444
4667
  if (entryNodeIds.has(edge.target))
2445
4668
  connectedToEntry.add(edge.source);
2446
4669
  }
4670
+ // CHANGE SURFACE (#1064): a named method's signature types — its parameter
4671
+ // and return types — are part of what you'd edit to "add a parameter to X",
4672
+ // yet they can be lexically dissimilar to the query ("add a parameter to
4673
+ // NewClient" shares no words with `dialoptions.go`, which defines NewClient's
4674
+ // `DialOption`) and sit a hop away. COLLECT them here from each named-seed
4675
+ // callable's outgoing signature edges (full graph — the type is often not in
4676
+ // the subgraph); the decision to surface one is DEFERRED to the buried-rescue
4677
+ // pass below, which fires only when the type's file would otherwise be
4678
+ // dropped — so a well-connected type (excalidraw's element types, Alamofire's
4679
+ // `DataRequest` on a flow query) is left to rank on its own and never
4680
+ // displaces a flow-central file. Bounded: only the few named seeds, only the
4681
+ // types in their signatures.
4682
+ const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor']);
4683
+ const TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'type_alias']);
4684
+ const SIG_EDGE = new Set(['references', 'type_of', 'returns']);
4685
+ const changeSurfaceCandidates = [];
4686
+ const seenChangeSurface = new Set();
4687
+ for (const seedId of tierSeedIds) {
4688
+ const seedNode = subgraph.nodes.get(seedId);
4689
+ if (!seedNode || !CALLABLE_KINDS.has(seedNode.kind))
4690
+ continue;
4691
+ let outs = [];
4692
+ try {
4693
+ outs = cg.getOutgoingEdges(seedId);
4694
+ }
4695
+ catch {
4696
+ continue;
4697
+ }
4698
+ for (const e of outs) {
4699
+ if (!SIG_EDGE.has(e.kind))
4700
+ continue;
4701
+ const tgt = cg.getNode(e.target);
4702
+ if (!tgt || !TYPE_KINDS.has(tgt.kind) || namedSeedIds.has(tgt.id))
4703
+ continue;
4704
+ if (seenChangeSurface.has(tgt.id))
4705
+ continue;
4706
+ seenChangeSurface.add(tgt.id);
4707
+ changeSurfaceCandidates.push(tgt);
4708
+ }
4709
+ }
2447
4710
  for (const node of subgraph.nodes.values()) {
2448
4711
  // Skip import/export nodes — they add noise without information
2449
4712
  if (node.kind === 'import' || node.kind === 'export')
@@ -2476,6 +4739,14 @@ class ToolHandler {
2476
4739
  }
2477
4740
  fileGroups.set(node.filePath, group);
2478
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
+ }
2479
4750
  // Only include files that have entry points or nodes directly connected to entry points
2480
4751
  let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
2481
4752
  // Extract query terms for relevance checking
@@ -2563,6 +4834,36 @@ class ToolHandler {
2563
4834
  if (n)
2564
4835
  entryFiles.add(n.filePath);
2565
4836
  }
4837
+ // Buried-rescue pass (#1064): surface a named method's signature type ONLY
4838
+ // when its file is genuinely buried — near-zero graph mass AND not lexically
4839
+ // matched. That is the invisible case (grpc's `DialOption` → `dialoptions.go`,
4840
+ // g≈0, 0 term hits): reachable but ranked nowhere, so the agent greps. A
4841
+ // well-connected type file (excalidraw element types, Alamofire `DataRequest`)
4842
+ // is NOT buried and is left alone — rescuing it would displace a flow-central
4843
+ // file (App.tsx, Validation.swift). Buried is judged on the PRE-rescue graph,
4844
+ // so injecting the type below can't make it look connected. A rescued file is
4845
+ // injected (so it renders), force-kept (gate + relevantFiles), and tiered.
4846
+ const changeSurfaceFiles = new Set();
4847
+ for (const t of changeSurfaceCandidates) {
4848
+ const fp = t.filePath;
4849
+ const buried = (fileGraphScore.get(fp) ?? 0) < maxGraph * 0.06
4850
+ && (fileTermHits.get(fp) ?? 0) < 2;
4851
+ if (!buried)
4852
+ continue;
4853
+ changeSurfaceFiles.add(fp);
4854
+ if (!subgraph.nodes.has(t.id))
4855
+ subgraph.nodes.set(t.id, t);
4856
+ let group = fileGroups.get(fp);
4857
+ if (!group) {
4858
+ group = { nodes: [], score: 0 };
4859
+ fileGroups.set(fp, group);
4860
+ }
4861
+ if (!group.nodes.some((n) => n.id === t.id))
4862
+ group.nodes.push(t);
4863
+ group.score = Math.max(group.score, 45);
4864
+ if (!relevantFiles.some(([f]) => f === fp))
4865
+ relevantFiles.push([fp, group]);
4866
+ }
2566
4867
  // Relevance gate (so the generous budget is a CEILING, not a target): keep a
2567
4868
  // file only if it is STRUCTURALLY relevant by ANY of:
2568
4869
  // - graph score within a fraction of the top (it's on/near the flow), OR
@@ -2579,6 +4880,7 @@ class ToolHandler {
2579
4880
  const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
2580
4881
  || centralFiles.has(fp)
2581
4882
  || entryFiles.has(fp)
4883
+ || changeSurfaceFiles.has(fp)
2582
4884
  || (fileTermHits.get(fp) ?? 0) >= 2);
2583
4885
  if (gated.length >= 2)
2584
4886
  relevantFiles = gated;
@@ -2593,11 +4895,16 @@ class ToolHandler {
2593
4895
  // in other files (`Validation.swift`), falls outside the budget, and the
2594
4896
  // agent Reads it. The named file is the answer — rank it at the top.
2595
4897
  const namedSeedFiles = new Set();
2596
- for (const id of namedSeedIds) {
4898
+ for (const id of tierSeedIds) {
2597
4899
  const n = subgraph.nodes.get(id);
2598
4900
  if (n)
2599
4901
  namedSeedFiles.add(n.filePath);
2600
4902
  }
4903
+ // A rescued change-surface file (only the genuinely-buried ones — see the
4904
+ // buried-rescue pass) is the lexically-dissimilar answer; give it the named
4905
+ // tier so it isn't buried under files that merely share surface words (#1064).
4906
+ for (const fp of changeSurfaceFiles)
4907
+ namedSeedFiles.add(fp);
2601
4908
  // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
2602
4909
  // (a search root, named seed, or graph-central hub — i.e. structurally part of
2603
4910
  // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
@@ -2617,9 +4924,16 @@ class ToolHandler {
2617
4924
  const isCorroborated = (fp) => !MULTITERM_OFF &&
2618
4925
  (fileTermHits.get(fp) ?? 0) >= 2 &&
2619
4926
  (entryFiles.has(fp) || centralFiles.has(fp));
4927
+ const queryMemberAccesses = (0, query_utils_1.extractMemberAccessFromQuery)(query);
2620
4928
  const sortedFiles = relevantFiles.sort((a, b) => {
2621
4929
  const aPath = a[0].toLowerCase();
2622
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;
2623
4937
  // Agent-named files first (it asked for a symbol defined here by name).
2624
4938
  const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
2625
4939
  const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
@@ -2662,19 +4976,164 @@ class ToolHandler {
2662
4976
  const lines = [
2663
4977
  `**Exploration: ${query}**`,
2664
4978
  '',
2665
- `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
4979
+ // Curated summary filled in after the source loop (see below). We do NOT
4980
+ // report `subgraph.nodes.size` / `fileGroups.size` here: that's the raw
4981
+ // candidate gather, which a broad natural-language query inflates wildly
4982
+ // (260 symbols / 124 files on a 636-file repo) even though only a handful
4983
+ // render. Reporting the pool read as "260 results to wade through" when the
4984
+ // real, correctly-ranked answer is the few files below (#1046).
4985
+ '',
2666
4986
  '',
2667
4987
  ];
2668
- // Blast radius (always-on, compact): for the entry symbols, who depends on
2669
- // them + which tests cover them — locations only, no source — so the agent
2670
- // knows what to update/verify before editing without a separate call.
2671
- const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
2672
- if (blastRadius)
2673
- lines.push(blastRadius);
2674
- // Relationship map show how symbols connect
4988
+ const summaryLineIdx = 2;
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).
2675
5133
  const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
2676
5134
  );
2677
- if (budget.includeRelationships && significantEdges.length > 0) {
5135
+ if (budget.includeRelationships && hasFlowPath && !importResult.compactListing
5136
+ && significantEdges.length > 0) {
2678
5137
  lines.push('**Relationships**');
2679
5138
  lines.push('');
2680
5139
  // Group edges by kind for readability
@@ -2702,10 +5161,7 @@ class ToolHandler {
2702
5161
  }
2703
5162
  }
2704
5163
  // Step 4: Read contiguous file sections
2705
- // Compute the flow spine once used both to prepend the Flow section (below)
2706
- // and to gate adaptive source sizing: files on the spine get full source,
2707
- // off-spine peers skeletonize.
2708
- const flow = this.buildFlowFromNamedSymbols(cg, query);
5164
+ // (flow already computed above for relationship gating and adaptive sizing)
2709
5165
  // Polymorphic-sibling detector for adaptive sizing. A class that implements/
2710
5166
  // extends a supertype shared by >= MIN_SIBLINGS classes is one of many
2711
5167
  // INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
@@ -2762,13 +5218,61 @@ class ToolHandler {
2762
5218
  };
2763
5219
  lines.push('**Source Code**');
2764
5220
  lines.push('');
2765
- 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.');
2766
5224
  lines.push('');
2767
5225
  let totalChars = lines.join('\n').length;
2768
5226
  let filesIncluded = 0;
5227
+ // Paths we actually render source for below. Drives the curated header count
5228
+ // (#1046) — it must reflect what we show, not the raw candidate gather.
5229
+ const renderedFilePaths = [];
2769
5230
  let anyFileTrimmed = false;
2770
- for (const [filePath, group] of sortedFiles) {
2771
- 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)
2772
5276
  break;
2773
5277
  // A file DEFINES a named/spine symbol (the answer) vs merely references the
2774
5278
  // flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
@@ -2779,6 +5283,24 @@ class ToolHandler {
2779
5283
  const fileNecessary = group.nodes.some(n => entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
2780
5284
  if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9)
2781
5285
  continue;
5286
+ if ((0, arkts_1.isOhosApiFilePath)(filePath)) {
5287
+ const rel = filePath.slice(arkts_1.OHOS_API_FILE_PREFIX.length);
5288
+ const syms = group.nodes
5289
+ .filter((n) => n.kind !== 'import' && n.kind !== 'export')
5290
+ .sort((a, b) => a.startLine - b.startLine);
5291
+ if (syms.length === 0)
5292
+ continue;
5293
+ lines.push(fileSectionHeader(rel, 'HarmonyOS SDK API (prebuilt db)'));
5294
+ lines.push('');
5295
+ for (const n of syms) {
5296
+ const sig = n.signature || n.docstring || `${n.kind} ${n.qualifiedName || n.name}`;
5297
+ lines.push(`\`${sig}\``);
5298
+ }
5299
+ lines.push('');
5300
+ totalChars = lines.join('\n').length;
5301
+ filesIncluded++;
5302
+ continue;
5303
+ }
2782
5304
  const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, filePath);
2783
5305
  if (!absPath || !(0, fs_1.existsSync)(absPath))
2784
5306
  continue;
@@ -2917,6 +5439,7 @@ class ToolHandler {
2917
5439
  : 'skeleton (signatures only — homegraph_explore a name for its full body; do NOT Read)';
2918
5440
  lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
2919
5441
  totalChars += skel.join('\n').length + 120;
5442
+ renderedFilePaths.push(filePath);
2920
5443
  filesIncluded++;
2921
5444
  continue;
2922
5445
  }
@@ -2963,6 +5486,7 @@ class ToolHandler {
2963
5486
  }
2964
5487
  lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
2965
5488
  totalChars += wholeSection.length + 200;
5489
+ renderedFilePaths.push(filePath);
2966
5490
  filesIncluded++;
2967
5491
  continue;
2968
5492
  }
@@ -3044,6 +5568,39 @@ class ToolHandler {
3044
5568
  ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
3045
5569
  }
3046
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
+ }
3047
5604
  ranges.sort((a, b) => a.start - b.start);
3048
5605
  if (ranges.length === 0)
3049
5606
  continue;
@@ -3251,8 +5808,14 @@ class ToolHandler {
3251
5808
  lines.push('```');
3252
5809
  lines.push('');
3253
5810
  totalChars += fileSection.length + 200;
5811
+ renderedFilePaths.push(filePath);
3254
5812
  filesIncluded++;
3255
5813
  }
5814
+ // The curated header count is computed from the files that SURVIVE the final
5815
+ // truncation (see end of method) — `filesIncluded` can over-count when the
5816
+ // hard ceiling drops trailing sections — so leave a sentinel here and fill it
5817
+ // in once the output is final.
5818
+ lines[summaryLineIdx] = SUMMARY_SENTINEL;
3256
5819
  // Add remaining files as references (from both relevant and peripheral files).
3257
5820
  // Small projects (per budget) skip this — the relevant story already fits
3258
5821
  // in the source section, and a trailing pointer list is pure overhead.
@@ -3309,6 +5872,7 @@ class ToolHandler {
3309
5872
  // externalize territory.
3310
5873
  const output = flow.text + lines.join('\n');
3311
5874
  const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
5875
+ let finalText;
3312
5876
  if (output.length > hardCeiling) {
3313
5877
  // Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
3314
5878
  // ceiling) so we drop whole trailing file-sections rather than slicing
@@ -3319,9 +5883,36 @@ class ToolHandler {
3319
5883
  const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
3320
5884
  const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
3321
5885
  const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
3322
- return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another 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.)';
3323
5887
  }
3324
- return this.textResult(output);
5888
+ else {
5889
+ finalText = output;
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
+ }
5897
+ // Curated header (#1046): substitute the sentinel with the count of files
5898
+ // whose source SURVIVES in the final text — not `subgraph`/`fileGroups` (the
5899
+ // raw gather a broad query inflates) and not `filesIncluded` (which can
5900
+ // over-count when the ceiling above drops trailing sections). A file counts
5901
+ // only if its section header is still present; its relevant (non-import)
5902
+ // symbols are summed for N. Files we couldn't fit are still named under "Not
5903
+ // shown above" + the budget note, so nothing is silently dropped.
5904
+ const survivors = renderedFilePaths.filter((fp) => finalText.includes(`${FILE_SECTION_PREFIX}${fp}\``));
5905
+ const shownSymbols = survivors.reduce((sum, fp) => {
5906
+ const g = fileGroups.get(fp);
5907
+ if (!g)
5908
+ return sum;
5909
+ return sum + new Set(g.nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export').map((n) => n.id)).size;
5910
+ }, 0);
5911
+ const summaryLine = survivors.length > 0
5912
+ ? `Found ${shownSymbols} symbol${shownSymbols === 1 ? '' : 's'} across ${survivors.length} file${survivors.length === 1 ? '' : 's'}.`
5913
+ : `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
5914
+ finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
5915
+ return this.textResult(finalText);
3325
5916
  }
3326
5917
  /**
3327
5918
  * Handle homegraph_node
@@ -3392,12 +5983,12 @@ class ToolHandler {
3392
5983
  const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
3393
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')));
3394
5985
  }
3395
- 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
3396
5987
  // The CHAR budget is the real limiter — keep the count cap high so a set of
3397
5988
  // SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
3398
5989
  // render in full rather than relegating the one the agent wanted to a
3399
5990
  // bodiless list. Only a set of many LARGE bodies hits the char budget first.
3400
- const HARD_CAP = 16;
5991
+ const HARD_CAP = 12;
3401
5992
  const rendered = [];
3402
5993
  const listed = [];
3403
5994
  let used = 0;
@@ -3483,7 +6074,7 @@ class ToolHandler {
3483
6074
  const symbolMap = (heading, limit = 200) => {
3484
6075
  const lines = [heading];
3485
6076
  for (const n of nodes.slice(0, limit)) {
3486
- const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
6077
+ const sig = n.signature ? ` ${formatInlineSignature(n.signature)}` : '';
3487
6078
  lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
3488
6079
  }
3489
6080
  if (nodes.length > limit)
@@ -3533,12 +6124,12 @@ class ToolHandler {
3533
6124
  const fileLines = content.split('\n');
3534
6125
  const total = fileLines.length;
3535
6126
  // Read-parity windowing: `offset`/`limit` mean exactly what they do on Read
3536
- // (1-based start line; max line count). Default: the whole file, capped like
3537
- // Read at 2000 lines and bounded by a char budget that tracks explore's
3538
- // proven-safe ~38k response ceiling. Overflow is stated explicitly (Read
3539
- // paginates too) never the silent 15k truncateOutput chop.
3540
- const CHAR_BUDGET = 38000;
3541
- 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;
3542
6133
  const offset = Math.max(1, opts.offset ?? 1);
3543
6134
  if (offset > total) {
3544
6135
  return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
@@ -3561,8 +6152,9 @@ class ToolHandler {
3561
6152
  const complete = offset === 1 && shownEnd >= total;
3562
6153
  const out = [header, '', ...numbered];
3563
6154
  if (!complete) {
3564
- 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)`);
3565
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.');
3566
6158
  // Self-bounded to CHAR_BUDGET — do NOT route through truncateOutput (15k).
3567
6159
  return this.textResult(out.join('\n'));
3568
6160
  }
@@ -3886,12 +6478,12 @@ class ToolHandler {
3886
6478
  return query;
3887
6479
  const repoPath = args.repoPath;
3888
6480
  const explicitDbPath = args.dbPath;
3889
- const topK = Math.max(1, Math.min(Number(args.topK) || 5, 50));
6481
+ const topKRaw = Number(args.topK);
6482
+ const topK = Math.max(1, Math.min(isNaN(topKRaw) ? 5 : topKRaw, 50));
3890
6483
  const includeFragments = args.includeFragments !== false;
3891
6484
  // Lazily require spec modules so the MCP startup path stays lean.
3892
6485
  const { resolveDbPath } = require('../spec/utils');
3893
6486
  const { createDatabase } = require('../db/sqlite-adapter');
3894
- const { initSpecSchema } = require('../spec/db/schema');
3895
6487
  const { searchAndGetContext } = require('../spec/graph/queries');
3896
6488
  const { truncateCodeDiff, truncateSubtitles, computeBudgetProfile, } = require('../spec/utils');
3897
6489
  // Resolve the database path.
@@ -3906,8 +6498,6 @@ class ToolHandler {
3906
6498
  `${err instanceof Error ? err.message : String(err)}`);
3907
6499
  }
3908
6500
  try {
3909
- // Ensure the schema exists (idempotent).
3910
- initSpecSchema(db);
3911
6501
  // Search and traverse.
3912
6502
  const contexts = searchAndGetContext(db, query, topK, includeFragments);
3913
6503
  if (contexts.length === 0) {
@@ -3975,15 +6565,238 @@ class ToolHandler {
3975
6565
  return this.textResult(`(Fragments elided — output exceeded ${MAX_OUTPUT_LENGTH} chars)\n\n` + slim);
3976
6566
  }
3977
6567
  }
3978
- // Fallback: hard-truncate the JSON.
3979
- const truncated = json.slice(0, MAX_OUTPUT_LENGTH - 3) + '...';
3980
- return this.textResult(truncated);
6568
+ // Fallback: truncate at a newline to avoid broken JSON.
6569
+ return this.textResult(this.truncateOutput(json));
6570
+ }
6571
+ finally {
6572
+ db.close();
6573
+ }
6574
+ }
6575
+ // =========================================================================
6576
+ // handleSpecFind — file-path based spec lookup
6577
+ // =========================================================================
6578
+ /**
6579
+ * Find which specs are related to the given file path by matching against
6580
+ * code-fragment file paths in the Commit4Spec knowledge graph.
6581
+ *
6582
+ * Traverses: filePath → code_fragment_nodes → commit_fragment_relations
6583
+ * → spec_commit_relations → spec_nodes.
6584
+ */
6585
+ async handleSpecFind(args) {
6586
+ const filePath = this.validateString(args.filePath, 'filePath');
6587
+ if (typeof filePath !== 'string')
6588
+ return filePath;
6589
+ const repoPath = args.repoPath;
6590
+ const explicitDbPath = args.dbPath;
6591
+ // Lazily require spec modules so the MCP startup path stays lean.
6592
+ const { resolveDbPath } = require('../spec/utils');
6593
+ const { createDatabase } = require('../db/sqlite-adapter');
6594
+ const { findSpecsByFilePath } = require('../spec/graph/queries');
6595
+ // Resolve the database path.
6596
+ const dbPath = resolveDbPath(repoPath || process.cwd(), explicitDbPath);
6597
+ // Open the database.
6598
+ let db;
6599
+ try {
6600
+ db = createDatabase(dbPath).db;
6601
+ }
6602
+ catch (err) {
6603
+ return this.errorResult(`Failed to open Commit4Spec database at "${dbPath}": ` +
6604
+ `${err instanceof Error ? err.message : String(err)}`);
6605
+ }
6606
+ try {
6607
+ const result = findSpecsByFilePath(db, filePath);
6608
+ const response = {
6609
+ filePath,
6610
+ matched_count: result.matched_count,
6611
+ truncated: result.truncated,
6612
+ results: result.results,
6613
+ };
6614
+ const json = JSON.stringify(response, null, 2);
6615
+ if (json.length <= MAX_OUTPUT_LENGTH) {
6616
+ return this.textResult(json);
6617
+ }
6618
+ // Truncate: reduce results
6619
+ const slim = {
6620
+ ...response,
6621
+ results: response.results.slice(0, Math.max(1, Math.floor(response.results.length / 2))),
6622
+ };
6623
+ const slimJson = JSON.stringify(slim, null, 2);
6624
+ if (slimJson.length <= MAX_OUTPUT_LENGTH) {
6625
+ return this.textResult(`(Results trimmed to fit output limit)\n\n${slimJson}`);
6626
+ }
6627
+ return this.textResult(this.truncateOutput(json));
3981
6628
  }
3982
6629
  finally {
3983
6630
  db.close();
3984
6631
  }
3985
6632
  }
3986
6633
  // =========================================================================
6634
+ // handleSpecTrace — code symbol → Spec reverse trace
6635
+ // =========================================================================
6636
+ /**
6637
+ * Trace a code symbol back to its associated Specs.
6638
+ *
6639
+ * Uses the HomeGraph code index to resolve the symbol to AST-level node(s),
6640
+ * then queries the Commit4Spec knowledge graph for associated Specs via
6641
+ * five-dimensional scoring (file-path, content FTS5, name FTS5, recency,
6642
+ * line overlap).
6643
+ *
6644
+ * This bridges the two databases: homegraph.db (code entities) →
6645
+ * commit4spec.db (Spec knowledge graph).
6646
+ */
6647
+ async handleSpecTrace(args) {
6648
+ const symbol = this.validateString(args.symbol, 'symbol');
6649
+ if (typeof symbol !== 'string')
6650
+ return symbol;
6651
+ const fileRaw = this.validateOptionalPath(args.file, 'file');
6652
+ if (typeof fileRaw === 'object')
6653
+ return fileRaw;
6654
+ const file = fileRaw;
6655
+ const line = typeof args.line === 'number' ? args.line : undefined;
6656
+ const repoPath = args.repoPath;
6657
+ const explicitDbPath = args.dbPath;
6658
+ const topKRaw = Number(args.topK);
6659
+ const topK = Math.max(1, Math.min(isNaN(topKRaw) ? 10 : topKRaw, 50));
6660
+ // Lazily require all needed modules
6661
+ const HomeGraph = loadHomeGraph();
6662
+ const { resolveDbPath } = require('../spec/utils');
6663
+ const { createDatabase } = require('../db/sqlite-adapter');
6664
+ const { findSpecsByCodeSymbol } = require('../spec/graph/queries');
6665
+ const { initSpecSchema, runSpecMigrations, getCurrentSpecVersion, CURRENT_SPEC_SCHEMA_VERSION } = require('../spec/db/schema');
6666
+ // Resolve project path for the code graph
6667
+ const projectPath = repoPath || process.cwd();
6668
+ // Open the HomeGraph code index
6669
+ let cg;
6670
+ try {
6671
+ cg = await HomeGraph.open(projectPath);
6672
+ }
6673
+ catch (err) {
6674
+ return this.errorResult(`Failed to open HomeGraph code index at "${projectPath}": ` +
6675
+ `${err instanceof Error ? err.message : String(err)}`);
6676
+ }
6677
+ try {
6678
+ // Step 1: Resolve symbol to nodes via findSymbolMatches
6679
+ let nodes = this.findSymbolMatches(cg, symbol);
6680
+ if (nodes.length === 0) {
6681
+ await cg.close();
6682
+ return this.textResult(JSON.stringify({
6683
+ symbol,
6684
+ error: `No code entities found for symbol "${symbol}".`,
6685
+ matches: [],
6686
+ }, null, 2));
6687
+ }
6688
+ // Disambiguate by file/line if provided
6689
+ if (file) {
6690
+ // Use endsWith for precise file path matching
6691
+ nodes = nodes.filter((n) => n.filePath.endsWith(file));
6692
+ }
6693
+ if (line !== undefined && nodes.length > 1) {
6694
+ const closest = nodes.reduce((best, n) => {
6695
+ const bestDist = Math.abs(best.startLine - line) + Math.abs(best.endLine - line);
6696
+ const curDist = Math.abs(n.startLine - line) + Math.abs(n.endLine - line);
6697
+ return curDist < bestDist ? n : best;
6698
+ });
6699
+ nodes = [closest];
6700
+ }
6701
+ // Take the best disambiguated node
6702
+ const node = nodes[0];
6703
+ if (!node) {
6704
+ await cg.close();
6705
+ return this.textResult(JSON.stringify({
6706
+ symbol,
6707
+ error: `Could not resolve symbol "${symbol}" to a specific code entity.`,
6708
+ matches: [],
6709
+ }, null, 2));
6710
+ }
6711
+ // Step 2: Resolve the Spec database path
6712
+ const dbPath = resolveDbPath(repoPath || process.cwd(), explicitDbPath);
6713
+ // Step 3: Open the Spec database
6714
+ let db;
6715
+ try {
6716
+ db = createDatabase(dbPath).db;
6717
+ }
6718
+ catch (err) {
6719
+ await cg.close();
6720
+ return this.errorResult(`Failed to open Commit4Spec database at "${dbPath}": ` +
6721
+ `${err instanceof Error ? err.message : String(err)}`);
6722
+ }
6723
+ try {
6724
+ // Ensure schema is up to date
6725
+ initSpecSchema(db);
6726
+ const currentVersion = getCurrentSpecVersion(db);
6727
+ if (currentVersion < CURRENT_SPEC_SCHEMA_VERSION) {
6728
+ runSpecMigrations(db, currentVersion);
6729
+ }
6730
+ // Step 4: Query Specs for the code entity
6731
+ const result = findSpecsByCodeSymbol(db, {
6732
+ name: node.name,
6733
+ qualifiedName: node.qualifiedName,
6734
+ kind: node.kind,
6735
+ filePath: node.filePath,
6736
+ startLine: node.startLine,
6737
+ endLine: node.endLine,
6738
+ }, topK);
6739
+ // Step 5: Serialize the response
6740
+ const response = {
6741
+ symbol,
6742
+ entity: {
6743
+ name: result.entity.name,
6744
+ qualifiedName: result.entity.qualifiedName,
6745
+ kind: result.entity.kind,
6746
+ filePath: result.entity.filePath,
6747
+ startLine: result.entity.startLine,
6748
+ endLine: result.entity.endLine,
6749
+ },
6750
+ matched_count: result.matches.length,
6751
+ total_candidates: result.totalCandidates,
6752
+ matches: result.matches.map((m) => ({
6753
+ spec_id: m.spec.id,
6754
+ title: m.spec.title,
6755
+ status: m.spec.status,
6756
+ version: m.spec.version,
6757
+ file_path: m.spec.filePath,
6758
+ score: Math.round(m.score * 1000) / 1000,
6759
+ score_detail: {
6760
+ file_path: Math.round(m.scoreDetail.filePathScore * 1000) / 1000,
6761
+ content: Math.round(m.scoreDetail.contentScore * 1000) / 1000,
6762
+ name: Math.round(m.scoreDetail.nameScore * 1000) / 1000,
6763
+ recency: Math.round(m.scoreDetail.recencyScore * 1000) / 1000,
6764
+ line_overlap: Math.round(m.scoreDetail.overlapScore * 1000) / 1000,
6765
+ },
6766
+ fragment_count: m.fragmentCount,
6767
+ commit_count: m.commitCount,
6768
+ })),
6769
+ };
6770
+ const json = JSON.stringify(response, null, 2);
6771
+ if (json.length <= MAX_OUTPUT_LENGTH) {
6772
+ return this.textResult(json);
6773
+ }
6774
+ // Truncate: reduce matches
6775
+ const slim = {
6776
+ ...response,
6777
+ matches: response.matches.slice(0, Math.max(1, Math.floor(topK / 2))),
6778
+ };
6779
+ const slimJson = JSON.stringify(slim, null, 2);
6780
+ if (slimJson.length <= MAX_OUTPUT_LENGTH) {
6781
+ return this.textResult(`(Results trimmed to fit output limit)\n\n${slimJson}`);
6782
+ }
6783
+ return this.textResult(this.truncateOutput(json));
6784
+ }
6785
+ finally {
6786
+ try {
6787
+ db.close();
6788
+ }
6789
+ catch { /* best effort */ }
6790
+ }
6791
+ }
6792
+ finally {
6793
+ try {
6794
+ await cg.close();
6795
+ }
6796
+ catch { /* best effort */ }
6797
+ }
6798
+ }
6799
+ // =========================================================================
3987
6800
  // Symbol resolution helpers
3988
6801
  // =========================================================================
3989
6802
  /**
@@ -4108,25 +6921,47 @@ class ToolHandler {
4108
6921
  if (tail && tail !== symbol)
4109
6922
  results = cg.searchNodes(tail, { limit: 50 });
4110
6923
  }
4111
- 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) {
4112
6949
  return { nodes: [], note: '' };
4113
6950
  }
4114
- const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
4115
- if (exactMatches.length <= 1) {
4116
- const node = exactMatches[0]?.node ?? results[0].node;
4117
- return { nodes: [node], note: '' };
6951
+ if (pool.length === 1) {
6952
+ return { nodes: pool, note: '' };
4118
6953
  }
4119
6954
  // Same generated-file down-rank as findSymbol — keeps callers/callees
4120
6955
  // /impact aggregation aligned (a query against "Send" returns the
4121
6956
  // hand-written implementations before the protobuf scaffold).
4122
- const ranked = [...exactMatches].sort((a, b) => {
4123
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
4124
- 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;
4125
6960
  return aGen - bGen;
4126
6961
  });
4127
- 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}`);
4128
6963
  const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
4129
- return { nodes: ranked.map(r => r.node), note };
6964
+ return { nodes: ranked, note };
4130
6965
  }
4131
6966
  /**
4132
6967
  * Truncate output if it exceeds the maximum length
@@ -4151,7 +6986,7 @@ class ToolHandler {
4151
6986
  lines.push(`**${node.name}** (${node.kind})`);
4152
6987
  lines.push(`${node.filePath}${location}`);
4153
6988
  if (node.signature)
4154
- lines.push(`\`${node.signature}\``);
6989
+ lines.push(`\`${formatInlineSignature(node.signature)}\``);
4155
6990
  lines.push('');
4156
6991
  }
4157
6992
  return lines.join('\n');
@@ -4225,7 +7060,7 @@ class ToolHandler {
4225
7060
  const lines = [`**Members (${children.length}):**`, ''];
4226
7061
  for (const c of children) {
4227
7062
  const loc = c.startLine ? `:${c.startLine}` : '';
4228
- const sig = c.signature ? ` — \`${c.signature}\`` : '';
7063
+ const sig = c.signature ? ` — \`${formatInlineSignature(c.signature)}\`` : '';
4229
7064
  lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
4230
7065
  }
4231
7066
  return lines.join('\n');
@@ -4238,7 +7073,7 @@ class ToolHandler {
4238
7073
  `**Location:** ${node.filePath}${location}`,
4239
7074
  ];
4240
7075
  if (node.signature) {
4241
- lines.push(`**Signature:** \`${node.signature}\``);
7076
+ lines.push(...formatNodeSignatureBlock(node.signature));
4242
7077
  }
4243
7078
  // Only include docstring if it's short and useful
4244
7079
  if (node.docstring && node.docstring.length < 200) {