@cstart/coldstart 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (360) hide show
  1. package/README.md +236 -0
  2. package/dist/cache/disk-cache.d.ts +14 -0
  3. package/dist/cache/disk-cache.d.ts.map +1 -0
  4. package/dist/cache/disk-cache.js +371 -0
  5. package/dist/cache/disk-cache.js.map +1 -0
  6. package/dist/cli.d.ts +18 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +206 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/constants.d.ts +8 -0
  11. package/dist/constants.d.ts.map +1 -0
  12. package/dist/constants.js +111 -0
  13. package/dist/constants.js.map +1 -0
  14. package/dist/daemon-lock.d.ts +63 -0
  15. package/dist/daemon-lock.d.ts.map +1 -0
  16. package/dist/daemon-lock.js +218 -0
  17. package/dist/daemon-lock.js.map +1 -0
  18. package/dist/daemon-log.d.ts +26 -0
  19. package/dist/daemon-log.d.ts.map +1 -0
  20. package/dist/daemon-log.js +110 -0
  21. package/dist/daemon-log.js.map +1 -0
  22. package/dist/index-manager.d.ts +103 -0
  23. package/dist/index-manager.d.ts.map +1 -0
  24. package/dist/index-manager.js +361 -0
  25. package/dist/index-manager.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +724 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/indexer/content-tokens.d.ts +74 -0
  31. package/dist/indexer/content-tokens.d.ts.map +1 -0
  32. package/dist/indexer/content-tokens.js +406 -0
  33. package/dist/indexer/content-tokens.js.map +1 -0
  34. package/dist/indexer/cpp-include-roots.d.ts +7 -0
  35. package/dist/indexer/cpp-include-roots.d.ts.map +1 -0
  36. package/dist/indexer/cpp-include-roots.js +166 -0
  37. package/dist/indexer/cpp-include-roots.js.map +1 -0
  38. package/dist/indexer/csharp-synthetic.d.ts +9 -0
  39. package/dist/indexer/csharp-synthetic.d.ts.map +1 -0
  40. package/dist/indexer/csharp-synthetic.js +51 -0
  41. package/dist/indexer/csharp-synthetic.js.map +1 -0
  42. package/dist/indexer/django-synthetic.d.ts +12 -0
  43. package/dist/indexer/django-synthetic.d.ts.map +1 -0
  44. package/dist/indexer/django-synthetic.js +49 -0
  45. package/dist/indexer/django-synthetic.js.map +1 -0
  46. package/dist/indexer/extractors/angularjs.d.ts +16 -0
  47. package/dist/indexer/extractors/angularjs.d.ts.map +1 -0
  48. package/dist/indexer/extractors/angularjs.js +49 -0
  49. package/dist/indexer/extractors/angularjs.js.map +1 -0
  50. package/dist/indexer/extractors/cpp.d.ts +9 -0
  51. package/dist/indexer/extractors/cpp.d.ts.map +1 -0
  52. package/dist/indexer/extractors/cpp.js +254 -0
  53. package/dist/indexer/extractors/cpp.js.map +1 -0
  54. package/dist/indexer/extractors/csharp.d.ts +19 -0
  55. package/dist/indexer/extractors/csharp.d.ts.map +1 -0
  56. package/dist/indexer/extractors/csharp.js +327 -0
  57. package/dist/indexer/extractors/csharp.js.map +1 -0
  58. package/dist/indexer/extractors/env.d.ts +9 -0
  59. package/dist/indexer/extractors/env.d.ts.map +1 -0
  60. package/dist/indexer/extractors/env.js +43 -0
  61. package/dist/indexer/extractors/env.js.map +1 -0
  62. package/dist/indexer/extractors/go.d.ts +9 -0
  63. package/dist/indexer/extractors/go.d.ts.map +1 -0
  64. package/dist/indexer/extractors/go.js +227 -0
  65. package/dist/indexer/extractors/go.js.map +1 -0
  66. package/dist/indexer/extractors/graphql.d.ts +22 -0
  67. package/dist/indexer/extractors/graphql.d.ts.map +1 -0
  68. package/dist/indexer/extractors/graphql.js +88 -0
  69. package/dist/indexer/extractors/graphql.js.map +1 -0
  70. package/dist/indexer/extractors/groovy.d.ts +9 -0
  71. package/dist/indexer/extractors/groovy.d.ts.map +1 -0
  72. package/dist/indexer/extractors/groovy.js +356 -0
  73. package/dist/indexer/extractors/groovy.js.map +1 -0
  74. package/dist/indexer/extractors/java.d.ts +10 -0
  75. package/dist/indexer/extractors/java.d.ts.map +1 -0
  76. package/dist/indexer/extractors/java.js +586 -0
  77. package/dist/indexer/extractors/java.js.map +1 -0
  78. package/dist/indexer/extractors/kotlin.d.ts +10 -0
  79. package/dist/indexer/extractors/kotlin.d.ts.map +1 -0
  80. package/dist/indexer/extractors/kotlin.js +398 -0
  81. package/dist/indexer/extractors/kotlin.js.map +1 -0
  82. package/dist/indexer/extractors/node-helpers.d.ts +13 -0
  83. package/dist/indexer/extractors/node-helpers.d.ts.map +1 -0
  84. package/dist/indexer/extractors/node-helpers.js +17 -0
  85. package/dist/indexer/extractors/node-helpers.js.map +1 -0
  86. package/dist/indexer/extractors/parser-factory.d.ts +4 -0
  87. package/dist/indexer/extractors/parser-factory.d.ts.map +1 -0
  88. package/dist/indexer/extractors/parser-factory.js +28 -0
  89. package/dist/indexer/extractors/parser-factory.js.map +1 -0
  90. package/dist/indexer/extractors/php.d.ts +17 -0
  91. package/dist/indexer/extractors/php.d.ts.map +1 -0
  92. package/dist/indexer/extractors/php.js +363 -0
  93. package/dist/indexer/extractors/php.js.map +1 -0
  94. package/dist/indexer/extractors/python.d.ts +14 -0
  95. package/dist/indexer/extractors/python.d.ts.map +1 -0
  96. package/dist/indexer/extractors/python.js +449 -0
  97. package/dist/indexer/extractors/python.js.map +1 -0
  98. package/dist/indexer/extractors/ruby.d.ts +10 -0
  99. package/dist/indexer/extractors/ruby.d.ts.map +1 -0
  100. package/dist/indexer/extractors/ruby.js +996 -0
  101. package/dist/indexer/extractors/ruby.js.map +1 -0
  102. package/dist/indexer/extractors/rust.d.ts +9 -0
  103. package/dist/indexer/extractors/rust.d.ts.map +1 -0
  104. package/dist/indexer/extractors/rust.js +322 -0
  105. package/dist/indexer/extractors/rust.js.map +1 -0
  106. package/dist/indexer/extractors/toml.d.ts +9 -0
  107. package/dist/indexer/extractors/toml.d.ts.map +1 -0
  108. package/dist/indexer/extractors/toml.js +84 -0
  109. package/dist/indexer/extractors/toml.js.map +1 -0
  110. package/dist/indexer/extractors/xml.d.ts +9 -0
  111. package/dist/indexer/extractors/xml.d.ts.map +1 -0
  112. package/dist/indexer/extractors/xml.js +163 -0
  113. package/dist/indexer/extractors/xml.js.map +1 -0
  114. package/dist/indexer/extractors/yaml.d.ts +9 -0
  115. package/dist/indexer/extractors/yaml.d.ts.map +1 -0
  116. package/dist/indexer/extractors/yaml.js +117 -0
  117. package/dist/indexer/extractors/yaml.js.map +1 -0
  118. package/dist/indexer/git.d.ts +19 -0
  119. package/dist/indexer/git.d.ts.map +1 -0
  120. package/dist/indexer/git.js +55 -0
  121. package/dist/indexer/git.js.map +1 -0
  122. package/dist/indexer/graph.d.ts +7 -0
  123. package/dist/indexer/graph.d.ts.map +1 -0
  124. package/dist/indexer/graph.js +20 -0
  125. package/dist/indexer/graph.js.map +1 -0
  126. package/dist/indexer/indexed-file.d.ts +15 -0
  127. package/dist/indexer/indexed-file.d.ts.map +1 -0
  128. package/dist/indexer/indexed-file.js +50 -0
  129. package/dist/indexer/indexed-file.js.map +1 -0
  130. package/dist/indexer/invariants.d.ts +15 -0
  131. package/dist/indexer/invariants.d.ts.map +1 -0
  132. package/dist/indexer/invariants.js +96 -0
  133. package/dist/indexer/invariants.js.map +1 -0
  134. package/dist/indexer/laravel-synthetic.d.ts +10 -0
  135. package/dist/indexer/laravel-synthetic.d.ts.map +1 -0
  136. package/dist/indexer/laravel-synthetic.js +78 -0
  137. package/dist/indexer/laravel-synthetic.js.map +1 -0
  138. package/dist/indexer/parser.d.ts +4 -0
  139. package/dist/indexer/parser.d.ts.map +1 -0
  140. package/dist/indexer/parser.js +499 -0
  141. package/dist/indexer/parser.js.map +1 -0
  142. package/dist/indexer/patch.d.ts +3 -0
  143. package/dist/indexer/patch.d.ts.map +1 -0
  144. package/dist/indexer/patch.js +291 -0
  145. package/dist/indexer/patch.js.map +1 -0
  146. package/dist/indexer/rails-synthetic.d.ts +11 -0
  147. package/dist/indexer/rails-synthetic.d.ts.map +1 -0
  148. package/dist/indexer/rails-synthetic.js +80 -0
  149. package/dist/indexer/rails-synthetic.js.map +1 -0
  150. package/dist/indexer/reconcile.d.ts +9 -0
  151. package/dist/indexer/reconcile.d.ts.map +1 -0
  152. package/dist/indexer/reconcile.js +96 -0
  153. package/dist/indexer/reconcile.js.map +1 -0
  154. package/dist/indexer/resolvers/cpp.d.ts +15 -0
  155. package/dist/indexer/resolvers/cpp.d.ts.map +1 -0
  156. package/dist/indexer/resolvers/cpp.js +35 -0
  157. package/dist/indexer/resolvers/cpp.js.map +1 -0
  158. package/dist/indexer/resolvers/csharp.d.ts +2 -0
  159. package/dist/indexer/resolvers/csharp.d.ts.map +1 -0
  160. package/dist/indexer/resolvers/csharp.js +89 -0
  161. package/dist/indexer/resolvers/csharp.js.map +1 -0
  162. package/dist/indexer/resolvers/generic.d.ts +10 -0
  163. package/dist/indexer/resolvers/generic.d.ts.map +1 -0
  164. package/dist/indexer/resolvers/generic.js +35 -0
  165. package/dist/indexer/resolvers/generic.js.map +1 -0
  166. package/dist/indexer/resolvers/go.d.ts +2 -0
  167. package/dist/indexer/resolvers/go.d.ts.map +1 -0
  168. package/dist/indexer/resolvers/go.js +171 -0
  169. package/dist/indexer/resolvers/go.js.map +1 -0
  170. package/dist/indexer/resolvers/index.d.ts +26 -0
  171. package/dist/indexer/resolvers/index.d.ts.map +1 -0
  172. package/dist/indexer/resolvers/index.js +301 -0
  173. package/dist/indexer/resolvers/index.js.map +1 -0
  174. package/dist/indexer/resolvers/java.d.ts +17 -0
  175. package/dist/indexer/resolvers/java.d.ts.map +1 -0
  176. package/dist/indexer/resolvers/java.js +104 -0
  177. package/dist/indexer/resolvers/java.js.map +1 -0
  178. package/dist/indexer/resolvers/php.d.ts +2 -0
  179. package/dist/indexer/resolvers/php.d.ts.map +1 -0
  180. package/dist/indexer/resolvers/php.js +108 -0
  181. package/dist/indexer/resolvers/php.js.map +1 -0
  182. package/dist/indexer/resolvers/python.d.ts +17 -0
  183. package/dist/indexer/resolvers/python.d.ts.map +1 -0
  184. package/dist/indexer/resolvers/python.js +63 -0
  185. package/dist/indexer/resolvers/python.js.map +1 -0
  186. package/dist/indexer/resolvers/ruby.d.ts +13 -0
  187. package/dist/indexer/resolvers/ruby.d.ts.map +1 -0
  188. package/dist/indexer/resolvers/ruby.js +199 -0
  189. package/dist/indexer/resolvers/ruby.js.map +1 -0
  190. package/dist/indexer/resolvers/rust.d.ts +12 -0
  191. package/dist/indexer/resolvers/rust.d.ts.map +1 -0
  192. package/dist/indexer/resolvers/rust.js +61 -0
  193. package/dist/indexer/resolvers/rust.js.map +1 -0
  194. package/dist/indexer/resolvers/shared.d.ts +18 -0
  195. package/dist/indexer/resolvers/shared.d.ts.map +1 -0
  196. package/dist/indexer/resolvers/shared.js +71 -0
  197. package/dist/indexer/resolvers/shared.js.map +1 -0
  198. package/dist/indexer/rust-workspace.d.ts +25 -0
  199. package/dist/indexer/rust-workspace.d.ts.map +1 -0
  200. package/dist/indexer/rust-workspace.js +155 -0
  201. package/dist/indexer/rust-workspace.js.map +1 -0
  202. package/dist/indexer/symbol-edges.d.ts +19 -0
  203. package/dist/indexer/symbol-edges.d.ts.map +1 -0
  204. package/dist/indexer/symbol-edges.js +117 -0
  205. package/dist/indexer/symbol-edges.js.map +1 -0
  206. package/dist/indexer/tokenize.d.ts +26 -0
  207. package/dist/indexer/tokenize.d.ts.map +1 -0
  208. package/dist/indexer/tokenize.js +200 -0
  209. package/dist/indexer/tokenize.js.map +1 -0
  210. package/dist/indexer/ts-parser.d.ts +10 -0
  211. package/dist/indexer/ts-parser.d.ts.map +1 -0
  212. package/dist/indexer/ts-parser.js +605 -0
  213. package/dist/indexer/ts-parser.js.map +1 -0
  214. package/dist/indexer/walker.d.ts +9 -0
  215. package/dist/indexer/walker.d.ts.map +1 -0
  216. package/dist/indexer/walker.js +91 -0
  217. package/dist/indexer/walker.js.map +1 -0
  218. package/dist/init.d.ts +111 -0
  219. package/dist/init.d.ts.map +1 -0
  220. package/dist/init.js +787 -0
  221. package/dist/init.js.map +1 -0
  222. package/dist/kb/cli.d.ts +2 -0
  223. package/dist/kb/cli.d.ts.map +1 -0
  224. package/dist/kb/cli.js +377 -0
  225. package/dist/kb/cli.js.map +1 -0
  226. package/dist/kb/commit.d.ts +6 -0
  227. package/dist/kb/commit.d.ts.map +1 -0
  228. package/dist/kb/commit.js +84 -0
  229. package/dist/kb/commit.js.map +1 -0
  230. package/dist/kb/fold.d.ts +42 -0
  231. package/dist/kb/fold.d.ts.map +1 -0
  232. package/dist/kb/fold.js +285 -0
  233. package/dist/kb/fold.js.map +1 -0
  234. package/dist/kb/freshness.d.ts +9 -0
  235. package/dist/kb/freshness.d.ts.map +1 -0
  236. package/dist/kb/freshness.js +52 -0
  237. package/dist/kb/freshness.js.map +1 -0
  238. package/dist/kb/git.d.ts +4 -0
  239. package/dist/kb/git.d.ts.map +1 -0
  240. package/dist/kb/git.js +28 -0
  241. package/dist/kb/git.js.map +1 -0
  242. package/dist/kb/ids.d.ts +8 -0
  243. package/dist/kb/ids.d.ts.map +1 -0
  244. package/dist/kb/ids.js +42 -0
  245. package/dist/kb/ids.js.map +1 -0
  246. package/dist/kb/lint.d.ts +23 -0
  247. package/dist/kb/lint.d.ts.map +1 -0
  248. package/dist/kb/lint.js +102 -0
  249. package/dist/kb/lint.js.map +1 -0
  250. package/dist/kb/lookup.d.ts +55 -0
  251. package/dist/kb/lookup.d.ts.map +1 -0
  252. package/dist/kb/lookup.js +92 -0
  253. package/dist/kb/lookup.js.map +1 -0
  254. package/dist/kb/notes-index.d.ts +31 -0
  255. package/dist/kb/notes-index.d.ts.map +1 -0
  256. package/dist/kb/notes-index.js +0 -0
  257. package/dist/kb/notes-index.js.map +1 -0
  258. package/dist/kb/raw-log.d.ts +34 -0
  259. package/dist/kb/raw-log.d.ts.map +1 -0
  260. package/dist/kb/raw-log.js +106 -0
  261. package/dist/kb/raw-log.js.map +1 -0
  262. package/dist/kb/render.d.ts +17 -0
  263. package/dist/kb/render.d.ts.map +1 -0
  264. package/dist/kb/render.js +101 -0
  265. package/dist/kb/render.js.map +1 -0
  266. package/dist/kb/search.d.ts +110 -0
  267. package/dist/kb/search.d.ts.map +1 -0
  268. package/dist/kb/search.js +453 -0
  269. package/dist/kb/search.js.map +1 -0
  270. package/dist/kb/store.d.ts +35 -0
  271. package/dist/kb/store.d.ts.map +1 -0
  272. package/dist/kb/store.js +160 -0
  273. package/dist/kb/store.js.map +1 -0
  274. package/dist/kb/types.d.ts +142 -0
  275. package/dist/kb/types.d.ts.map +1 -0
  276. package/dist/kb/types.js +13 -0
  277. package/dist/kb/types.js.map +1 -0
  278. package/dist/kb/view-template.d.ts +8 -0
  279. package/dist/kb/view-template.d.ts.map +1 -0
  280. package/dist/kb/view-template.js +8 -0
  281. package/dist/kb/view-template.js.map +1 -0
  282. package/dist/kb/view.d.ts +42 -0
  283. package/dist/kb/view.d.ts.map +1 -0
  284. package/dist/kb/view.js +118 -0
  285. package/dist/kb/view.js.map +1 -0
  286. package/dist/kb/write.d.ts +72 -0
  287. package/dist/kb/write.d.ts.map +1 -0
  288. package/dist/kb/write.js +345 -0
  289. package/dist/kb/write.js.map +1 -0
  290. package/dist/keeper-state.d.ts +29 -0
  291. package/dist/keeper-state.d.ts.map +1 -0
  292. package/dist/keeper-state.js +89 -0
  293. package/dist/keeper-state.js.map +1 -0
  294. package/dist/keeper.d.ts +23 -0
  295. package/dist/keeper.d.ts.map +1 -0
  296. package/dist/keeper.js +105 -0
  297. package/dist/keeper.js.map +1 -0
  298. package/dist/migrate.d.ts +9 -0
  299. package/dist/migrate.d.ts.map +1 -0
  300. package/dist/migrate.js +102 -0
  301. package/dist/migrate.js.map +1 -0
  302. package/dist/restart.d.ts +15 -0
  303. package/dist/restart.d.ts.map +1 -0
  304. package/dist/restart.js +48 -0
  305. package/dist/restart.js.map +1 -0
  306. package/dist/server/find.d.ts +39 -0
  307. package/dist/server/find.d.ts.map +1 -0
  308. package/dist/server/find.js +979 -0
  309. package/dist/server/find.js.map +1 -0
  310. package/dist/server/mcp.d.ts +117 -0
  311. package/dist/server/mcp.d.ts.map +1 -0
  312. package/dist/server/mcp.js +301 -0
  313. package/dist/server/mcp.js.map +1 -0
  314. package/dist/server/searcher.d.ts +14 -0
  315. package/dist/server/searcher.d.ts.map +1 -0
  316. package/dist/server/searcher.js +146 -0
  317. package/dist/server/searcher.js.map +1 -0
  318. package/dist/server/tools.d.ts +17 -0
  319. package/dist/server/tools.d.ts.map +1 -0
  320. package/dist/server/tools.js +875 -0
  321. package/dist/server/tools.js.map +1 -0
  322. package/dist/status.d.ts +12 -0
  323. package/dist/status.d.ts.map +1 -0
  324. package/dist/status.js +157 -0
  325. package/dist/status.js.map +1 -0
  326. package/dist/types.d.ts +138 -0
  327. package/dist/types.d.ts.map +1 -0
  328. package/dist/types.js +3 -0
  329. package/dist/types.js.map +1 -0
  330. package/dist/watcher.d.ts +11 -0
  331. package/dist/watcher.d.ts.map +1 -0
  332. package/dist/watcher.js +67 -0
  333. package/dist/watcher.js.map +1 -0
  334. package/hooks/canonical-find-key.mjs +68 -0
  335. package/hooks/codex-find-nudge.mjs +15 -0
  336. package/hooks/codex-find-preguard.mjs +14 -0
  337. package/hooks/codex-kb-elicit.mjs +329 -0
  338. package/hooks/codex-kb-recall.mjs +151 -0
  339. package/hooks/codex-nudge-handler.mjs +370 -0
  340. package/hooks/codex-preguard-handler.mjs +75 -0
  341. package/hooks/codex-run-hook.mjs +132 -0
  342. package/hooks/coldstart-call.mjs +58 -0
  343. package/hooks/cursor-find-nudge.mjs +15 -0
  344. package/hooks/cursor-find-preguard.mjs +14 -0
  345. package/hooks/cursor-input.mjs +72 -0
  346. package/hooks/cursor-kb-elicit.mjs +335 -0
  347. package/hooks/cursor-kb-recall.mjs +138 -0
  348. package/hooks/cursor-nudge-handler.mjs +33 -0
  349. package/hooks/cursor-preguard-handler.mjs +39 -0
  350. package/hooks/cursor-run-hook.mjs +129 -0
  351. package/hooks/find-nudge.mjs +15 -0
  352. package/hooks/find-preguard.mjs +14 -0
  353. package/hooks/kb-elicit.mjs +334 -0
  354. package/hooks/kb-recall.mjs +151 -0
  355. package/hooks/nudge-handler.mjs +370 -0
  356. package/hooks/preguard-handler.mjs +75 -0
  357. package/hooks/run-hook.mjs +132 -0
  358. package/package.json +69 -0
  359. package/templates/coldstart.cli.md +60 -0
  360. package/templates/coldstart.mcp.md +59 -0
@@ -0,0 +1,979 @@
1
+ /**
2
+ * `coldstart find <terms...>` — the rich-page query surface.
3
+ *
4
+ * One command, one page, one read. The design (verified against the arches
5
+ * q03/q09 traces, see docs/) fuses two signals the two-tool surface kept apart:
6
+ *
7
+ * - grep-RECALL for candidate selection: ripgrep every term across the repo,
8
+ * rank files by DISTINCT-TERM COVERAGE. This catches body-level matches the
9
+ * declared-name index misses (nested defs, dynamic refs) and is the reason a
10
+ * discriminating file rises — it covers MORE of the query than its lookalikes.
11
+ * - AST + grep PRECISION per candidate: for the top files, print indexed
12
+ * symbols (with line numbers) AND a filtered scan of body lines that contain
13
+ * the query terms (def/class/assignment lines first). The nested defs the
14
+ * parser cannot see and the `editable=False`-style context both show up here,
15
+ * inline — so the agent answers without a follow-up Read.
16
+ *
17
+ * Recall is bounded by the terms it is given: a one-token query cannot
18
+ * out-rank lookalikes. The skill instructs the agent to pass every salient
19
+ * identifier from the task — that is the load-bearing half of this command.
20
+ */
21
+ import { execFile, execFileSync } from 'node:child_process';
22
+ import { readFileSync } from 'node:fs';
23
+ import { deriveRelatedFiles } from '../indexer/content-tokens.js';
24
+ import { resolveRg, invalidateRg } from './searcher.js';
25
+ import { loadAll, notebookExists } from '../kb/store.js';
26
+ import { NOTES_REL } from '../kb/raw-log.js';
27
+ import { stampAnchors } from '../kb/freshness.js';
28
+ let _searcher = null;
29
+ /** Per-term scans run concurrently up to this cap, each scan single-threaded.
30
+ * Measured (jmri): 3 concurrent threads=1 git greps ≈ one combined pass (2.85s
31
+ * vs 2.65s) WITH per-term attribution, vs 0.7-2.5s × N terms sequentially. */
32
+ const SCAN_CONCURRENCY = 4;
33
+ function probe(bin, args, cwd, requireSuccess = false) {
34
+ try {
35
+ execFileSync(bin, args, { cwd, stdio: 'ignore', timeout: 4000 });
36
+ return true;
37
+ }
38
+ catch (e) {
39
+ if (requireSuccess)
40
+ return false;
41
+ // ENOENT = binary absent (unusable); any other exit = present but errored (usable)
42
+ return e.code !== 'ENOENT';
43
+ }
44
+ }
45
+ function pickSearcher(root) {
46
+ if (_searcher)
47
+ return _searcher;
48
+ const rg = resolveRg();
49
+ if (rg)
50
+ _searcher = { kind: 'rg', rg };
51
+ // The git probe MUST require exit 0: `rev-parse --is-inside-work-tree` is the
52
+ // work-tree test itself. With the lenient rule, any non-git root picked
53
+ // gitgrep, every `git grep` then failed, and find's body-content lane
54
+ // silently vanished (name/symbol matches only).
55
+ else if (probe('git', ['rev-parse', '--is-inside-work-tree'], root, true))
56
+ _searcher = { kind: 'gitgrep' };
57
+ else if (probe('grep', ['--version'], root))
58
+ _searcher = { kind: 'grep' };
59
+ else
60
+ _searcher = { kind: 'node' };
61
+ return _searcher;
62
+ }
63
+ const MAX_CANDIDATES_LISTED = 12;
64
+ const DETAIL_TOP = 3; // top files get an inline convergence preview; the rest list as bare paths
65
+ // Convergence-preview parameters (denoise hub files: show where the rarest terms cluster).
66
+ const WINDOW = 3; // lines each side counted for local term convergence
67
+ const RARE_FRAC = 0.05; // a term is a discriminator if it hits < 5% of candidate files
68
+ const MAX_PREVIEW_LINES = 8; // total body lines shown per file
69
+ const MAX_CLUSTERS = 2; // distinct match regions shown per file
70
+ const IMPORT_RE = /^\s*(import\b|from\s+[\w.]+\s+import\b|export\s+[^=]*\bfrom\b|(?:const|let|var)\s+[^=]*=\s*require\s*\(|require\s*\(|use\s+[\w:\\]+|using\s+[\w.]+|#include\b|@import\b)/;
71
+ /** Prose/doc files (release notes, READMEs). They mention every term in plain
72
+ * text and otherwise out-rank the source file the agent is actually looking for,
73
+ * so they're partitioned into a secondary list rather than competing with code. */
74
+ const DOC_EXT = /\.(md|markdown|rst|txt|adoc|rdoc)$/i;
75
+ const isDoc = (rel) => DOC_EXT.test(rel);
76
+ const MAX_DOC_CANDIDATES = 4;
77
+ /** Stylesheets match style vocabulary (sidebar/nav/menu as class names) and crowd code queries,
78
+ * but ARE the right answer for a genuine CSS task — so they're partitioned into a bare list
79
+ * (no preview, no evidence sentence) rather than dropped. SCSS is the source, never single it out. */
80
+ const STYLE_EXT = /\.(css|scss|sass|less)$/i;
81
+ const isStyle = (rel) => STYLE_EXT.test(rel);
82
+ const MAX_STYLE_CANDIDATES = 4;
83
+ /** Minified bundles and vendored third-party trees are never the human-readable answer: a minified
84
+ * file matches many terms on one giant line (inflating coverage) and previews as noise. Detected by
85
+ * path (vendored tree / *.min.*) OR by content shape — chars-per-line ≫ real source (minified bundles
86
+ * run 2700-43000 cpl, real source p99 ≈ 600), which catches un-suffixed bundles like nifty.js that the
87
+ * path rule misses. Scoped to script/style languages so data/config files and golds are never touched.
88
+ * Index-only (lineCount/tokenEstimate), NO file read. */
89
+ const VENDOR_PATH = /(^|\/)(node_modules|vendor|bower_components)\//i;
90
+ const MIN_SUFFIX = /\.min\.(js|css)$/i;
91
+ const SCRIPT_STYLE_EXT = /\.(js|jsx|ts|tsx|mjs|cjs|css|scss|sass|less)$/i;
92
+ const MINIFIED_CPL = 1000;
93
+ const isVendorOrMinified = (rel, file) => {
94
+ if (VENDOR_PATH.test(rel) || MIN_SUFFIX.test(rel))
95
+ return true;
96
+ return SCRIPT_STYLE_EXT.test(rel) && file.lineCount > 0
97
+ && (file.tokenEstimate * 4) / file.lineCount > MINIFIED_CPL;
98
+ };
99
+ /**
100
+ * Pass 1 — import co-citation among the candidate set (precision relations).
101
+ *
102
+ * For the files we're about to list, compute how they relate TO EACH OTHER via the
103
+ * import graph, so the page can say "consumed by graph.py" instead of leaving the
104
+ * agent to infer it. Three relations, strongest first:
105
+ * - consumes / consumedBy: a DIRECT import edge between two candidates (a fact).
106
+ * - siblings: two candidates share a non-hub import neighbour (co-citation). A
107
+ * shared neighbour imported by > HUB_DEGREE files is a utility hub, not evidence
108
+ * of relatedness, so it's discounted (offline: hub-discounted co-citation degree
109
+ * separated gold from lookalikes 2.05×; raw degree did not).
110
+ * Index-only (outEdges/inEdges/importedByCount), NO file reads.
111
+ */
112
+ const HUB_DEGREE = 30;
113
+ function computeImportRelations(index, candidates) {
114
+ const candSet = new Set(candidates);
115
+ // Non-hub neighbour signature per candidate: low-fanin import targets + low-fanout importers.
116
+ // Importer ids are namespaced ('<' prefix) so a shared target and a shared importer never collide.
117
+ const sig = new Map();
118
+ for (const c of candidates) {
119
+ const s = new Set();
120
+ for (const t of index.outEdges.get(c) ?? []) {
121
+ const f = index.files.get(t);
122
+ if (f && f.importedByCount <= HUB_DEGREE)
123
+ s.add(t);
124
+ }
125
+ for (const p of index.inEdges.get(c) ?? []) {
126
+ if ((index.outEdges.get(p) ?? []).length <= HUB_DEGREE)
127
+ s.add('<' + p);
128
+ }
129
+ sig.set(c, s);
130
+ }
131
+ const rels = new Map();
132
+ for (const c of candidates) {
133
+ const outs = new Set(index.outEdges.get(c) ?? []);
134
+ const ins = new Set(index.inEdges.get(c) ?? []);
135
+ const consumes = [], consumedBy = [], siblings = [];
136
+ const sc = sig.get(c);
137
+ for (const d of candidates) {
138
+ if (d === c)
139
+ continue;
140
+ if (outs.has(d)) {
141
+ consumes.push(d);
142
+ continue;
143
+ }
144
+ if (ins.has(d)) {
145
+ consumedBy.push(d);
146
+ continue;
147
+ }
148
+ const sd = sig.get(d);
149
+ let shared = false;
150
+ for (const n of sc)
151
+ if (sd.has(n)) {
152
+ shared = true;
153
+ break;
154
+ }
155
+ if (shared)
156
+ siblings.push(d);
157
+ }
158
+ rels.set(c, { consumes, consumedBy, siblings });
159
+ void candSet;
160
+ }
161
+ return rels;
162
+ }
163
+ /** Separator-fold for the NAME/PATH/SYMBOL channels: query vocabulary and code
164
+ * naming disagree on separators for the same compound — `spatial-view` vs
165
+ * `spatial_view` vs `SpatialView` vs `spatialview` are one concept. Folding
166
+ * `-`/`_` out of BOTH sides makes those channels separator-invariant (a strict
167
+ * superset of the literal matches). Body/grep matching stays literal — text is
168
+ * matched as written. (Root-caused from the q16 three-arm trace: the gold
169
+ * migration ranked tail-[1/3] purely because the agent hyphenated its query.) */
170
+ export function foldSep(s) {
171
+ return s.replace(/[-_]/g, '');
172
+ }
173
+ /** Clean a raw query into distinct, matchable terms (identifiers ≥3 chars). */
174
+ export function parseTerms(raw) {
175
+ const out = [];
176
+ const seen = new Set();
177
+ for (const w of raw.split(/[\s[\]|,()'".]+/)) {
178
+ if (w.length < 3 || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(w))
179
+ continue;
180
+ const key = w.toLowerCase();
181
+ if (seen.has(key))
182
+ continue;
183
+ seen.add(key);
184
+ out.push(w);
185
+ }
186
+ return out;
187
+ }
188
+ /** argv for one single-threaded per-term scan (they parallelize ACROSS terms). */
189
+ function scanArgv(searcher, term) {
190
+ switch (searcher.kind) {
191
+ case 'rg':
192
+ // `-j2`: two worker threads per scan — bounded so N concurrent finds can't
193
+ // thrash (unbounded default threads was the original mechanism, see PR#46),
194
+ // but measured on jmri/M1: -j1 3-term recall 1.04s, -j2 0.60s, -j4 0.70s
195
+ // with sys-time blowup. 12 simultaneous -j2 scans finish in 0.8s — no thrash.
196
+ // rg skips .gitignore'd + binary files and searches untracked by default.
197
+ return { bin: searcher.rg.bin, args: ['-l', '-i', '-F', '-j', '2', '--', term, '.'], ...(searcher.rg.argv0 ? { argv0: searcher.rg.argv0 } : {}) };
198
+ case 'gitgrep':
199
+ // `-c grep.threads=1`: same bounded-threads reasoning as rg -j1 above.
200
+ // `--untracked`: also search new, uncommitted files — the index includes them
201
+ // (the keeper watches live edits), but plain `git grep` only sees tracked files.
202
+ return { bin: 'git', args: ['-c', 'grep.threads=1', 'grep', '--untracked', '-l', '-i', '-F', '-I', '-e', term] };
203
+ case 'grep':
204
+ return { bin: 'grep', args: ['-r', '-l', '-i', '-F', '-I', '--', term, '.'] };
205
+ }
206
+ }
207
+ /** Run one external scan; repo-relative paths, [] on no-match, null on SPAWN failure
208
+ * (binary vanished — distinct from exit 1 so the caller can re-resolve rg). */
209
+ function listFilesExternal(searcher, root, term) {
210
+ const { bin, args, argv0 } = scanArgv(searcher, term);
211
+ return new Promise((resolve) => {
212
+ execFile(bin, args, {
213
+ cwd: root,
214
+ encoding: 'utf8',
215
+ maxBuffer: 64 * 1024 * 1024,
216
+ ...(argv0 ? { argv0 } : {}),
217
+ }, (err, stdout) => {
218
+ // execFile err: string `code` (ENOENT/EACCES) = spawn-level failure;
219
+ // numeric `code` = the tool ran and exited non-zero (= no matches).
220
+ if (err && typeof err.code === 'string')
221
+ return resolve(null);
222
+ resolve((stdout ?? '').split('\n').map((l) => l.replace(/^\.\//, '').trim()).filter(Boolean));
223
+ });
224
+ });
225
+ }
226
+ /** Map with a concurrency cap; results stay index-aligned with `items`. */
227
+ async function mapLimit(items, limit, fn) {
228
+ const out = new Array(items.length);
229
+ let next = 0;
230
+ const worker = async () => {
231
+ while (next < items.length) {
232
+ const i = next++;
233
+ out[i] = await fn(items[i]);
234
+ }
235
+ };
236
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
237
+ return out;
238
+ }
239
+ /**
240
+ * Build term-coverage per indexed file. External searchers run per-term; the
241
+ * Node fallback reads each indexed file once and tests all terms together.
242
+ * Only files present in the index count (restricts to indexed code files).
243
+ */
244
+ export async function collectCoverage(index, root, terms) {
245
+ const coverage = new Map();
246
+ const add = (rel, term) => {
247
+ if (!index.files.has(rel))
248
+ return;
249
+ let s = coverage.get(rel);
250
+ if (!s) {
251
+ s = new Set();
252
+ coverage.set(rel, s);
253
+ }
254
+ s.add(term);
255
+ };
256
+ const lowers = terms.map((t) => t.toLowerCase());
257
+ // (A) body-content matches — what grep sees. Terms scan concurrently (capped).
258
+ let searcher = pickSearcher(root);
259
+ if (searcher.kind !== 'node') {
260
+ const s = searcher;
261
+ let results = await mapLimit(terms, SCAN_CONCURRENCY, (t) => listFilesExternal(s, root, t));
262
+ if (searcher.kind === 'rg' && results.some((r) => r === null)) {
263
+ // The persisted rg vanished (app uninstalled, binary moved). Drop the
264
+ // record, re-resolve the whole chain once, and rerun the scans.
265
+ invalidateRg();
266
+ _searcher = null;
267
+ searcher = pickSearcher(root);
268
+ if (searcher.kind !== 'node') {
269
+ const s2 = searcher;
270
+ results = await mapLimit(terms, SCAN_CONCURRENCY, (t) => listFilesExternal(s2, root, t));
271
+ }
272
+ else {
273
+ results = terms.map(() => []);
274
+ }
275
+ }
276
+ for (let i = 0; i < terms.length; i++) {
277
+ for (const rel of results[i] ?? [])
278
+ add(rel, terms[i]);
279
+ }
280
+ }
281
+ if (searcher.kind === 'node') {
282
+ // Pure-Node fallback: one read per indexed file, all terms tested at once.
283
+ for (const [rel, file] of index.files.entries()) {
284
+ let text;
285
+ try {
286
+ text = readFileSync(file.path, 'utf8').toLowerCase();
287
+ }
288
+ catch {
289
+ continue;
290
+ }
291
+ for (let i = 0; i < terms.length; i++) {
292
+ if (text.includes(lowers[i]))
293
+ add(rel, terms[i]);
294
+ }
295
+ }
296
+ }
297
+ // (B) name/path/symbol matches — the signal grep CANNOT see. A file whose
298
+ // distinguishing token is its own filename (e.g. 11726_join_tile_nodegroup.py)
299
+ // or a declared symbol name is invisible to a body-content grep; without this
300
+ // it sinks into a wall of equal-coverage lookalikes. This restores the
301
+ // declared-name index that the two-tool GO surface ranked on.
302
+ // Separator-folded: `spatial-view` must hit `spatial_view_…filter.py`.
303
+ const folded = lowers.map(foldSep);
304
+ for (const [rel, file] of index.files.entries()) {
305
+ const fpath = foldSep(rel.toLowerCase());
306
+ for (let i = 0; i < terms.length; i++) {
307
+ const t = folded[i];
308
+ if (fpath.includes(t) || file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(t))) {
309
+ add(rel, terms[i]);
310
+ }
311
+ }
312
+ }
313
+ return coverage;
314
+ }
315
+ /** Per-term document frequency over the candidate set (how many candidate files contain each term).
316
+ * This is the rarity signal: a low-DF term is a discriminator, a high-DF term ("view" → 45% of files)
317
+ * is noise that should neither rank a file nor light up a body line. */
318
+ function docFreq(coverage, terms) {
319
+ const df = new Map();
320
+ for (const t of terms)
321
+ df.set(t, 0);
322
+ for (const set of coverage.values()) {
323
+ for (const t of set)
324
+ df.set(t, (df.get(t) ?? 0) + 1);
325
+ }
326
+ return df;
327
+ }
328
+ /** Mark comment-only and docstring lines so they don't count as content matches — this is what kills
329
+ * the "view"⊂"review" docstring noise and prose hits. Approximate (line-based) but cheap and language-agnostic. */
330
+ function excludedLines(lines) {
331
+ const ex = new Array(lines.length).fill(false);
332
+ let inTriple = false;
333
+ let delim = '';
334
+ for (let i = 0; i < lines.length; i++) {
335
+ const s = lines[i].trim();
336
+ if (inTriple) {
337
+ ex[i] = true;
338
+ if (s.includes(delim))
339
+ inTriple = false;
340
+ continue;
341
+ }
342
+ const m = s.match(/("""|''')/);
343
+ if (m) {
344
+ ex[i] = true;
345
+ if ((s.split(m[1]).length - 1) % 2 === 1) {
346
+ inTriple = true;
347
+ delim = m[1];
348
+ }
349
+ continue;
350
+ }
351
+ if (/^(#|\/\/|\/\*|\*|<!--|;;|--\s)/.test(s))
352
+ ex[i] = true;
353
+ }
354
+ return ex;
355
+ }
356
+ /** Innermost indexed symbol whose line range contains `line` (1-based) — used to LABEL a preview
357
+ * cluster with its scope (e.g. "in class ResourceInstance"), not to print the whole symbol list. */
358
+ function enclosing(file, line) {
359
+ let best = null;
360
+ for (const s of file.symbols) {
361
+ if (s.startLine <= line && line <= s.endLine) {
362
+ if (!best || s.endLine - s.startLine < best.endLine - best.startLine)
363
+ best = s;
364
+ }
365
+ }
366
+ return best;
367
+ }
368
+ /**
369
+ * Convergence preview: instead of dumping every line that mentions a term (which floods hub files
370
+ * like models.py with matches from unrelated classes), score each matched line by RARITY-WEIGHTED
371
+ * LOCAL CONVERGENCE — sum of 1/DF over the distinct query terms within ±WINDOW lines. The answer is
372
+ * where the rare terms co-locate; scattered single-common-term matches score ~20-60× lower and drop out.
373
+ * A matched line that is an import statement is boosted and annotated with its resolved target, because
374
+ * it both marks a usage and points at the definition file.
375
+ */
376
+ function convergencePreview(file, terms, df, nFiles, specToTarget) {
377
+ let text;
378
+ try {
379
+ text = readFileSync(file.path, 'utf8');
380
+ }
381
+ catch {
382
+ return [];
383
+ }
384
+ const lines = text.split('\n');
385
+ const excl = excludedLines(lines);
386
+ const lterms = terms.map((t) => t.toLowerCase());
387
+ const weight = (t) => nFiles / Math.max(1, df.get(t) ?? nFiles);
388
+ const isRare = (t) => (df.get(t) ?? nFiles) / nFiles < RARE_FRAC;
389
+ // terms present in each non-excluded line
390
+ const inLine = lines.map((l, i) => {
391
+ if (excl[i])
392
+ return [];
393
+ const low = l.toLowerCase();
394
+ return terms.filter((_t, k) => low.includes(lterms[k]));
395
+ });
396
+ const hits = [];
397
+ for (let i = 0; i < lines.length; i++) {
398
+ if (inLine[i].length === 0)
399
+ continue;
400
+ const windowTerms = new Set();
401
+ for (let j = Math.max(0, i - WINDOW); j <= Math.min(lines.length - 1, i + WINDOW); j++) {
402
+ for (const t of inLine[j])
403
+ windowTerms.add(t);
404
+ }
405
+ // keep only discriminating lines: ≥2 distinct terms nearby, OR a rare term on the line itself.
406
+ // (drops `resourceinstanceid` in an unrelated class — one common term, no neighbours.)
407
+ if (windowTerms.size < 2 && !inLine[i].some(isRare))
408
+ continue;
409
+ let score = 0;
410
+ for (const t of windowTerms)
411
+ score += weight(t);
412
+ if (IMPORT_RE.test(lines[i]))
413
+ score *= 1.5;
414
+ hits.push({ line: i, score });
415
+ }
416
+ if (hits.length === 0)
417
+ return [];
418
+ // cluster kept hits by proximity, then rank clusters by peak score
419
+ hits.sort((a, b) => a.line - b.line);
420
+ const clusters = [];
421
+ for (const h of hits) {
422
+ const last = clusters[clusters.length - 1];
423
+ if (last && h.line - last[last.length - 1].line <= WINDOW)
424
+ last.push(h);
425
+ else
426
+ clusters.push([h]);
427
+ }
428
+ const peakOf = (c) => Math.max(...c.map((h) => h.score));
429
+ clusters.sort((a, b) => peakOf(b) - peakOf(a));
430
+ const out = [];
431
+ let shown = 0;
432
+ for (const cluster of clusters.slice(0, MAX_CLUSTERS)) {
433
+ if (shown >= MAX_PREVIEW_LINES)
434
+ break;
435
+ const peak = cluster.reduce((a, b) => (b.score > a.score ? b : a));
436
+ const sym = enclosing(file, peak.line + 1);
437
+ out.push(sym
438
+ ? ` in ${sym.kind} ${sym.name} [L${sym.startLine}-${sym.endLine}]:`
439
+ : ` near L${peak.line + 1}:`);
440
+ const lineSet = new Set(cluster.map((h) => h.line));
441
+ const start = Math.max(0, cluster[0].line - 1); // one context line above
442
+ const end = Math.min(lines.length - 1, cluster[cluster.length - 1].line + 1); // and below
443
+ for (let i = start; i <= end && shown < MAX_PREVIEW_LINES; i++) {
444
+ if (excl[i] && !lineSet.has(i))
445
+ continue; // skip comment context, keep comment only if it's a hit
446
+ const raw = lines[i].trim().slice(0, 120);
447
+ if (!raw)
448
+ continue;
449
+ let suffix = '';
450
+ if (IMPORT_RE.test(lines[i])) {
451
+ const tgt = specToTarget.find((st) => st.spec && lines[i].includes(st.spec));
452
+ if (tgt)
453
+ suffix = ` → defined in ${tgt.target}`;
454
+ }
455
+ out.push(` L${i + 1}: ${raw}${suffix}`);
456
+ shown++;
457
+ }
458
+ }
459
+ return out;
460
+ }
461
+ // Notebook lane: agent-authored knowledge about a previewed file, shown at
462
+ // the point where the read decision is made (49% of gold answer files were
463
+ // note-anchored after one 27-question run). Evidence, not instruction — a
464
+ // title + gist + freshness stamp; whether the file still needs opening stays
465
+ // the agent's call. One note per file: file-note summary beats flow-step role
466
+ // beats lesson title.
467
+ export function buildNoteMap(root, lterms) {
468
+ const out = new Map();
469
+ if (!notebookExists(root))
470
+ return out;
471
+ let notes;
472
+ try {
473
+ notes = loadAll(root).notes;
474
+ }
475
+ catch {
476
+ return out;
477
+ }
478
+ for (const note of notes) {
479
+ if (note.status !== 'active')
480
+ continue;
481
+ const rank = note.type === 'file' ? 0 : note.type === 'flow' ? 1 : 2;
482
+ // Hub files anchor many notes — pick the one the QUERY is about (term
483
+ // hits on the note's declared identity), not just the "best" type. An
484
+ // off-topic annotation on a hub file is noise, not evidence. A file
485
+ // note's identity includes its facet SYMBOLS: a query naming GraphModel
486
+ // is about the models.py file note even though its title is just the
487
+ // path — without them, any flow sharing one query word steals the slot
488
+ // from the note that holds the answer.
489
+ const name = foldSep([note.title, ...note.aliases,
490
+ ...(note.type === 'file' ? note.facets.map((f) => f.symbol) : [])].join(' ').toLowerCase());
491
+ const hits = lterms.filter((t) => name.includes(foldSep(t))).length;
492
+ for (const a of note.anchors) {
493
+ const cur = out.get(a.path);
494
+ if (cur && (cur.hits > hits || (cur.hits === hits && cur.rank <= rank)))
495
+ continue;
496
+ const role = note.type === 'flow' ? note.steps.find((s) => s.path === a.path)?.role : undefined;
497
+ out.set(a.path, { note, role, rank, hits });
498
+ }
499
+ }
500
+ return out;
501
+ }
502
+ /** First sentence (or first ~n chars) of a prose blob — the agent-authored
503
+ * summary opener, which prompt v4 makes carry the file's non-obvious point. */
504
+ function firstSentence(s, n) {
505
+ const t = s.replace(/\s+/g, ' ').trim();
506
+ const stop = t.search(/[.!?](\s|$)/);
507
+ const cut = stop >= 20 ? t.slice(0, stop + 1) : t;
508
+ return cut.length > n ? cut.slice(0, n) + '…' : cut;
509
+ }
510
+ /** Empty line → the caller drops it: a Summary: that only restates the path is
511
+ * noise, not evidence (a file note's title IS the path).
512
+ * `summary` — the gist is summary-grade prose (a single's body sentence or a
513
+ * query-matched facet detail): a past agent's verified description of THIS
514
+ * file. find then drops the convergence preview for the file — the summary
515
+ * IS the preview (user ruling 2026-07-06: file summaries live in find; flows
516
+ * live in kb search). Stale notes still surface — most of a note survives
517
+ * an edit — with [evidence changed] marking what to re-verify. */
518
+ export function noteLine(root, rel, entry, lterms = []) {
519
+ const { note, role } = entry;
520
+ const clamp = (s, n) => {
521
+ const t = s.replace(/\s+/g, ' ').trim();
522
+ return t.length > n ? t.slice(0, n) + '…' : t;
523
+ };
524
+ let gist = '';
525
+ let summary = false;
526
+ if (note.type === 'flow') {
527
+ // Full role text — the step role is the flow's statement about THIS
528
+ // file, and a clamp cuts exactly at the payload (q8 replay evidence).
529
+ const roleTxt = role ? role.replace(/\s+/g, ' ').trim() : '';
530
+ gist = `part of "${clamp(note.title, 80)}"${roleTxt ? ` — ${roleTxt}` : ''}`;
531
+ }
532
+ else if (note.type === 'file') {
533
+ // Hub whose facet the query names → that facet's FULL detail (it replaces
534
+ // the preview, so untruncated is still a net page shrink; a clamp only
535
+ // hides the payload — 11-transcript replay showed agents never fetch the
536
+ // full note off a teaser line). Single → the WHOLE body: at ~400B the
537
+ // body IS the note (user ruling 2026-07-07). Otherwise the hub's symbol
538
+ // inventory (what the note knows, one lookup away).
539
+ const matched = lterms.length
540
+ ? note.facets.find((f) => { const fs = foldSep(f.symbol.toLowerCase()); return lterms.some((t) => fs.includes(foldSep(t))); })
541
+ : undefined;
542
+ const prose = (note.summary || note.body || '').replace(/\s+/g, ' ').trim();
543
+ if (matched) {
544
+ gist = `${matched.symbol} — ${matched.detail.replace(/\s+/g, ' ').trim()}`;
545
+ summary = true;
546
+ }
547
+ else if (prose && note.character === 'single') {
548
+ gist = prose;
549
+ summary = true;
550
+ }
551
+ else if (prose) {
552
+ gist = firstSentence(prose, 220);
553
+ summary = true;
554
+ }
555
+ else if (note.facets.length)
556
+ gist = clamp(`facets: ${note.facets.map((f) => f.symbol).join(', ')}`, 150);
557
+ else if (note.aliases.length)
558
+ gist = clamp(note.aliases[0], 120);
559
+ else
560
+ return { line: '', summary: false }; // nothing beyond the path — silence over noise
561
+ }
562
+ else {
563
+ gist = `${note.kind ?? 'lesson'}: ${clamp(note.title, 130)}`;
564
+ }
565
+ const anchor = note.anchors.find((a) => a.path === rel);
566
+ const state = anchor ? stampAnchors(root, [anchor])[0]?.state : undefined;
567
+ const fresh = state === 'fresh' ? ' [fresh]' : state === 'changed' || state === 'missing' ? ' [evidence changed]' : '';
568
+ // "Summary:" (user ruling 2026-07-08): a past agent's high-level overview of
569
+ // THIS file — and the full note is a real markdown file the reader can open
570
+ // directly. A path is the one pointer agents reliably follow (grep→Read is
571
+ // trained-in; "re-search by title words" measurably is not).
572
+ return { line: ` Summary: ${gist}${fresh} · full note: ${NOTES_REL}/${note.id}.md`, summary };
573
+ }
574
+ export async function buildRichPage(index, root, rawQuery, asData = false, via = false) {
575
+ const terms = parseTerms(rawQuery);
576
+ if (terms.length === 0) {
577
+ return 'find: no usable terms. Pass the salient identifiers from the task, e.g. `coldstart find ResourceInstance principaluser editable`.';
578
+ }
579
+ // 1. grep-recall: term → indexed files, accumulate distinct-term coverage.
580
+ const coverage = await collectCoverage(index, root, terms);
581
+ if (coverage.size === 0) {
582
+ return `find: no indexed file contains any of [${terms.join(', ')}].\nThese identifiers may not exist in the repo, or be in excluded/binary files. Reformulate, or grep directly.`;
583
+ }
584
+ const df = docFreq(coverage, terms);
585
+ const nFiles = index.files.size;
586
+ const lterms = terms.map((t) => t.toLowerCase());
587
+ const flterms = lterms.map(foldSep); // name/path/symbol channels match separator-folded
588
+ const rareTerms = terms.filter((t) => (df.get(t) ?? nFiles) / nFiles < RARE_FRAC);
589
+ // Rarity (BM25-style IDF): a term hitting few files is a discriminator; a common domain
590
+ // token (order/payment/view) hits ~everything → near-zero weight. This is what makes the
591
+ // ranker robust on domain-heavy repos and what keeps it from promoting hubs.
592
+ const idf = (t) => {
593
+ const d = df.get(t) ?? 0;
594
+ return Math.log(1 + (nFiles - d + 0.5) / (d + 0.5));
595
+ };
596
+ const maxIdf = Math.max(1, ...terms.map(idf));
597
+ // Relevance score — index-only, NO file reads (term frequency was measured to add nothing
598
+ // once name/path are in play; see find-ranking sim). Binary term-coverage sets the tier;
599
+ // within/across tiers a file is lifted by rarity-weighted evidence that a term NAMES a
600
+ // declared symbol here (definition signal) or appears in the PATH. Length-free + IDF-weighted,
601
+ // so the 2700-line hub gets no edge. (Offline: top-12 recall 67%→79%, hub-misfires ~halved.)
602
+ const NAME_W = 3, PATH_W = 2;
603
+ const scoreCache = new Map();
604
+ const score = (rel) => {
605
+ let v = scoreCache.get(rel);
606
+ if (v !== undefined)
607
+ return v;
608
+ const file = index.files.get(rel);
609
+ const fpathLow = foldSep(rel.toLowerCase());
610
+ let boost = 0;
611
+ for (let k = 0; k < terms.length; k++) {
612
+ const t = flterms[k];
613
+ if (!t)
614
+ continue;
615
+ if (file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(t)))
616
+ boost += NAME_W * idf(terms[k]);
617
+ if (fpathLow.includes(t))
618
+ boost += PATH_W * idf(terms[k]);
619
+ }
620
+ v = coverage.get(rel).size * 3 + boost / maxIdf;
621
+ scoreCache.set(rel, v);
622
+ return v;
623
+ };
624
+ // rank: relevance score desc, then path (stable). importedBy dropped — centrality was the hub trap.
625
+ const ranked = [...coverage.entries()].sort((a, b) => {
626
+ const d = score(b[0]) - score(a[0]);
627
+ return d !== 0 ? d : a[0].localeCompare(b[0]);
628
+ });
629
+ // Per-file MATCH EVIDENCE, as a short plain-English sentence — what the file actually does with the
630
+ // query terms, strongest signal first: declares a symbol of that name > the term is in its path >
631
+ // the term is imported here (a consumer, not the definer) > a rare term sits in its body. Common
632
+ // (non-discriminating) body terms are omitted — every file matches them, so listing them is the
633
+ // noise we're cutting. `disc` (count of discriminating signals) doubles as the NOISE FLOOR:
634
+ // disc===0 means the file matched only common words → dropped from the listing. Index-only
635
+ // (graph edges + declared symbols), NO file reads; cached per file.
636
+ const rareSet = new Set(rareTerms);
637
+ const tick = (s) => `\`${s}\``;
638
+ // Does `rel` import a definition that carries `term`? A term appearing on an import line resolves
639
+ // to an out-edge whose target file is named for the term or declares a symbol of that name. Cheap:
640
+ // walks only this file's out-edges. Lets the sentence say "imports X" (consumer) vs "mentions X".
641
+ const importTargetFor = (rel, term) => {
642
+ const tl = foldSep(term.toLowerCase());
643
+ for (const tgt of index.outEdges.get(rel) ?? []) {
644
+ if (foldSep(tgt.toLowerCase()).includes(tl))
645
+ return tgt;
646
+ const tf = index.files.get(tgt);
647
+ if (tf && tf.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(tl)))
648
+ return tgt;
649
+ }
650
+ return null;
651
+ };
652
+ // Grade-1 symbol locator: for a previewed file, the DECLARED symbols whose name matches a
653
+ // discriminating query term, with their line ranges — so the agent reads the right offset
654
+ // instead of windowing a large file to find them. The convergence preview shows where rare
655
+ // terms CLUSTER (often an incidental method); this shows where the NAMED symbols LIVE — the
656
+ // agent's actual read targets. (q22 read graph.py 8× hunting for serialize/restore_state that
657
+ // the index had located at L1965/L2098.) Index-only, NO file read. Container symbols that
658
+ // enclose another matched symbol are dropped (keep the specific method, not the 2800-line class).
659
+ const MATCHED_SYM_CAP = 6;
660
+ const MATCHED_SYM_MIN_LINES = 80; // only worth it on files big enough to hunt within
661
+ // Gate symbol matching on terms that DISCRIMINATE THIS QUERY (idf ≥ the query's median), not the
662
+ // global 5% rare threshold: `serialize`/`restore` are common repo-wide but are exactly the symbols
663
+ // a "graph serialize restore" query wants, while generic `graph`/`function` would flood a god-file.
664
+ const sortedIdf = terms.map(idf).sort((a, b) => a - b);
665
+ const medIdf = sortedIdf[Math.floor((sortedIdf.length - 1) / 2)];
666
+ const discTermIdx = terms.map((_t, k) => k).filter((k) => idf(terms[k]) >= medIdf);
667
+ // Structured ranking export (--json): the real score(), coverage, and per-file
668
+ // symbol-binding (NAME channel) + path-binding for every term — so a convergence
669
+ // characterization reads the ranker's actual numbers, not a parse of the page.
670
+ if (asData) {
671
+ const discSet = new Set(discTermIdx.map((k) => lterms[k]));
672
+ const rows = ranked.slice(0, 40).map(([rel]) => {
673
+ const file = index.files.get(rel);
674
+ const fpathLow = foldSep(rel.toLowerCase());
675
+ const defines = [], inPath = [];
676
+ for (let k = 0; k < terms.length; k++) {
677
+ const t = flterms[k];
678
+ if (!t)
679
+ continue;
680
+ if (file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(t)))
681
+ defines.push(terms[k]);
682
+ if (fpathLow.includes(t))
683
+ inPath.push(terms[k]);
684
+ }
685
+ // does a DISCRIMINATING term bind to a declared symbol here? (Signal 1)
686
+ const defsDisc = defines.filter((t) => discSet.has(t.toLowerCase()));
687
+ return { path: rel, score: +score(rel).toFixed(4), coverage: coverage.get(rel).size,
688
+ defines, inPath, definesDiscriminating: defsDisc };
689
+ });
690
+ return JSON.stringify({
691
+ terms, nFiles, rareTerms,
692
+ discTerms: discTermIdx.map((k) => terms[k]),
693
+ idf: Object.fromEntries(terms.map((t) => [t, +idf(t).toFixed(4)])),
694
+ ranked: rows,
695
+ });
696
+ }
697
+ const matchedSymbols = (rel) => {
698
+ const file = index.files.get(rel);
699
+ if (file.lineCount < MATCHED_SYM_MIN_LINES)
700
+ return [];
701
+ const hits = file.symbols.filter((s) => {
702
+ const nl = foldSep(s.name.toLowerCase());
703
+ return discTermIdx.some((k) => nl.includes(flterms[k]));
704
+ });
705
+ if (hits.length === 0)
706
+ return [];
707
+ // drop a symbol if it strictly contains another matched symbol (keep the inner, specific one)
708
+ const specific = hits.filter((s) => !hits.some((o) => o !== s && o.startLine >= s.startLine && o.endLine <= s.endLine
709
+ && (o.startLine > s.startLine || o.endLine < s.endLine)));
710
+ // rank by rarity-weighted term match (Σ idf of distinct discriminating terms in the name), so the
711
+ // most query-specific methods win the cap; tiebreak by line order for readability.
712
+ const symScore = (s) => {
713
+ const nl = foldSep(s.name.toLowerCase());
714
+ return discTermIdx.reduce((acc, k) => acc + (nl.includes(flterms[k]) ? idf(terms[k]) : 0), 0);
715
+ };
716
+ return specific
717
+ .sort((a, b) => symScore(b) - symScore(a) || a.startLine - b.startLine)
718
+ .slice(0, MATCHED_SYM_CAP)
719
+ .sort((a, b) => a.startLine - b.startLine)
720
+ .map((s) => `${s.name} [L${s.startLine}-${s.endLine}]`);
721
+ };
722
+ const evCache = new Map();
723
+ const evidence = (rel) => {
724
+ const cached = evCache.get(rel);
725
+ if (cached)
726
+ return cached;
727
+ const file = index.files.get(rel);
728
+ const fpathLow = foldSep(rel.toLowerCase());
729
+ const matched = coverage.get(rel);
730
+ const declares = [], inPath = [], mentions = [];
731
+ const imports = [];
732
+ for (let k = 0; k < terms.length; k++) {
733
+ const t = terms[k];
734
+ if (!matched.has(t))
735
+ continue;
736
+ if (file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(flterms[k]))) {
737
+ declares.push(t);
738
+ continue;
739
+ }
740
+ if (fpathLow.includes(flterms[k])) {
741
+ inPath.push(t);
742
+ continue;
743
+ }
744
+ if (!rareSet.has(t))
745
+ continue; // common body-only term → noise, omit
746
+ const tgt = importTargetFor(rel, t);
747
+ if (tgt)
748
+ imports.push({ term: t, from: tgt });
749
+ else
750
+ mentions.push(t);
751
+ }
752
+ const parts = [];
753
+ if (declares.length)
754
+ parts.push(`Defines ${declares.map(tick).join(', ')}.`);
755
+ if (inPath.length)
756
+ parts.push(`Named for ${inPath.map(tick).join(', ')} (in its path).`);
757
+ for (const im of imports)
758
+ parts.push(`Imports ${tick(im.term)} from ${im.from}.`);
759
+ if (mentions.length)
760
+ parts.push(`Mentions ${mentions.map(tick).join(', ')} in its body.`);
761
+ const disc = declares.length + inPath.length + imports.length + mentions.length;
762
+ const res = { text: parts.join(' '), disc, declares, inPath, imports, mentions };
763
+ evCache.set(rel, res);
764
+ return res;
765
+ };
766
+ // Compact "Role:" line — the file's identity in query terms, strongest signal first, one line.
767
+ const roleText = (rel) => {
768
+ const ev = evidence(rel);
769
+ const segs = [];
770
+ if (ev.declares.length)
771
+ segs.push(`defines ${ev.declares.map(tick).join(', ')}`);
772
+ if (ev.inPath.length)
773
+ segs.push(`named for ${ev.inPath.map(tick).join(', ')}`);
774
+ if (ev.imports.length)
775
+ segs.push(`imports ${ev.imports.map((i) => tick(i.term)).join(', ')} (from ${base(ev.imports[0].from)})`);
776
+ if (ev.mentions.length)
777
+ segs.push(`mentions ${ev.mentions.map(tick).join(', ')}`);
778
+ return segs.join('; ');
779
+ };
780
+ // Partition the listing. Vendored/minified bundles are dropped outright (never an answer); code
781
+ // files lead with previews; stylesheets and prose/docs split into bare secondary lists so they can't
782
+ // crowd or out-rank the source file. NOISE FLOOR: drop code files with no discriminating evidence.
783
+ const droppedCount = ranked.filter(([rel]) => isVendorOrMinified(rel, index.files.get(rel))).length;
784
+ const kept = ranked.filter(([rel]) => !isVendorOrMinified(rel, index.files.get(rel)));
785
+ const styleRanked = kept.filter(([rel]) => isStyle(rel));
786
+ const docRanked = kept.filter(([rel]) => isDoc(rel));
787
+ const codeRanked = kept.filter(([rel]) => !isDoc(rel) && !isStyle(rel) && evidence(rel).disc > 0);
788
+ const lines = [];
789
+ lines.push(`find: ${terms.join(' ')} (${terms.length} terms, ${coverage.size} candidate files)`);
790
+ if (rareTerms.length === 0) {
791
+ lines.push(` ⚠ no discriminating term — all of [${terms.join(', ')}] are common in this repo. Add a specific symbol, filename, or field name and re-run.`);
792
+ }
793
+ lines.push('');
794
+ // Top files get an inline convergence preview; the rest list as bare paths (cheap landscape,
795
+ // and `| head` truncation now drops whole tail files instead of cutting every file's evidence).
796
+ const detailSet = (codeRanked.length > 0 ? codeRanked : docRanked).slice(0, DETAIL_TOP);
797
+ const candidateList = codeRanked.slice(0, MAX_CANDIDATES_LISTED).map(([rel]) => rel);
798
+ const candidateSet = new Set(candidateList);
799
+ // Pass 1 — import co-citation among the listed candidates (precision relations).
800
+ const relations = computeImportRelations(index, candidateList);
801
+ const base = (rel) => rel.split('/').pop() ?? rel;
802
+ // Query-grounded "via" (A/B variant only): the connecting term = a DISCRIMINATING term the DEFINER
803
+ // declares that the CONSUMER also matched — i.e. the rare query identifier that flows across the edge.
804
+ // Filtered to discriminating terms (idf ≥ query median): a common connector like `graph`/`delete`
805
+ // sits on every edge and is noise; the rare one (`LoadStaging`) is the meaning-making token. No reads.
806
+ const discLower = new Set(discTermIdx.map((k) => lterms[k]));
807
+ const viaTerms = (definer, other) => {
808
+ const decl = new Set(evidence(definer).declares.map((t) => t.toLowerCase()));
809
+ if (decl.size === 0)
810
+ return [];
811
+ const out = [];
812
+ for (const t of coverage.get(other) ?? []) {
813
+ const tl = t.toLowerCase();
814
+ if (decl.has(tl) && discLower.has(tl))
815
+ out.push(t);
816
+ }
817
+ return out.sort((a, b) => idf(b) - idf(a)).slice(0, 2);
818
+ };
819
+ // Render the relation line for a candidate, strongest signal first, capped. When `via`, annotate each
820
+ // edge with the query term that connects the two files (the "consumed in X via blah" the agent asked for).
821
+ const relText = (rel, via) => {
822
+ const r = relations.get(rel);
823
+ if (!r)
824
+ return '';
825
+ const label = (other, definer, consumer) => {
826
+ if (!via)
827
+ return tick(base(other));
828
+ const vs = viaTerms(definer, consumer);
829
+ return `${tick(base(other))}${vs.length ? ` (via ${vs.map(tick).join(', ')})` : ''}`;
830
+ };
831
+ const parts = [];
832
+ if (r.consumedBy.length)
833
+ parts.push(`used by ${r.consumedBy.slice(0, 3).map((d) => label(d, rel, d)).join(', ')}`);
834
+ if (r.consumes.length)
835
+ parts.push(`uses ${r.consumes.slice(0, 3).map((d) => label(d, d, rel)).join(', ')}`);
836
+ if (r.siblings.length)
837
+ parts.push(`near ${r.siblings.slice(0, 3).map((d) => tick(base(d))).join(', ')}`);
838
+ return parts.join(' · ');
839
+ };
840
+ // Soft anchor: the connected set the agent should NAME, not just the file it opens. If ≥2 of the
841
+ // top files relate to each other, say so up front — the recall losses were the agent answering from
842
+ // one opened file and dropping the cluster it never read.
843
+ const connected = detailSet.filter(([rel]) => {
844
+ const r = relations.get(rel);
845
+ return r && (r.consumedBy.length || r.consumes.length || r.siblings.length);
846
+ }).length;
847
+ lines.push('== matches (top files previewed; ranked by term coverage + definition/path match) ==');
848
+ if (connected >= 2) {
849
+ lines.push("Several of these top files reference each other (see Wired: below) — they're likely one connected answer set; the connected files belong in your answer even if you don't open each one.");
850
+ }
851
+ const noteMap = buildNoteMap(root, lterms);
852
+ detailSet.forEach(([rel, ts]) => {
853
+ const file = index.files.get(rel);
854
+ lines.push('');
855
+ lines.push(`▸ ${rel} [${ts.size}/${terms.length}]`);
856
+ const role = roleText(rel);
857
+ if (role)
858
+ lines.push(` Role: ${role}`);
859
+ const syms = matchedSymbols(rel);
860
+ if (syms.length)
861
+ lines.push(` Read: ${syms.join(', ')}`);
862
+ const rt = relText(rel, via);
863
+ if (rt)
864
+ lines.push(` Wired: ${rt}`);
865
+ const noted = noteMap.get(rel);
866
+ let noteSummarized = false;
867
+ if (noted) {
868
+ const nl = noteLine(root, rel, noted, lterms);
869
+ if (nl.line)
870
+ lines.push(nl.line);
871
+ noteSummarized = nl.summary;
872
+ }
873
+ // A summary-grade note replaces the convergence preview: the walls of
874
+ // preview text exist to tell the agent what's inside the file, and a
875
+ // past agent already wrote that answer down. Weak gists (symbol
876
+ // inventory, alias, flow-step role) keep the preview — they don't
877
+ // describe the file's content.
878
+ if (noteSummarized)
879
+ return;
880
+ const specToTarget = index.edges
881
+ .filter((e) => e.from === rel)
882
+ .map((e) => ({ spec: e.specifier, target: index.files.get(e.to)?.relativePath ?? e.to }));
883
+ const preview = convergencePreview(file, terms, df, nFiles, specToTarget);
884
+ if (preview.length)
885
+ for (const p of preview)
886
+ lines.push(p);
887
+ else
888
+ lines.push(' (no discriminating line — terms appear scattered; open the file)');
889
+ });
890
+ const rest = codeRanked.slice(DETAIL_TOP, MAX_CANDIDATES_LISTED);
891
+ if (rest.length > 0) {
892
+ lines.push('');
893
+ lines.push('-- more candidates (paths + what matched) --');
894
+ for (const [rel, ts] of rest) {
895
+ const role = roleText(rel);
896
+ const rt = relText(rel, via);
897
+ const tail = [role, rt].filter(Boolean).join(' · ');
898
+ lines.push(` [${ts.size}/${terms.length}] ${rel}${tail ? ` — ${tail}` : ''}`);
899
+ }
900
+ }
901
+ if (codeRanked.length > MAX_CANDIDATES_LISTED) {
902
+ lines.push(` [+${codeRanked.length - MAX_CANDIDATES_LISTED} more lower-coverage code files]`);
903
+ }
904
+ if (droppedCount > 0) {
905
+ lines.push(` [${droppedCount} vendor/minified file${droppedCount === 1 ? '' : 's'} hidden]`);
906
+ }
907
+ // Pass 2 — shared-rare-token bridge (recall). For each previewed file, find files that
908
+ // co-mention the same rare, identifier-shaped tokens but produce NO import edge — the
909
+ // implicit-reference relations (migrations↔models, config-by-name, JS↔Python pairs) that the
910
+ // import graph cannot see. Gated by deriveRelatedFiles: cross-dir only, import-edge dedup,
911
+ // ≥2 shared tokens OR string-literal provenance, twin-merge, cap 3. Index-only, NO file reads.
912
+ const bridgeExclude = new Set(candidateSet);
913
+ for (const [rel] of detailSet)
914
+ bridgeExclude.add(rel);
915
+ // Query-relevance gate. A bridge survives only if a shared token relates to a query
916
+ // stem. Without it, any file sharing an unrelated rare token with a big top match
917
+ // (e.g. a migration that touches one of models.py's many unrelated fields) surfaces —
918
+ // measured offline as 73% of related entries, all recurring noise (9075_external_oauth,
919
+ // 0001_initial, …); the query-relevant gold (q19 javascript.htm via `term…`) survives.
920
+ const qStems = new Set();
921
+ for (const t of terms) {
922
+ const tl = t.toLowerCase();
923
+ if (tl.length >= 4)
924
+ qStems.add(tl);
925
+ for (const part of tl.split(/[_\W]+/))
926
+ if (part.length >= 4)
927
+ qStems.add(part);
928
+ }
929
+ const relatesToQuery = (toks) => qStems.size === 0 ||
930
+ toks.some((tok) => {
931
+ const tl = tok.toLowerCase();
932
+ for (const s of qStems)
933
+ if (tl.includes(s) || s.includes(tl))
934
+ return true;
935
+ return false;
936
+ });
937
+ const bridged = [];
938
+ const bridgedSeen = new Set();
939
+ for (const [rel] of detailSet) {
940
+ const src = index.files.get(rel);
941
+ if (!src?.contentTokens)
942
+ continue;
943
+ for (const rf of deriveRelatedFiles(rel, src.contentTokens, index, bridgeExclude)) {
944
+ if (bridgedSeen.has(rf.fileId))
945
+ continue;
946
+ if (!relatesToQuery(rf.tokens))
947
+ continue;
948
+ bridgedSeen.add(rf.fileId);
949
+ bridgeExclude.add(rf.fileId);
950
+ bridged.push({ from: rel, fileId: rf.fileId, tokens: rf.tokens, alsoFileIds: rf.alsoFileIds });
951
+ }
952
+ }
953
+ if (bridged.length > 0) {
954
+ lines.push('');
955
+ lines.push('-- related files (no import edge; share rare identifiers with a top match) --');
956
+ for (const b of bridged.slice(0, 6)) {
957
+ const also = b.alsoFileIds?.length ? ` (+${b.alsoFileIds.length} similar)` : '';
958
+ lines.push(` ${b.fileId}${also} — shares ${b.tokens.slice(0, 3).map(tick).join(', ')} with ${base(b.from)}`);
959
+ }
960
+ }
961
+ if (styleRanked.length > 0) {
962
+ lines.push('');
963
+ lines.push('-- stylesheets matching your terms (css/scss — not previewed) --');
964
+ for (const [rel, ts] of styleRanked.slice(0, MAX_STYLE_CANDIDATES)) {
965
+ lines.push(` [${ts.size}/${terms.length}] ${rel}`);
966
+ }
967
+ }
968
+ if (docRanked.length > 0) {
969
+ lines.push('');
970
+ lines.push('-- docs/notes mentioning your terms (prose, not definitions) --');
971
+ for (const [rel, ts] of docRanked.slice(0, MAX_DOC_CANDIDATES)) {
972
+ lines.push(` [${ts.size}/${terms.length}] ${rel}`);
973
+ }
974
+ }
975
+ lines.push('');
976
+ lines.push('Previews show where your rarest terms converge (comments/docstrings filtered, ±1 context line). If the answer is not here, add a more discriminating term and re-run.');
977
+ return lines.join('\n');
978
+ }
979
+ //# sourceMappingURL=find.js.map