argo-search 1.0.1 → 2.9.7
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.
- package/LICENSE +201 -21
- package/README.en.md +822 -0
- package/README.es.md +864 -0
- package/README.ja.md +864 -0
- package/README.ko.md +864 -0
- package/README.md +867 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2308 -96
- package/backends/engine_registry.yaml +3314 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2409 -161
- package/backends/source_types_cn.json +83 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +703 -0
- package/bin/argo.js +71 -39
- package/config.yaml +4976 -363
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +564 -0
- package/docs/MCP_SETUP.md +106 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +31 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +74 -0
- package/package.json +51 -12
- package/packages/dsh-plugin/README.md +134 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1244 -0
- package/packages/dsh-plugin/dsh/index.js +2290 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/test/client.test.js +232 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/index.test.js +1338 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +271 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1078 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +730 -0
- package/scripts/crawl.py +74 -35
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +1019 -0
- package/scripts/engine_env.py +534 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +732 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +778 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +298 -0
- package/scripts/engines_builders_batch10.py +159 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1360 -0
- package/scripts/evidence.py +588 -162
- package/scripts/evidence_loop.py +592 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1779 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +184 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +206 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_engines.py +321 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +366 -0
- package/scripts/macro_countries.py +82 -0
- package/scripts/matrix_search_eval.py +1105 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +1074 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +122 -0
- package/scripts/mcp_server.py +118 -417
- package/scripts/mcp_setup.py +407 -0
- package/scripts/mcp_tools.py +330 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +488 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +640 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +591 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +146 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +225 -0
- package/scripts/route_combo.py +910 -0
- package/scripts/route_domains.py +328 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +897 -405
- package/scripts/search_attribution.py +88 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +627 -0
- package/scripts/search_entry.py +389 -0
- package/scripts/search_output.py +606 -0
- package/scripts/search_pipeline.py +1077 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +27 -12
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +215 -0
- package/scripts/serp_guard.py +228 -0
- package/scripts/serp_kill_log.py +51 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +95 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +402 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/references/browser-runtime.md +43 -0
- package/sub-skills/ego-search/references/install.md +67 -0
- package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
- package/sub-skills/js-run/tests/test_jsrun.py +165 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +162 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/search_v3.py +1182 -65
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
- package/sub-skills/local-seek/references/git-integration.md +54 -0
- package/sub-skills/local-seek/references/remote-handoff.md +46 -0
- package/sub-skills/local-seek/references/rg-recipes.md +96 -0
- package/sub-skills/local-seek/references/strategies.md +52 -0
- package/sub-skills/local-seek/references/structural-search.md +59 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
- package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
- package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
- package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
- package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
- package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
- package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""local-seek: 本地高效搜索统一入口。零第三方依赖,仅用 Python 标准库。
|
|
3
|
+
|
|
4
|
+
路由规则(Argo 式确定性路由,模型不必自己拼参数):
|
|
5
|
+
rg 正文/正则搜索(默认,最快,尊重 .gitignore)
|
|
6
|
+
fd 按文件名查找(--filename)
|
|
7
|
+
mdfind macOS Spotlight 全盘保底(--spotlight 或 --scope all)
|
|
8
|
+
|
|
9
|
+
输出原则:默认精简文本(路径:行号:截断片段),--json 供 Agent 消费。
|
|
10
|
+
排序原则:内容命中按 BM25F 相关度重排(池子选择仍是确定性的 (路径,行号) 口径,
|
|
11
|
+
变的只是池内次序;ARGO_SEEK_CONTENT_RERANK=0 关闭,正则/纯符号查询与
|
|
12
|
+
--count/--context 模式自动跳过)。文件名场景用 fzf V2 评分 + 拼音加分。
|
|
13
|
+
全链路零 token 消耗:工具输出本身就是压缩后的结果。
|
|
14
|
+
|
|
15
|
+
用法:
|
|
16
|
+
seek.py "查询词" # 当前目录 rg 搜索(精简输出)
|
|
17
|
+
seek.py "查询词" --path ~/notes # 指定目录
|
|
18
|
+
seek.py "查询词" --scope doc # 文档类(含 pdf/docx 等文件名提示)
|
|
19
|
+
seek.py "查询词" --filename # 按文件名查找(fd)
|
|
20
|
+
seek.py "查询词" --spotlight # Spotlight 全盘保底(含 PDF/邮件/笔记)
|
|
21
|
+
seek.py "查询词" --count # 先看每文件命中数,不输出内容
|
|
22
|
+
seek.py "查询词" --context 3 # 带上下文行
|
|
23
|
+
seek.py "查询词" --type py,ts # 限定扩展名
|
|
24
|
+
seek.py "查询词" --json # JSON 输出
|
|
25
|
+
seek.py "查询词" --max 10 # 限制结果数
|
|
26
|
+
seek.py "查询词" --exact # 关闭中文扩展(精确匹配)
|
|
27
|
+
seek.py "查询词" --since 7d # 只看最近 7 天修改过的文件命中
|
|
28
|
+
seek.py "查询词" --until 2026-07-01 # 只看 2026-07-01 之前修改的命中
|
|
29
|
+
seek.py "查询词" --json --since 7d # JSON 输出带 mtime 字段
|
|
30
|
+
seek.py "查询词" --exclude 某文件 # 额外排除 glob(可重复,如排除评估脚本自身)
|
|
31
|
+
seek.py --outline 文件路径 # 输出文件结构(def/class/标题/顶层key)
|
|
32
|
+
seek.py --lines 10-50 文件路径 # 按行读取文件(替代 read_file 全文)
|
|
33
|
+
seek.py "裸except" --structural # 结构搜索(空catch/裸except/装饰函数/函数定义)
|
|
34
|
+
seek.py --git-log 文件路径 # 文件的最近提交历史
|
|
35
|
+
seek.py --git-blame 12 文件路径 # 第 12 行的提交归属
|
|
36
|
+
seek.py --domains # 列出知识域配置
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# PEP 604 联合注解(str | None)在 3.10+ 才可运行时求值;此处开启延迟注解,
|
|
40
|
+
# 使脚本在 Python 3.9(本机默认 python3)下也能正常导入/执行,而不只在 3.10+ 可用。
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import heapq
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import shutil
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
import time
|
|
52
|
+
from datetime import datetime
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
|
|
56
|
+
DOMAINS_FILE = CONFIG_DIR / "domains.yaml"
|
|
57
|
+
|
|
58
|
+
DEFAULT_EXCLUDES = [
|
|
59
|
+
"node_modules", ".git", ".svn", ".hg", "__pycache__", ".venv", "venv",
|
|
60
|
+
"dist", "build", "target", ".next", ".cache", "Pods", "vendor",
|
|
61
|
+
".DS_Store", "*.min.js", "*.map", "*.lock", ".terraform", ".pytest_cache",
|
|
62
|
+
".mypy_cache", ".ruff_cache", "coverage", "htmlcov", ".idea", ".vscode",
|
|
63
|
+
".venv", "venv", "env", ".env", "site-packages", "node_modules/.cache",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# ── 噪声档(2026-09-27 新增)───────────────────────────────────────────────
|
|
67
|
+
# 实测(Documents/GPT 搜 "import",rg 61899 处命中):
|
|
68
|
+
# vendor/生成物/归档 7% 测试/fixture/benchmark 27% tmp/ 7%
|
|
69
|
+
# 合计 41% 的命中是「搜代码时不想看到的东西」。DEFAULT_EXCLUDES 已经挡掉
|
|
70
|
+
# 一部分生成物,但漏了三类最大的:repos/(克隆的第三方仓)、__tests__|tests|
|
|
71
|
+
# fixtures|benchmark(测试与固件)、tmp/(临时检出)。
|
|
72
|
+
#
|
|
73
|
+
# 为什么是**降权**而不是排除:
|
|
74
|
+
# 排除 = 这些内容永远搜不到。搜「某个第三方仓里怎么写的」「我的测试怎么
|
|
75
|
+
# 写的」是真实且常见的用法,排除会让工具在这些查询上直接给错答案。
|
|
76
|
+
# 降权 = 真实源优先,源不够时再回落到噪声档。既改善日常体验,又不制造
|
|
77
|
+
# 「明明有却搜不到」这种更糟的失败模式。
|
|
78
|
+
NOISE_TIER = [
|
|
79
|
+
"repos", "repo", "third_party", "thirdparty", "vendors",
|
|
80
|
+
"__tests__", "__test__", "testdata", "test_data", "fixtures", "fixture",
|
|
81
|
+
"__mocks__", "__snapshots__", "e2e", "benchmark", "benchmarks",
|
|
82
|
+
"tmp", "temp", ".tmp", "tmpdir", "archive", "archives", ".trash",
|
|
83
|
+
"2026-*", # 本工作区的日期归档目录:研究产物,非源码
|
|
84
|
+
]
|
|
85
|
+
_NOISE_SET = frozenset(n for n in NOISE_TIER if not n.endswith("*"))
|
|
86
|
+
DOC_EXTS = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "md", "txt",
|
|
87
|
+
"rtf", "html", "htm", "epub", "csv", "json", "yaml", "yml", "log"}
|
|
88
|
+
CODE_EXTS = {"py", "js", "ts", "tsx", "jsx", "go", "rs", "java", "c", "h",
|
|
89
|
+
"cpp", "hpp", "cs", "rb", "php", "swift", "kt", "sh", "bash",
|
|
90
|
+
"zsh", "sql", "vue", "svelte", "lua", "r", "scala", "dart", "ex"}
|
|
91
|
+
|
|
92
|
+
REGEX_META = re.compile(r'[.*+?\[\](){}^$|\\]')
|
|
93
|
+
LOOKAROUND = re.compile(r'\(\?[=!<]|\\[1-9]')
|
|
94
|
+
CJK_RE = re.compile(r'[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+') # 汉字扩展A/汉字/日假名/韩谚文(多语言 2-gram 扩展)
|
|
95
|
+
|
|
96
|
+
_pcre2_ok = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_literal(query: str) -> bool:
|
|
100
|
+
"""查询是否纯字面量(无 regex 元字符),决定是否用 rg -F 固定字符串模式。"""
|
|
101
|
+
return not REGEX_META.search(query)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def needs_pcre2(query: str) -> bool:
|
|
105
|
+
"""查询是否需要 PCRE2(look-around / 反向引用),默认引擎不支持。"""
|
|
106
|
+
return bool(LOOKAROUND.search(query))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pcre2_supported() -> bool:
|
|
110
|
+
"""本机 rg 是否编译了 pcre2 特性(模块级缓存,只测一次)。
|
|
111
|
+
|
|
112
|
+
判据读 `rg --version` 的 features 行(如 `features:+pcre2`),
|
|
113
|
+
**不再用「跑一次匹配看返回码」**。
|
|
114
|
+
|
|
115
|
+
历史 bug(2026-09-27 实测定位):原判据是
|
|
116
|
+
|
|
117
|
+
proc = run(["rg", "--pcre2", "-e", "x", os.devnull])
|
|
118
|
+
_pcre2_ok = proc is not None and proc.returncode == 0
|
|
119
|
+
|
|
120
|
+
`/dev/null` 永远没有匹配,rg 无匹配时返回 **1**,于是 `returncode == 0`
|
|
121
|
+
恒为 False——本机 rg 15.0.0 明明带 `+pcre2`(PCRE2 10.45 带 JIT),
|
|
122
|
+
却永远被判为「未编译 PCRE2」。后果是所有 look-around / 反向引用查询
|
|
123
|
+
被拒绝:
|
|
124
|
+
|
|
125
|
+
$ seek.py 'foo(?=\\d)' --path /tmp/pcretest
|
|
126
|
+
local-seek: 本机 rg 未编译 PCRE2,不支持 look-around 语法,请简化查询
|
|
127
|
+
$ rg --pcre2 -e 'foo(?=\\d)' /tmp/pcretest # 同一个 rg,正常工作
|
|
128
|
+
/tmp/pcretest/a.txt:foo123
|
|
129
|
+
|
|
130
|
+
新判据与 rg 自己声明的能力一致,不依赖「某个文件恰好有匹配」这种
|
|
131
|
+
与探测目标无关的巧合;`rg --version` 不输出 ANSI 颜色(实测
|
|
132
|
+
CLICOLOR_FORCE=1 下 features 行仍是纯文本),无需额外 strip。
|
|
133
|
+
也不看 returncode——`rg --version` 正常时返回 0,但把它纳入判据等于
|
|
134
|
+
给「返回码语义」留后门(原缺陷正是踩在这里),只看 features 行更纯。
|
|
135
|
+
rg 不存在时 run 返回 None,同样落到 False(走 grep 回退路径)。
|
|
136
|
+
"""
|
|
137
|
+
global _pcre2_ok
|
|
138
|
+
if _pcre2_ok is None:
|
|
139
|
+
proc = run(["rg", "--version"])
|
|
140
|
+
_pcre2_ok = bool(proc is not None
|
|
141
|
+
and "+pcre2" in (proc.stdout or ""))
|
|
142
|
+
return _pcre2_ok
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_patterns(query: str, exact: bool = False):
|
|
146
|
+
"""构建搜索 pattern 列表。零依赖中文扩展:对长度>=3 的中文段补滑动二元组,
|
|
147
|
+
能多搜到一些(如「封面图」扩展出「封面」),代价是少量噪音,--exact 可关闭。
|
|
148
|
+
返回 (patterns, fixed):fixed 表示全部 pattern 可作固定字符串。"""
|
|
149
|
+
if exact:
|
|
150
|
+
return [query], is_literal(query)
|
|
151
|
+
tokens, pos = [], 0
|
|
152
|
+
for m in CJK_RE.finditer(query):
|
|
153
|
+
if m.start() > pos:
|
|
154
|
+
tokens.append(query[pos:m.start()])
|
|
155
|
+
tokens.append(m.group(0))
|
|
156
|
+
pos = m.end()
|
|
157
|
+
if pos < len(query):
|
|
158
|
+
tokens.append(query[pos:])
|
|
159
|
+
# 统一构建(中英混排同一条路径):CJK 段 ≥3 字补 2-gram 放宽;非 CJK 段
|
|
160
|
+
# 按空格拆词。此前混排查询(如「性能 asyncio tutorial」)走中文分支时英文
|
|
161
|
+
# 段整段化,退回「固定短语匹配」,单词条目全部漏掉——eb0ed38 只修了纯英文
|
|
162
|
+
# 分支,2026-10-03 两条分支合并后混排随之修复。
|
|
163
|
+
parts = []
|
|
164
|
+
for t in tokens:
|
|
165
|
+
if CJK_RE.fullmatch(t) and len(t) >= 3:
|
|
166
|
+
parts.append(t)
|
|
167
|
+
for i in range(len(t) - 1):
|
|
168
|
+
parts.append(t[i:i + 2])
|
|
169
|
+
elif t.strip():
|
|
170
|
+
for word in t.strip().split():
|
|
171
|
+
if word:
|
|
172
|
+
parts.append(word)
|
|
173
|
+
seen, out = set(), []
|
|
174
|
+
for p in parts:
|
|
175
|
+
if p not in seen:
|
|
176
|
+
seen.add(p)
|
|
177
|
+
out.append(p)
|
|
178
|
+
return out, all(is_literal(p) for p in out)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_EXCLUDES_MEMO: tuple[int, list[str]] | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_excludes() -> list:
|
|
185
|
+
"""解析 domains.yaml 的排除规则(极简段感知解析,不引入 YAML 依赖;mtime 记忆化)。"""
|
|
186
|
+
global _EXCLUDES_MEMO
|
|
187
|
+
try:
|
|
188
|
+
mtime_ns = DOMAINS_FILE.stat().st_mtime_ns
|
|
189
|
+
except OSError:
|
|
190
|
+
return list(DEFAULT_EXCLUDES)
|
|
191
|
+
if _EXCLUDES_MEMO is not None and _EXCLUDES_MEMO[0] == mtime_ns:
|
|
192
|
+
return list(_EXCLUDES_MEMO[1])
|
|
193
|
+
excludes = list(DEFAULT_EXCLUDES)
|
|
194
|
+
section = None
|
|
195
|
+
for raw in DOMAINS_FILE.read_text(encoding="utf-8").splitlines():
|
|
196
|
+
line = raw.strip()
|
|
197
|
+
if not line or line.startswith("#"):
|
|
198
|
+
continue
|
|
199
|
+
if line.startswith("exclude") and ":" in line:
|
|
200
|
+
section = "exclude"
|
|
201
|
+
continue
|
|
202
|
+
if line.startswith("knowledge_domains") and ":" in line:
|
|
203
|
+
section = "knowledge"
|
|
204
|
+
continue
|
|
205
|
+
if line.startswith(("max_results", "snippet", "doc_exts")) and ":" in line:
|
|
206
|
+
section = None
|
|
207
|
+
continue
|
|
208
|
+
m = re.match(r"^-\s+(.+?)\s*$", line)
|
|
209
|
+
if m and section == "exclude":
|
|
210
|
+
item = m.group(1).strip().strip("\"'")
|
|
211
|
+
if item and item not in excludes:
|
|
212
|
+
excludes.append(item)
|
|
213
|
+
_EXCLUDES_MEMO = (mtime_ns, excludes)
|
|
214
|
+
return list(excludes)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def run(cmd, cwd=None, timeout=30):
|
|
218
|
+
try:
|
|
219
|
+
return subprocess.run(cmd, capture_output=True, text=True,
|
|
220
|
+
encoding="utf-8", errors="replace",
|
|
221
|
+
cwd=cwd, timeout=timeout)
|
|
222
|
+
except subprocess.TimeoutExpired:
|
|
223
|
+
return None
|
|
224
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def tool_exists(name: str) -> bool:
|
|
229
|
+
# shutil.which 跨平台(Windows 下 command -v 不存在)
|
|
230
|
+
return shutil.which(name) is not None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# 搜索器未显式传超时时的默认上限(秒,与 run() 的历史默认一致)。
|
|
234
|
+
# H2 之后进程内调用方会显式传更紧的 time_budget(防挂死占死单线程
|
|
235
|
+
# executor——子进程可硬杀,线程不可杀)。
|
|
236
|
+
_RUN_DEFAULT_TIMEOUT_S = 30.0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def truncate(text: str, n: int = 120) -> str:
|
|
240
|
+
text = text.strip()
|
|
241
|
+
return text if len(text) <= n else text[: n - 1] + "…"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _norm_root(root, cwd: str) -> str:
|
|
245
|
+
"""搜索根归一化成绝对、无冗余分隔符的字符串(纯字符串运算,零 syscall)。"""
|
|
246
|
+
return os.path.normpath(root if os.path.isabs(root) else os.path.join(cwd, root))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_noise_path(fp: str, root: str, root_abs: str | None = None,
|
|
250
|
+
cwd: str | None = None) -> bool:
|
|
251
|
+
"""该命中是否落在噪声档目录(相对**搜索根**判定)。
|
|
252
|
+
|
|
253
|
+
为什么必须相对搜索根:直接对整条路径做分段匹配是错的——搜索根自己叫
|
|
254
|
+
`tests/` 或含 `2026-` 时(本工作区正是如此),整棵树都会被判成噪声。
|
|
255
|
+
rg 的 -g glob 犯的正是这个错(`**/tests/**` 会匹配路径里任意一段),
|
|
256
|
+
所以降权放到 Python 层做,不交给 glob。
|
|
257
|
+
|
|
258
|
+
性能(2026-10-06):原实现每行都做
|
|
259
|
+
`Path(fp).resolve().relative_to(Path(root).resolve())`。`resolve()` 对每个
|
|
260
|
+
路径段 lstat、还反复 getcwd;实测 `--count` 扫 8930 个文件时它独占
|
|
261
|
+
1.52s / 1.91s(80%),内含 3.5 万次 getcwd、10 万次 lstat。改为纯字符串
|
|
262
|
+
归一化:根只归一化一次,命中路径用 normpath 做前缀比较,全程零 syscall。
|
|
263
|
+
不在根之下(软链/越界)时退回整条路径判定,与原 `Path(fp).parts` 一致。
|
|
264
|
+
"""
|
|
265
|
+
if cwd is None:
|
|
266
|
+
cwd = os.getcwd()
|
|
267
|
+
if root_abs is None:
|
|
268
|
+
root_abs = _norm_root(root, cwd)
|
|
269
|
+
afp = os.path.normpath(fp if os.path.isabs(fp) else os.path.join(cwd, fp))
|
|
270
|
+
if afp == root_abs:
|
|
271
|
+
return False
|
|
272
|
+
prefix = root_abs + os.sep
|
|
273
|
+
if afp.startswith(prefix):
|
|
274
|
+
parts = afp[len(prefix):].split(os.sep)
|
|
275
|
+
elif os.path.isabs(fp):
|
|
276
|
+
parts = afp.split(os.sep) # 绝对但不在根下:退回整条绝对路径
|
|
277
|
+
else:
|
|
278
|
+
parts = fp.split(os.sep) # 相对但不在根下:退回原始相对路径
|
|
279
|
+
if not parts:
|
|
280
|
+
return False
|
|
281
|
+
for p in parts[:-1]: # 最后一段是文件名,不参与目录判定
|
|
282
|
+
if p in _NOISE_SET:
|
|
283
|
+
return True
|
|
284
|
+
# 日期归档目录(2026-09-26_xxx)
|
|
285
|
+
if p[:4].isdigit() and p[4:5] in "-_" and len(p) >= 5:
|
|
286
|
+
return True
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _apply_noise_floor(rows, root, max_results):
|
|
291
|
+
"""噪声档结果排到真源之后;真源够数时直接不返回噪声档。
|
|
292
|
+
|
|
293
|
+
不是排除:真源不足 max_results 时用噪声档补满,保证「有结果总比没结果好」,
|
|
294
|
+
也保证搜第三方仓里的内容仍然可达。
|
|
295
|
+
|
|
296
|
+
根与 cwd 只解析一次、每行只判一次(原实现对每行判两遍,还把根重复
|
|
297
|
+
resolve 两遍——见 _is_noise_path 的性能注记)。
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
cwd = os.getcwd()
|
|
301
|
+
except OSError:
|
|
302
|
+
cwd = "."
|
|
303
|
+
root_abs = _norm_root(root, cwd)
|
|
304
|
+
clean, noisy = [], []
|
|
305
|
+
for r in rows:
|
|
306
|
+
(noisy if _is_noise_path(r[0], root, root_abs, cwd) else clean).append(r)
|
|
307
|
+
return (clean + noisy)[:max_results]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_ENV_FALSY = ("0", "off", "no", "false", "disable", "disabled")
|
|
311
|
+
|
|
312
|
+
# 重排开启时的池子上限:评分几百行在毫秒级(seek_rank 单遍计数),而浅池
|
|
313
|
+
# 会让「每文件只轮到 1 行」、文件内最相关的深行进不了评分视野。
|
|
314
|
+
_POOL_CAP_MAX = 400
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _rerank_wanted(raw_query: str, context: int, count: bool,
|
|
318
|
+
fixed: bool) -> bool:
|
|
319
|
+
"""内容重排是否应该启用(只做**廉价判定**,不导入打分模块)。
|
|
320
|
+
|
|
321
|
+
供 rg_search 在选池**之前**决定池子上限:重排开启时池子放宽到
|
|
322
|
+
_POOL_CAP_MAX(评分几百行只多几毫秒,而浅池会把每个文件只轮到 1 行、
|
|
323
|
+
文件内最相关的深行永远进不了评分视野);关闭时维持与旧版逐位一致的
|
|
324
|
+
小池(此时输出次序 = (路径,行号) 基准序,行为零变化)。
|
|
325
|
+
"""
|
|
326
|
+
if (os.environ.get("ARGO_SEEK_CONTENT_RERANK", "").strip().lower()
|
|
327
|
+
in _ENV_FALSY):
|
|
328
|
+
return False
|
|
329
|
+
return bool(fixed) and not count and context <= 0 and bool(raw_query)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _rerank_content(rows, raw_query: str, context: int, count: bool,
|
|
333
|
+
fixed: bool = True):
|
|
334
|
+
"""BM25F 相关度重排的**闸门**:条件全过才真正打分,否则原样返回。
|
|
335
|
+
|
|
336
|
+
1. ARGO_SEEK_CONTENT_RERANK 关闭 → 整体逃生门(恢复 (路径,行号) 原序)
|
|
337
|
+
2. --count / --context 模式跳过:计数模式的输出语义是「每文件命中数」,
|
|
338
|
+
上下文模式的行序承载分组结构,都不适用相关度次序
|
|
339
|
+
3. 非字面量查询(正则/PCRE2,fixed=False)跳过:词元切分会把 `\\d{4}`
|
|
340
|
+
切成 d/4/2 这类碎片,重排只引入噪声(字面量才进重排)
|
|
341
|
+
4. seek_rank 任何异常 → 原样返回(重排是增益项,绝不阻塞主流程)
|
|
342
|
+
|
|
343
|
+
打分只依赖 (路径, 行号, 行文本, 查询),与 rg 发射序无关,并列分按
|
|
344
|
+
(路径,行号) 升序——test_determinism_20261006 的确定性契约由此延续。
|
|
345
|
+
"""
|
|
346
|
+
if not _rerank_wanted(raw_query, context, count, fixed):
|
|
347
|
+
return rows
|
|
348
|
+
try:
|
|
349
|
+
import seek_rank
|
|
350
|
+
import seek_frecency
|
|
351
|
+
boost_fn = seek_frecency.boost if seek_frecency.enabled() else None
|
|
352
|
+
return seek_rank.rank_rows(rows, raw_query, boost_fn=boost_fn)
|
|
353
|
+
except Exception:
|
|
354
|
+
return rows
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _select_pool(candidates, cap: int, per_file: int = 8) -> list:
|
|
358
|
+
"""有界池的**按文件轮转配额**选择(2026-10-07,方案甲)。
|
|
359
|
+
|
|
360
|
+
旧口径 = (路径,行号,内容) 最小的 cap 条:单文件命中几百行的高频词查询
|
|
361
|
+
会把池位全吃掉,而路径序在后的文件(sub-skills/ 排在 docs/ 之后)一行
|
|
362
|
+
都进不了池——BM25F 重排只能排池内的东西,池子本身偏了排序再好也白搭。
|
|
363
|
+
|
|
364
|
+
现口径:文件按路径序(确定性),每文件最多 per_file 行(取该文件
|
|
365
|
+
(行号,内容) 最小的几行),轮转填满 cap。命中集无论以什么顺序发射,
|
|
366
|
+
选出的池逐条一致(test_determinism 契约延续);多样性换来的代价是
|
|
367
|
+
「单文件超 per_file 行的深处行」可能落选——对行粒度输出这是可接受的
|
|
368
|
+
取舍,深层行本就该用 --context / --outline 精读。
|
|
369
|
+
"""
|
|
370
|
+
by_file: dict[str, list] = {}
|
|
371
|
+
for fp, ln, txt in candidates:
|
|
372
|
+
rows = by_file.setdefault(fp, [])
|
|
373
|
+
if len(rows) < per_file:
|
|
374
|
+
rows.append((ln, txt))
|
|
375
|
+
elif per_file > 1:
|
|
376
|
+
# 有界保持每文件最小的 per_file 行:与 nsmallest 同语义
|
|
377
|
+
largest = max(rows)
|
|
378
|
+
if (ln, txt) < largest:
|
|
379
|
+
rows[rows.index(largest)] = (ln, txt)
|
|
380
|
+
out: list = []
|
|
381
|
+
# 文件按路径序轮转,各出 1 行填池——池内文件多样性优先于单文件深度
|
|
382
|
+
consumed: dict[str, int] = {fp: 0 for fp in by_file}
|
|
383
|
+
while len(out) < cap:
|
|
384
|
+
progressed = False
|
|
385
|
+
for fp in sorted(by_file):
|
|
386
|
+
i = consumed[fp]
|
|
387
|
+
if i < len(by_file[fp]):
|
|
388
|
+
ln, txt = by_file[fp][i]
|
|
389
|
+
out.append((fp, ln, txt))
|
|
390
|
+
consumed[fp] = i + 1
|
|
391
|
+
progressed = True
|
|
392
|
+
if len(out) >= cap:
|
|
393
|
+
break
|
|
394
|
+
if not progressed:
|
|
395
|
+
break
|
|
396
|
+
return out
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _finish_content(candidates, raw_query, context, count, fixed,
|
|
400
|
+
path, max_results, drop_noise):
|
|
401
|
+
"""内容搜索的共用收尾:有界池 → 确定性排序 → BM25F 重排 → 噪声地板。
|
|
402
|
+
|
|
403
|
+
rg 路径与 FTS 快路(seek_fts)都必须走这里——两条来源、一套池化与
|
|
404
|
+
排序口径,输出 schema 才能逐位一致。
|
|
405
|
+
"""
|
|
406
|
+
cap = max(max_results, min(max_results * 4, 400))
|
|
407
|
+
if _rerank_wanted(raw_query, context, count, fixed):
|
|
408
|
+
cap = max(cap, _POOL_CAP_MAX) # 重排开启:池子放宽,深行进得了评分视野
|
|
409
|
+
out = _select_pool(candidates, cap)
|
|
410
|
+
out.sort() # 基准序 = (路径,行号,内容):重排关闭时与 2026-10-06 口径逐位一致
|
|
411
|
+
# BM25F 相关度重排(2026-10-07,方案甲):池子的**选择**口径不变(与发射序
|
|
412
|
+
# 无关),变的只是池内**呈现次序**——基线实测多词查询的 top5 常被配置/
|
|
413
|
+
# 文档里的顺带提及占满,实现文件进不了前五。闸门与降级见 _rerank_content。
|
|
414
|
+
# 注意:这里刻意不写具体查询例——本文件就在被搜语料里,注释里出现
|
|
415
|
+
# 常见查询原词会给 eval 制造自污染(首轮实测踩过)。
|
|
416
|
+
out = _rerank_content(out, raw_query, context, count, fixed)
|
|
417
|
+
if drop_noise:
|
|
418
|
+
return _apply_noise_floor(out, path, max_results)
|
|
419
|
+
return out[:max_results]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def rg_search(patterns, path, excludes, exts, context, count, max_results,
|
|
423
|
+
fixed, raw_query="", dots=False, drop_noise=True, timeout=None,
|
|
424
|
+
since_ts=None, until_ts=None, fts_ok=True):
|
|
425
|
+
timeout = _RUN_DEFAULT_TIMEOUT_S if timeout is None else timeout
|
|
426
|
+
|
|
427
|
+
# FTS5 持久索引快路(opt-in,ARGO_LOCAL_FTS=1,2026-10-10 方案乙):只在
|
|
428
|
+
# 「单模式 + 非 count/context」时尝试,正则元字符由 query_suitable 拦下
|
|
429
|
+
# (字面量标志 fixed 不参与判断——普通无元字符查询就是 fixed=True 进来的)。
|
|
430
|
+
# 索引不可用/未建成时返回 None,自然落到下面的 rg 主路;契约见
|
|
431
|
+
# seek_fts 模块 docstring(零命中回退、TTL 新鲜度、覆盖面对齐)。
|
|
432
|
+
#
|
|
433
|
+
# fts_ok(2026-10-12 修复):调用方带 --type/--exclude 时必须为 False。
|
|
434
|
+
# 快路只按内容命中,不会应用 rg 侧的 `-g !glob` / `-g *.ext` 过滤——
|
|
435
|
+
# 此前带过滤条件仍走快路,静默返回范围外的结果(--type py 会混入非 .py,
|
|
436
|
+
# --exclude 被忽略还会绕掉 eval 自污染的排除守卫)。
|
|
437
|
+
if fts_ok and not count and context == 0 and len(patterns) == 1:
|
|
438
|
+
try:
|
|
439
|
+
import seek_fts
|
|
440
|
+
except ImportError:
|
|
441
|
+
seek_fts = None
|
|
442
|
+
if seek_fts is not None and seek_fts.query_suitable(patterns[0]):
|
|
443
|
+
rows = seek_fts.content_candidates(
|
|
444
|
+
patterns[0], path, dots=dots,
|
|
445
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
446
|
+
if rows is not None:
|
|
447
|
+
return _finish_content(iter(rows), raw_query, context, count,
|
|
448
|
+
fixed, path, max_results,
|
|
449
|
+
drop_noise), None
|
|
450
|
+
|
|
451
|
+
def build(fixed, drop_noise=True):
|
|
452
|
+
cmd = ["rg", "--line-number", "--no-heading", "-i", "--color", "never"]
|
|
453
|
+
if dots:
|
|
454
|
+
# rg 默认既不进以 . 开头的目录,也不跟软链。本机 skill 就放在
|
|
455
|
+
# ~/.agents、~/.zcode 这类目录里,很多条目还是软链,所以两个都要打开。
|
|
456
|
+
# 排除规则里的 !.git 仍然生效,不会把 .git 扫进来。
|
|
457
|
+
cmd += ["--hidden", "--follow"]
|
|
458
|
+
if fixed:
|
|
459
|
+
cmd.append("-F")
|
|
460
|
+
elif needs_pcre2(raw_query):
|
|
461
|
+
if pcre2_supported():
|
|
462
|
+
cmd.append("--pcre2")
|
|
463
|
+
else:
|
|
464
|
+
return None, ("本机 rg 未编译 PCRE2,不支持 look-around/反向引用语法,"
|
|
465
|
+
"请简化查询(如去掉 (?=、(?! 等结构)")
|
|
466
|
+
if count:
|
|
467
|
+
cmd.append("--count-matches")
|
|
468
|
+
elif context > 0:
|
|
469
|
+
cmd += ["-C", str(context)]
|
|
470
|
+
for ex in excludes:
|
|
471
|
+
cmd += ["-g", f"!{ex}"]
|
|
472
|
+
if exts:
|
|
473
|
+
for e in exts:
|
|
474
|
+
cmd += ["-g", f"*.{e}"]
|
|
475
|
+
for p in patterns:
|
|
476
|
+
cmd += ["-e", p]
|
|
477
|
+
cmd.append(str(path))
|
|
478
|
+
return cmd, None
|
|
479
|
+
|
|
480
|
+
cmd, err = build(fixed, drop_noise=drop_noise)
|
|
481
|
+
if err:
|
|
482
|
+
return [], err
|
|
483
|
+
proc = run(cmd, timeout=timeout)
|
|
484
|
+
if (proc is not None and proc.returncode == 2 and not fixed
|
|
485
|
+
and "regex parse error" in (proc.stderr or "")):
|
|
486
|
+
# regex 解析失败(如按字面意图输入 interface{}、foo.bar),回退固定字符串
|
|
487
|
+
cmd2, _ = build(True, drop_noise=drop_noise)
|
|
488
|
+
proc = run(cmd2, timeout=timeout)
|
|
489
|
+
if proc is None or proc.returncode not in (0, 1, 2):
|
|
490
|
+
return [], "rg 执行失败"
|
|
491
|
+
if proc.returncode == 1:
|
|
492
|
+
return [], None # 无匹配,正常
|
|
493
|
+
if proc.returncode == 2:
|
|
494
|
+
# regex parse error 已回退,其他错误需要报告(权限不足、路径不存在等)
|
|
495
|
+
err = (proc.stderr or "").strip()
|
|
496
|
+
return [], f"rg 错误: {err[:200]}" if err else "rg 执行错误"
|
|
497
|
+
if count:
|
|
498
|
+
# 全量收集后按命中数降序取前 N:rg --count-matches 的输出顺序是
|
|
499
|
+
# 目录遍历序,直接截断会让「哪些文件命中最多」系统性答错——
|
|
500
|
+
# 命中最多的文件可能恰好排在遍历序后面。行数=文件数,全量收集有界。
|
|
501
|
+
counts = []
|
|
502
|
+
for line in proc.stdout.splitlines():
|
|
503
|
+
if ":" in line:
|
|
504
|
+
fp, _, n = line.rpartition(":")
|
|
505
|
+
if _in_time_window(fp, since_ts, until_ts):
|
|
506
|
+
counts.append((fp, int(n) if n.isdigit() else 0, ""))
|
|
507
|
+
# 并列计数按路径升序决出稳定次序:sort 稳定 + rg 发射序不定的组合,
|
|
508
|
+
# 会让并列文件在两次运行里顺序不同
|
|
509
|
+
counts.sort(key=lambda t: (-t[1], t[0]))
|
|
510
|
+
return _apply_noise_floor(counts, path, max_results) if drop_noise \
|
|
511
|
+
else counts[:max_results], None
|
|
512
|
+
out = []
|
|
513
|
+
# 上界池 + **确定性选择**:rg 并行遍历的发射序不稳定,「先到先得」截断
|
|
514
|
+
# 拿到的是任意的 cap 条——同一查询两次跑、命中集与排序都不同(v1.4.1
|
|
515
|
+
# 审计遗留,2026-10-06 定性修复)。池子选择与发射序无关、内存有界、
|
|
516
|
+
# 单遍流式(2026-10-07 升级为按文件轮转配额,见 _select_pool:修复
|
|
517
|
+
# 「单文件高频命中挤占池位、路径序靠后文件整体出局」的多样性缺陷)。
|
|
518
|
+
# 收尾(池化→排序→重排→噪声地板)在 _finish_content,与 FTS 快路共用。
|
|
519
|
+
candidates = (
|
|
520
|
+
(m.group(1), int(m.group(2)), truncate(m.group(3)))
|
|
521
|
+
for line in proc.stdout.splitlines()
|
|
522
|
+
if (m := re.match(r"^(.*?):(\d+):(.*)$", line))
|
|
523
|
+
# 时间窗在入池前过滤:窗外行不占上界名额(先截断后过滤会漏报)
|
|
524
|
+
and _in_time_window(m.group(1), since_ts, until_ts)
|
|
525
|
+
)
|
|
526
|
+
return _finish_content(candidates, raw_query, context, count, fixed,
|
|
527
|
+
path, max_results, drop_noise), None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ── fzf FuzzyMatchV2 评分 + 拼音首字母(pypinyin 优先,GB2312 表保底)──────────
|
|
531
|
+
# 评分常数与对齐算法按 junegunn/fzf src/algo/algo.go 的 FuzzyMatchV2 口径实现
|
|
532
|
+
# (2026-10-07 方案甲:原手搓版只有「边界+4/连续+3/全等+5」三档,无 gap 惩罚、
|
|
533
|
+
# 无 camelCase 加成,歧义文件名的 top1 判定比 fzf 真版粗一档)。DP 对齐保证
|
|
534
|
+
# 最优匹配位置(xaabc 搜 ab 时「跳过词首 a 取边界 b」的得分高于顺位连打,
|
|
535
|
+
# 顺位贪心永远发现不了这类反超);文件名长度量级下 O(m·n) 开销微秒级。
|
|
536
|
+
|
|
537
|
+
_SCORE_MATCH = 16
|
|
538
|
+
_GAP_START = -3
|
|
539
|
+
_GAP_EXT = -1
|
|
540
|
+
_BONUS_BOUNDARY = _SCORE_MATCH // 2 # 8:串首/路径段/词边界
|
|
541
|
+
_BONUS_NON_WORD = _SCORE_MATCH // 2 # 8:其他非词字符之后
|
|
542
|
+
_BONUS_CAMEL = _BONUS_BOUNDARY + _GAP_EXT # 7:驼峰大写/字母→数字
|
|
543
|
+
_BONUS_CONSECUTIVE = -(_GAP_START + _GAP_EXT) # 4:连续命中
|
|
544
|
+
_FIRST_CHAR_MULT = 2 # 首个模式字符的边界加成翻倍
|
|
545
|
+
_EXACT_CASE_BONUS = _SCORE_MATCH # 全等(区分大小写)
|
|
546
|
+
_EXACT_BONUS = _SCORE_MATCH // 2 # 全等(仅忽略大小写)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _match_bonus(prev: str | None, ch: str) -> int:
|
|
550
|
+
"""n[j] 作为匹配点时的边界加成(fzf 的 bonusFor 口径)。"""
|
|
551
|
+
if prev is None:
|
|
552
|
+
return _BONUS_BOUNDARY
|
|
553
|
+
if prev in "/-_. ":
|
|
554
|
+
return _BONUS_BOUNDARY
|
|
555
|
+
if prev.islower() and ch.isupper():
|
|
556
|
+
return _BONUS_CAMEL
|
|
557
|
+
if prev.isalpha() and ch.isdigit():
|
|
558
|
+
return _BONUS_CAMEL
|
|
559
|
+
if not prev.isalnum():
|
|
560
|
+
return _BONUS_NON_WORD
|
|
561
|
+
return 0
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _fzf_score(name: str, pattern: str) -> int:
|
|
565
|
+
"""fzf FuzzyMatchV2 文件名匹配评分(smart case + DP 最优对齐)。0 表示不匹配。
|
|
566
|
+
|
|
567
|
+
M[i][j] = 模式 p[:i+1] 匹配且 p[i] 恰落在 n[j] 的最优分;
|
|
568
|
+
连续命中走 M[i-1][j-1] + 连续加成;有间隙的转移用仿射 gap 惩罚
|
|
569
|
+
(GAP_START + GAP_EXT×(g-1)),经前缀最大值 H[i-1][j'] + (-GAP_EXT)×j'
|
|
570
|
+
折算成 O(m·n) 单遍——文件名长度量级下等价于 fzf 的滑窗实现。
|
|
571
|
+
"""
|
|
572
|
+
if not pattern or not name:
|
|
573
|
+
return 0
|
|
574
|
+
p, n = pattern, name
|
|
575
|
+
if not any(c.isupper() for c in p): # smart case:无大写则忽略大小写
|
|
576
|
+
p, n = p.lower(), n.lower()
|
|
577
|
+
m, ln = len(p), len(n)
|
|
578
|
+
if m > ln:
|
|
579
|
+
return 0
|
|
580
|
+
NEG = float("-inf")
|
|
581
|
+
prev_m: list[float] = [NEG] * ln # 上一模式字符的 M 值(按 n 下标)
|
|
582
|
+
prev_h: list[float] = [NEG] * ln # 上一行 M 的前缀最大值
|
|
583
|
+
prev_pm: list[float] = [NEG] * ln # max_{j'≤k}(prev_h[j'] + j'),仿射 gap 用
|
|
584
|
+
for i in range(m):
|
|
585
|
+
pc = p[i]
|
|
586
|
+
cur_m: list[float] = [NEG] * ln
|
|
587
|
+
cur_h: list[float] = [NEG] * ln
|
|
588
|
+
for j in range(ln):
|
|
589
|
+
if n[j] != pc:
|
|
590
|
+
continue
|
|
591
|
+
if i == 0:
|
|
592
|
+
cur_m[j] = _SCORE_MATCH + \
|
|
593
|
+
_match_bonus(n[j - 1] if j else None, n[j]) * _FIRST_CHAR_MULT
|
|
594
|
+
else:
|
|
595
|
+
best = NEG
|
|
596
|
+
if j >= 1 and prev_m[j - 1] != NEG:
|
|
597
|
+
best = prev_m[j - 1] + _SCORE_MATCH + _BONUS_CONSECUTIVE
|
|
598
|
+
if j >= 2 and prev_pm[j - 2] != NEG:
|
|
599
|
+
best = max(best, prev_pm[j - 2] + _GAP_START + 2 - j)
|
|
600
|
+
if best != NEG:
|
|
601
|
+
cur_m[j] = best + _SCORE_MATCH + \
|
|
602
|
+
_match_bonus(n[j - 1] if j else None, n[j])
|
|
603
|
+
if cur_m[j] != NEG:
|
|
604
|
+
cur_h[j] = max(cur_h[j - 1] if j else NEG, cur_m[j])
|
|
605
|
+
run = NEG
|
|
606
|
+
for j in range(ln):
|
|
607
|
+
if cur_h[j] != NEG:
|
|
608
|
+
run = max(run, cur_h[j] + j)
|
|
609
|
+
prev_pm[j] = run
|
|
610
|
+
prev_m, prev_h = cur_m, cur_h
|
|
611
|
+
best = max(prev_h) if ln else NEG
|
|
612
|
+
if best == NEG:
|
|
613
|
+
return 0
|
|
614
|
+
score = int(best)
|
|
615
|
+
if p == n:
|
|
616
|
+
score += _EXACT_CASE_BONUS
|
|
617
|
+
elif p == n.lower():
|
|
618
|
+
score += _EXACT_BONUS
|
|
619
|
+
return score
|
|
620
|
+
_GB2312_SECTIONS = (
|
|
621
|
+
(0xB0A1, 0xB0C4, "a"), (0xB0C5, 0xB2C0, "b"), (0xB2C1, 0xB4ED, "c"),
|
|
622
|
+
(0xB4EE, 0xB6E9, "d"), (0xB6EA, 0xB7A1, "e"), (0xB7A2, 0xB8C0, "f"),
|
|
623
|
+
(0xB8C1, 0xB9FD, "g"), (0xB9FE, 0xBBF6, "h"), (0xBBF7, 0xBFA5, "j"),
|
|
624
|
+
(0xBFA6, 0xC0AB, "k"), (0xC0AC, 0xC2E7, "l"), (0xC2E8, 0xC4C2, "m"),
|
|
625
|
+
(0xC4C3, 0xC5B5, "n"), (0xC5B6, 0xC5BD, "o"), (0xC5BE, 0xC6D9, "p"),
|
|
626
|
+
(0xC6DA, 0xC8BA, "q"), (0xC8BB, 0xC8F5, "r"), (0xC8F6, 0xCBF9, "s"),
|
|
627
|
+
(0xCBFA, 0xCDD9, "t"), (0xCDDA, 0xCEF3, "w"), (0xCEF4, 0xD1B8, "x"),
|
|
628
|
+
(0xD1B9, 0xD4D0, "y"), (0xD4D1, 0xD7F9, "z"),
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _gb2312_initial(b1: int, b2: int) -> str:
|
|
633
|
+
code = (b1 << 8) | b2
|
|
634
|
+
for lo, hi, letter in _GB2312_SECTIONS:
|
|
635
|
+
if lo <= code <= hi:
|
|
636
|
+
return letter
|
|
637
|
+
return "?"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
_PYPINYIN_FN = None
|
|
641
|
+
_PYPINYIN_PROBED = False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def pinyin_initials(text: str) -> str:
|
|
645
|
+
"""文本的拼音首字母(中文→首字母,非中文保留原字符),用于「xjj→新建夹」。
|
|
646
|
+
pypinyin 可用则用;否则 GB2312 区间表保底;两者都不可用时返回原文本。"""
|
|
647
|
+
global _PYPINYIN_FN, _PYPINYIN_PROBED
|
|
648
|
+
if not text:
|
|
649
|
+
return text
|
|
650
|
+
if not _PYPINYIN_PROBED:
|
|
651
|
+
_PYPINYIN_PROBED = True
|
|
652
|
+
try:
|
|
653
|
+
from pypinyin import lazy_pinyin, Style # type: ignore
|
|
654
|
+
_PYPINYIN_FN = lambda s: "".join( # noqa: E731
|
|
655
|
+
lazy_pinyin(s, style=Style.FIRST_LETTER, errors="default")
|
|
656
|
+
)
|
|
657
|
+
except Exception:
|
|
658
|
+
_PYPINYIN_FN = None
|
|
659
|
+
if _PYPINYIN_FN is not None:
|
|
660
|
+
try:
|
|
661
|
+
return _PYPINYIN_FN(text)
|
|
662
|
+
except Exception:
|
|
663
|
+
pass
|
|
664
|
+
out = []
|
|
665
|
+
for ch in text:
|
|
666
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
667
|
+
try:
|
|
668
|
+
b = ch.encode("gb2312")
|
|
669
|
+
out.append(_gb2312_initial(b[0], b[1]))
|
|
670
|
+
except (UnicodeEncodeError, IndexError):
|
|
671
|
+
out.append(ch)
|
|
672
|
+
else:
|
|
673
|
+
out.append(ch)
|
|
674
|
+
return "".join(out)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _looks_like_pinyin_abbrev(q: str) -> bool:
|
|
678
|
+
"""疑似中文拼音缩写(如 xjj):纯 ASCII 字母、2-4 位。"""
|
|
679
|
+
q = (q or "").strip()
|
|
680
|
+
return bool(q and q.isascii() and q.isalpha() and 2 <= len(q) <= 4)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _file_pinyin_bonus(path: str, query: str,
|
|
684
|
+
query_initials: str | None = None) -> int:
|
|
685
|
+
"""文件名拼音首字母与查询呼应 → 加分(「新建夹」↔ xjj)。
|
|
686
|
+
正向(中文查询 → 拼音缩写)50;反向(拼音缩写查询 → 中文文件名)8,
|
|
687
|
+
反向低于字面命中,避免把真名 xjj.docx 挤下去。"""
|
|
688
|
+
q = query.strip().lower()
|
|
689
|
+
if not q:
|
|
690
|
+
return 0
|
|
691
|
+
base = Path(path).name
|
|
692
|
+
has_cjk_query = any("\u4e00" <= c <= "\u9fff" for c in query)
|
|
693
|
+
is_abbrev = (not has_cjk_query and q.isascii() and q.isalpha()
|
|
694
|
+
and 2 <= len(q) <= 4
|
|
695
|
+
and any("\u4e00" <= c <= "\u9fff" for c in base))
|
|
696
|
+
if not has_cjk_query and not is_abbrev:
|
|
697
|
+
return 0
|
|
698
|
+
initials = pinyin_initials(base).lower()
|
|
699
|
+
if not initials:
|
|
700
|
+
return 0
|
|
701
|
+
if has_cjk_query:
|
|
702
|
+
qs = (query_initials if query_initials is not None
|
|
703
|
+
else pinyin_initials(query).strip().lower())
|
|
704
|
+
return 50 if qs and qs in initials else 0
|
|
705
|
+
return 8 if q in initials else 0
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _rank_path_results(results, query: str):
|
|
709
|
+
"""按文件名相关性排序:fzf V2 评分 + 拼音加分 + frecency(opt-in)+ mtime 新优先。
|
|
710
|
+
|
|
711
|
+
仅用于「按文件定位」场景(--filename / --spotlight);内容命中的相关度
|
|
712
|
+
次序由 _rerank_content(BM25F)负责,不走这里。
|
|
713
|
+
"""
|
|
714
|
+
frec_on, boost_fn = False, None
|
|
715
|
+
try:
|
|
716
|
+
import seek_frecency
|
|
717
|
+
frec_on, boost_fn = seek_frecency.enabled(), seek_frecency.boost
|
|
718
|
+
except Exception:
|
|
719
|
+
pass
|
|
720
|
+
now = time.time()
|
|
721
|
+
q_low = query.lower()
|
|
722
|
+
q_init = (pinyin_initials(query).strip().lower()
|
|
723
|
+
if any("\u4e00" <= c <= "\u9fff" for c in query) else "")
|
|
724
|
+
|
|
725
|
+
def key(it):
|
|
726
|
+
fp = it[0] if isinstance(it, (list, tuple)) else str(it)
|
|
727
|
+
sc = _fzf_score(Path(fp).name, query)
|
|
728
|
+
sc += _file_pinyin_bonus(fp, query, query_initials=q_init)
|
|
729
|
+
# 全路径段匹配也加分。V2 的分数量级是旧手搓版的 ~4 倍(基分 16 vs 3-4),
|
|
730
|
+
# 除数同步 4→16,保持「路径段是文件名的从属信号」这一原比例——
|
|
731
|
+
# 不调的话全路径匹配会反超文件名匹配 + 拼音加成,排序语义就变了。
|
|
732
|
+
sc += max(0, _fzf_score(fp, q_low)) // 16
|
|
733
|
+
if frec_on and boost_fn is not None:
|
|
734
|
+
try:
|
|
735
|
+
sc += boost_fn(fp, now)
|
|
736
|
+
except Exception:
|
|
737
|
+
pass
|
|
738
|
+
try:
|
|
739
|
+
mt = os.path.getmtime(fp)
|
|
740
|
+
except OSError:
|
|
741
|
+
mt = 0.0
|
|
742
|
+
return (-sc, -mt, fp)
|
|
743
|
+
return sorted(results, key=key)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _merge_dedup(a: list, b: list, limit: int) -> list:
|
|
747
|
+
"""按路径去重合并两组结果(fd 中文 + 拼音双查)。"""
|
|
748
|
+
seen, out = set(), []
|
|
749
|
+
for it in list(a) + list(b):
|
|
750
|
+
fp = it[0] if isinstance(it, (list, tuple)) else str(it)
|
|
751
|
+
if fp not in seen:
|
|
752
|
+
seen.add(fp)
|
|
753
|
+
out.append(it)
|
|
754
|
+
if len(out) >= limit:
|
|
755
|
+
break
|
|
756
|
+
return out
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def fd_search(query, path, exts, max_results, dots=False, timeout=None,
|
|
760
|
+
since_ts=None, until_ts=None):
|
|
761
|
+
timeout = _RUN_DEFAULT_TIMEOUT_S if timeout is None else timeout
|
|
762
|
+
cmd = ["fd", "-i", "-t", "f", "--color", "never"]
|
|
763
|
+
if dots:
|
|
764
|
+
cmd += ["-H", "-L"] # -H 进以 . 开头的目录、-L 跟软链,和 rg 的两个开关对应
|
|
765
|
+
for ex in DEFAULT_EXCLUDES:
|
|
766
|
+
cmd += ["-E", ex]
|
|
767
|
+
if exts:
|
|
768
|
+
for e in exts:
|
|
769
|
+
cmd += ["-e", e]
|
|
770
|
+
cmd += [query, str(path)]
|
|
771
|
+
proc = run(cmd, timeout=timeout)
|
|
772
|
+
if proc is None or proc.returncode not in (0, 1):
|
|
773
|
+
return [], "fd 执行失败"
|
|
774
|
+
# 时间窗在截断前过滤(先截后滤会漏报,与 rg 路径同理)
|
|
775
|
+
lines = [p for p in proc.stdout.splitlines()
|
|
776
|
+
if _in_time_window(p, since_ts, until_ts)]
|
|
777
|
+
return [(p, 0, "") for p in lines[:max_results]], None
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def resolve_grep():
|
|
781
|
+
p = shutil.which("grep")
|
|
782
|
+
if p:
|
|
783
|
+
return p
|
|
784
|
+
if os.name == "nt":
|
|
785
|
+
git = shutil.which("git")
|
|
786
|
+
if git:
|
|
787
|
+
cand = Path(git).resolve().parent.parent / "usr" / "bin" / "grep.exe"
|
|
788
|
+
if cand.exists():
|
|
789
|
+
return str(cand)
|
|
790
|
+
return None
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def grep_search(grep_exe, patterns, path, excludes, exts, context, count,
|
|
794
|
+
max_results, fixed, raw_query="", dots=False, timeout=None,
|
|
795
|
+
since_ts=None, until_ts=None):
|
|
796
|
+
timeout = _RUN_DEFAULT_TIMEOUT_S if timeout is None else timeout
|
|
797
|
+
def build(fixed):
|
|
798
|
+
# grep 本来就会进以 . 开头的目录,差别在软链:-r 遇到软链目录不进,-R 才跟。
|
|
799
|
+
# 但 macOS 自带的 grep 连 -R 也不进软链目录(实测),所以这一路只在没装 rg 时用。
|
|
800
|
+
cmd = [grep_exe, "-R" if dots else "-r", "-n", "-i", "-I", "--color=never"]
|
|
801
|
+
if fixed:
|
|
802
|
+
cmd.append("-F")
|
|
803
|
+
elif needs_pcre2(raw_query):
|
|
804
|
+
return None, ("grep 不支持 look-around/反向引用语法,"
|
|
805
|
+
"请简化查询(如去掉 (?=、(?! 等结构)或安装 ripgrep")
|
|
806
|
+
if count:
|
|
807
|
+
cmd.append("-c")
|
|
808
|
+
elif context > 0:
|
|
809
|
+
cmd += ["-C", str(context)]
|
|
810
|
+
for ex in excludes:
|
|
811
|
+
if any(c in ex for c in "*?["):
|
|
812
|
+
cmd += ["--exclude", ex]
|
|
813
|
+
else:
|
|
814
|
+
cmd += ["--exclude-dir", ex]
|
|
815
|
+
if exts:
|
|
816
|
+
for e in exts:
|
|
817
|
+
cmd += ["--include", f"*.{e}"]
|
|
818
|
+
for p in patterns:
|
|
819
|
+
cmd += ["-e", p]
|
|
820
|
+
cmd.append(str(path))
|
|
821
|
+
return cmd, None
|
|
822
|
+
|
|
823
|
+
cmd, err = build(fixed)
|
|
824
|
+
if err:
|
|
825
|
+
return [], err
|
|
826
|
+
proc = run(cmd, timeout=timeout)
|
|
827
|
+
if (proc is not None and proc.returncode == 2 and not fixed
|
|
828
|
+
and "grep:" in (proc.stderr or "")):
|
|
829
|
+
cmd2, _ = build(True)
|
|
830
|
+
proc = run(cmd2, timeout=timeout)
|
|
831
|
+
if proc is None or proc.returncode not in (0, 1, 2):
|
|
832
|
+
return [], "grep 执行失败"
|
|
833
|
+
# 返回码 1 不等于「没搜到」:macOS 自带的 grep 跟随软链时,会一边输出结果
|
|
834
|
+
# 一边返回 1(实测输出了 3972 行,返回码仍是 1)。输出为空才算真的没搜到。
|
|
835
|
+
if proc.returncode == 1 and not proc.stdout.strip():
|
|
836
|
+
return [], None
|
|
837
|
+
# 确定性选择与 rg 路径同款(grep/fd 同样并行发射序不定)
|
|
838
|
+
if count:
|
|
839
|
+
counts = []
|
|
840
|
+
for line in proc.stdout.splitlines():
|
|
841
|
+
if ":" in line:
|
|
842
|
+
fp, _, n = line.rpartition(":")
|
|
843
|
+
# grep -c 会把没有命中的文件也列出来,写成 file:0。这种不算命中,
|
|
844
|
+
# 丢掉;这样和 rg --count-matches 只列命中的文件保持一致。
|
|
845
|
+
if n.isdigit() and int(n) > 0 and _in_time_window(fp, since_ts, until_ts):
|
|
846
|
+
counts.append((fp, int(n), ""))
|
|
847
|
+
counts.sort(key=lambda t: (-t[1], t[0]))
|
|
848
|
+
return counts[:max_results], None
|
|
849
|
+
candidates = (
|
|
850
|
+
(m.group(1), int(m.group(2)), truncate(m.group(3)))
|
|
851
|
+
for line in proc.stdout.splitlines()
|
|
852
|
+
if (m := re.match(r"^(.*?):(\d+):(.*)$", line))
|
|
853
|
+
and _in_time_window(m.group(1), since_ts, until_ts)
|
|
854
|
+
)
|
|
855
|
+
return heapq.nsmallest(max_results, candidates,
|
|
856
|
+
key=lambda t: (t[0], t[1], t[2])), None
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def mdfind_search(query, path, max_results, timeout=None,
|
|
860
|
+
since_ts=None, until_ts=None):
|
|
861
|
+
timeout = _RUN_DEFAULT_TIMEOUT_S if timeout is None else timeout
|
|
862
|
+
cmd = ["mdfind"]
|
|
863
|
+
if path and str(path) != ".":
|
|
864
|
+
cmd += ["-onlyin", str(Path(path).expanduser())]
|
|
865
|
+
cmd += [query]
|
|
866
|
+
proc = run(cmd, timeout=timeout)
|
|
867
|
+
if proc is None:
|
|
868
|
+
return [], "mdfind 执行失败"
|
|
869
|
+
# 时间窗在截断前过滤(先截后滤会漏报,与 rg 路径同理)
|
|
870
|
+
lines = [p for p in proc.stdout.splitlines()
|
|
871
|
+
if _in_time_window(p, since_ts, until_ts)]
|
|
872
|
+
return [(p, 0, "") for p in lines[:max_results]], None
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def format_output(results, engine, mode, path, elapsed_ms, query, total):
|
|
876
|
+
lines = [f"local-seek: {total} 处命中({engine} · {path} · {elapsed_ms}ms · 模式 {mode})"]
|
|
877
|
+
base = os.path.abspath(os.path.expanduser(path))
|
|
878
|
+
for fp, ln, txt in results:
|
|
879
|
+
afp = os.path.abspath(fp)
|
|
880
|
+
try:
|
|
881
|
+
rel = os.path.relpath(afp, base)
|
|
882
|
+
except ValueError:
|
|
883
|
+
rel = afp # 跨挂载点无法计算相对路径
|
|
884
|
+
shown = rel if not rel.startswith("..") else afp
|
|
885
|
+
if ln:
|
|
886
|
+
lines.append(f"{shown}:{ln}: {txt}" if txt else f"{shown}:{ln}")
|
|
887
|
+
else:
|
|
888
|
+
lines.append(f"{shown}")
|
|
889
|
+
return "\n".join(lines)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def to_json(results, engine, mode, scope, path, elapsed_ms, query, since=None, until=None):
|
|
893
|
+
return json.dumps({
|
|
894
|
+
"query": query,
|
|
895
|
+
"engine": engine,
|
|
896
|
+
"mode": mode,
|
|
897
|
+
"scope": scope,
|
|
898
|
+
"path": str(path),
|
|
899
|
+
"elapsed_ms": elapsed_ms,
|
|
900
|
+
"count": len(results),
|
|
901
|
+
"since": since,
|
|
902
|
+
"until": until,
|
|
903
|
+
"results": [
|
|
904
|
+
{"path": fp, "line": ln, "snippet": txt, "mtime": _file_mtime(fp)}
|
|
905
|
+
for fp, ln, txt in results
|
|
906
|
+
],
|
|
907
|
+
}, ensure_ascii=False, indent=2)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
# ── 时间窗(文件修改时间过滤;统一出口,不涉公共缓存)────────────────
|
|
911
|
+
def parse_time(s: str | None) -> float | None:
|
|
912
|
+
"""相对(7d/12h/1w/1m/1y)或绝对(2026-08-01/ISO)→ unix 秒时间戳。"""
|
|
913
|
+
if not s:
|
|
914
|
+
return None
|
|
915
|
+
s = str(s).strip()
|
|
916
|
+
m = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", s)
|
|
917
|
+
if m:
|
|
918
|
+
try:
|
|
919
|
+
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).timestamp()
|
|
920
|
+
except ValueError:
|
|
921
|
+
return None
|
|
922
|
+
try:
|
|
923
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
|
924
|
+
except ValueError:
|
|
925
|
+
pass
|
|
926
|
+
m = re.fullmatch(r"(\d+)([hdwmy])", s.lower())
|
|
927
|
+
if not m:
|
|
928
|
+
return None
|
|
929
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
930
|
+
now = time.time()
|
|
931
|
+
delta = {"h": 3600, "d": 86400, "w": 7 * 86400,
|
|
932
|
+
"m": 30 * 86400, "y": 365 * 86400}[unit]
|
|
933
|
+
return now - n * delta
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _file_mtime(fp: str) -> str | None:
|
|
937
|
+
try:
|
|
938
|
+
return datetime.fromtimestamp(os.path.getmtime(fp)).strftime("%Y-%m-%d %H:%M:%S")
|
|
939
|
+
except OSError:
|
|
940
|
+
return None
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _in_time_window(fp: str, since_ts: float | None, until_ts: float | None) -> bool:
|
|
944
|
+
"""单个文件是否落在 [since_ts, until_ts] 时间窗内(None = 该侧不限)。"""
|
|
945
|
+
if not since_ts and not until_ts:
|
|
946
|
+
return True
|
|
947
|
+
try:
|
|
948
|
+
mt = os.path.getmtime(fp)
|
|
949
|
+
except OSError:
|
|
950
|
+
return False
|
|
951
|
+
if since_ts and mt < since_ts:
|
|
952
|
+
return False
|
|
953
|
+
if until_ts and mt > until_ts:
|
|
954
|
+
return False
|
|
955
|
+
return True
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def filter_by_mtime(results, since: str | None, until: str | None):
|
|
959
|
+
"""时间窗过滤:仅保留文件 mtime 落在 [since, until] 内的命中。
|
|
960
|
+
|
|
961
|
+
这是**兜底出口**(structural 等未下传时间戳的路径用)。主过滤在各搜索器
|
|
962
|
+
**截断之前**完成(since_ts/until_ts 参数)——「先截 max 条再过滤」会系统性
|
|
963
|
+
漏报(2026-10-03 实测:20 个新命中只报出 8 个,旧文件占满了截断池)。
|
|
964
|
+
时间窗查询下文件已消失等 IO 异常条目剔除(与 local-search 无日期剔除同理)。
|
|
965
|
+
"""
|
|
966
|
+
since_ts, until_ts = parse_time(since), parse_time(until)
|
|
967
|
+
if not since_ts and not until_ts:
|
|
968
|
+
return results
|
|
969
|
+
return [(fp, ln, txt) for fp, ln, txt in results
|
|
970
|
+
if _in_time_window(fp, since_ts, until_ts)]
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
_OUTLINE_RULES = {
|
|
974
|
+
".py": [re.compile(r'^\s*(async\s+)?(def|class)\s+\w+')],
|
|
975
|
+
".pyi": [re.compile(r'^\s*(async\s+)?(def|class)\s+\w+')],
|
|
976
|
+
".js": [re.compile(r'^\s*(export\s+)?(async\s+)?(function|class)\s+\w+'),
|
|
977
|
+
re.compile(r'^\s*(export\s+)?(const|let|var)\s+\w+')],
|
|
978
|
+
".jsx": [re.compile(r'^\s*(export\s+)?(async\s+)?(function|class)\s+\w+'),
|
|
979
|
+
re.compile(r'^\s*(export\s+)?(const|let|var)\s+\w+')],
|
|
980
|
+
".ts": [re.compile(r'^\s*(export\s+)?(async\s+)?(function|class|interface|type|enum)\s+\w+'),
|
|
981
|
+
re.compile(r'^\s*(export\s+)?(const|let|var)\s+\w+')],
|
|
982
|
+
".tsx": [re.compile(r'^\s*(export\s+)?(async\s+)?(function|class|interface|type|enum)\s+\w+'),
|
|
983
|
+
re.compile(r'^\s*(export\s+)?(const|let|var)\s+\w+')],
|
|
984
|
+
".go": [re.compile(r'^\s*func\s+\w+'),
|
|
985
|
+
re.compile(r'^\s*type\s+\w+\s+(struct|interface)\b')],
|
|
986
|
+
".rs": [re.compile(r'^\s*(pub\s+)?(fn|struct|enum|impl|trait|mod|type)\s+\w+')],
|
|
987
|
+
".java": [re.compile(r'^\s*(public|private|protected|static|\s)*(class|interface|enum)\s+\w+'),
|
|
988
|
+
re.compile(r'^\s*(public|private|protected|static|\s)*[\w<>,\[\] ]+\s+\w+\s*\(')],
|
|
989
|
+
".sh": [re.compile(r'^\s*function\s+\w+'),
|
|
990
|
+
re.compile(r'^\s*[a-zA-Z_]\w*\s*\(\)\s*\{?')],
|
|
991
|
+
".bash": [re.compile(r'^\s*function\s+\w+'),
|
|
992
|
+
re.compile(r'^\s*[a-zA-Z_]\w*\s*\(\)\s*\{?')],
|
|
993
|
+
".zsh": [re.compile(r'^\s*function\s+\w+'),
|
|
994
|
+
re.compile(r'^\s*[a-zA-Z_]\w*\s*\(\)\s*\{?')],
|
|
995
|
+
".md": [re.compile(r'^#{1,6}\s+')],
|
|
996
|
+
".mdx": [re.compile(r'^#{1,6}\s+')],
|
|
997
|
+
".json": [re.compile(r'^\s*"[^"]+"\s*:')],
|
|
998
|
+
".yaml": [re.compile(r'^[a-zA-Z_][\w.-]*\s*:')],
|
|
999
|
+
".yml": [re.compile(r'^[a-zA-Z_][\w.-]*\s*:')],
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def outline_file(path):
|
|
1004
|
+
"""输出代码/文档文件结构,替代整文件读取。返回 (输出文本, 退出码)。"""
|
|
1005
|
+
p = Path(path)
|
|
1006
|
+
if not p.is_file():
|
|
1007
|
+
return f"local-seek: {path} 不是文件", 1
|
|
1008
|
+
try:
|
|
1009
|
+
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1010
|
+
except OSError as e:
|
|
1011
|
+
return f"local-seek: 读取失败 {e}", 1
|
|
1012
|
+
rules = _OUTLINE_RULES.get(p.suffix.lower())
|
|
1013
|
+
if not rules:
|
|
1014
|
+
return (f"local-seek: {p.name} 共 {len(lines)} 行"
|
|
1015
|
+
f"({p.suffix or '无扩展名'} 暂无结构规则)"), 0
|
|
1016
|
+
hits = []
|
|
1017
|
+
for i, ln in enumerate(lines, 1):
|
|
1018
|
+
if any(r.match(ln) for r in rules):
|
|
1019
|
+
hits.append(f"{i}: {ln.strip()[:100]}")
|
|
1020
|
+
head = f"local-seek: {p.name} 结构 {len(hits)} 处 / 共 {len(lines)} 行"
|
|
1021
|
+
return "\n".join([head] + hits), 0
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def read_lines(path, spec):
|
|
1025
|
+
"""按行读取文件(单遍流式,不加载全文)。返回 (输出文本, 退出码)。"""
|
|
1026
|
+
m = re.fullmatch(r"(\d+)-(\d+)", spec)
|
|
1027
|
+
if not m:
|
|
1028
|
+
return "local-seek: --lines 格式应为 N-M(如 10-20)", 1
|
|
1029
|
+
a, b = int(m.group(1)), int(m.group(2))
|
|
1030
|
+
if a < 1 or b < a:
|
|
1031
|
+
return f"local-seek: 行范围无效 {spec}", 1
|
|
1032
|
+
p = Path(path)
|
|
1033
|
+
if not p.is_file():
|
|
1034
|
+
return f"local-seek: {path} 不是文件", 1
|
|
1035
|
+
# 单遍流式读取:边数总行数边收集目标区间。原实现先 open 数行、再 open
|
|
1036
|
+
# 读区间,同一文件开两遍——大文件时 IO 翻倍(2026-10-06 收口)。
|
|
1037
|
+
picked, total = [], 0
|
|
1038
|
+
try:
|
|
1039
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
1040
|
+
for i, ln in enumerate(f, 1):
|
|
1041
|
+
total = i
|
|
1042
|
+
if a <= i <= b:
|
|
1043
|
+
picked.append(f"{i}: {ln.rstrip()}")
|
|
1044
|
+
except OSError as e:
|
|
1045
|
+
return f"local-seek: 读取失败 {e}", 1
|
|
1046
|
+
if a > total:
|
|
1047
|
+
return f"local-seek: {p.name} 只有 {total} 行,请求从 {a} 行开始", 1
|
|
1048
|
+
end = min(b, total)
|
|
1049
|
+
out = [f"local-seek: {p.name} 第 {a}-{end} 行 / 共 {total} 行"]
|
|
1050
|
+
return "\n".join(out + picked), 0
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
STRUCTURAL_RULES = {
|
|
1054
|
+
"empty-catch": {
|
|
1055
|
+
"aliases": ("empty catch", "空catch", "空catch块", "空捕获", "空异常处理"),
|
|
1056
|
+
"patterns": [
|
|
1057
|
+
(("js", "jsx", "ts", "tsx"), r"catch\s*\([^)]*\)\s*\{\s*\}"),
|
|
1058
|
+
],
|
|
1059
|
+
},
|
|
1060
|
+
"bare-except": {
|
|
1061
|
+
"aliases": ("bare except", "裸except", "裸异常", "无类型except"),
|
|
1062
|
+
"patterns": [
|
|
1063
|
+
(("py",), r"except\s*:"),
|
|
1064
|
+
],
|
|
1065
|
+
},
|
|
1066
|
+
"unwrapped-error": {
|
|
1067
|
+
"aliases": ("unwrapped error", "错误未包装", "裸错误返回", "裸return err"),
|
|
1068
|
+
"patterns": [
|
|
1069
|
+
(("go",), r"return\s+err\b"),
|
|
1070
|
+
],
|
|
1071
|
+
},
|
|
1072
|
+
"decorated-fn": {
|
|
1073
|
+
"aliases": ("decorated function", "装饰函数", "装饰器函数", "decorator"),
|
|
1074
|
+
"patterns": [
|
|
1075
|
+
(("py",), r"^\s*@\w[\w.]*\s*\n\s*(async\s+)?def\s+"),
|
|
1076
|
+
],
|
|
1077
|
+
},
|
|
1078
|
+
"function-def": {
|
|
1079
|
+
"aliases": ("function definition", "函数定义", "找函数"),
|
|
1080
|
+
"patterns": [
|
|
1081
|
+
(("py", "pyi"), r"^\s*(async\s+)?def\s+\w+"),
|
|
1082
|
+
(("js", "jsx", "ts", "tsx"), r"^\s*(export\s+)?(async\s+)?function\s+\w+"),
|
|
1083
|
+
(("go",), r"^\s*func\s+\w+"),
|
|
1084
|
+
(("rs",), r"^\s*(pub\s+)?fn\s+\w+"),
|
|
1085
|
+
],
|
|
1086
|
+
},
|
|
1087
|
+
"class-def": {
|
|
1088
|
+
"aliases": ("class definition", "类定义", "找类"),
|
|
1089
|
+
"patterns": [
|
|
1090
|
+
(("py", "pyi"), r"^\s*class\s+\w+"),
|
|
1091
|
+
(("js", "jsx", "ts", "tsx"), r"^\s*(export\s+)?class\s+\w+"),
|
|
1092
|
+
(("go",), r"^\s*type\s+\w+\s+struct\b"),
|
|
1093
|
+
(("java",), r"^\s*(public|private|protected)?\s*class\s+\w+"),
|
|
1094
|
+
],
|
|
1095
|
+
},
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
_STRUCT_ALIAS = {}
|
|
1099
|
+
for _key, _rule in STRUCTURAL_RULES.items():
|
|
1100
|
+
_STRUCT_ALIAS[_key] = _key
|
|
1101
|
+
for _a in _rule["aliases"]:
|
|
1102
|
+
_STRUCT_ALIAS[_a] = _key
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def structural_search(rule_name, path, excludes, max_results, dots=False,
|
|
1106
|
+
timeout=None):
|
|
1107
|
+
"""结构搜索:按语义规则(空 catch/裸 except/未包装错误/装饰函数等)检索。
|
|
1108
|
+
零安装实现:rg -U 多行 + 语言感知 pattern;本机装 ast-grep 后可升级为 AST 精确匹配。"""
|
|
1109
|
+
key = _STRUCT_ALIAS.get(rule_name.strip().lower())
|
|
1110
|
+
if not key:
|
|
1111
|
+
avail = "、".join(STRUCTURAL_RULES)
|
|
1112
|
+
return [], f"未知结构查询「{rule_name}」,可用:{avail}"
|
|
1113
|
+
results = []
|
|
1114
|
+
for exts, pat in STRUCTURAL_RULES[key]["patterns"]:
|
|
1115
|
+
cmd = ["rg", "--line-number", "--no-heading", "-U", "-i", "--color", "never"]
|
|
1116
|
+
if dots:
|
|
1117
|
+
cmd += ["--hidden", "--follow"]
|
|
1118
|
+
for ex in excludes:
|
|
1119
|
+
cmd += ["-g", f"!{ex}"]
|
|
1120
|
+
for e in exts:
|
|
1121
|
+
cmd += ["-g", f"*.{e}"]
|
|
1122
|
+
cmd += ["-e", pat, str(path)]
|
|
1123
|
+
# timeout 与其他搜索器同源:H2 后进程内调用传紧预算,缺省走 30s。
|
|
1124
|
+
# 此前漏下传,MCP 进程内调用可被挂满 30s 占死单线程 executor。
|
|
1125
|
+
proc = run(cmd, timeout=timeout)
|
|
1126
|
+
if proc is None:
|
|
1127
|
+
continue
|
|
1128
|
+
if proc.returncode == 1:
|
|
1129
|
+
continue # 该语言无命中
|
|
1130
|
+
# 返回码 2 表示「有结果,但过程中报了错」(比如软链指向的文件不存在)。
|
|
1131
|
+
# 不能一见非零就当没结果,否则加上 --dot 以后,结构搜索会从有结果变成没找到。
|
|
1132
|
+
if proc.returncode not in (0, 2):
|
|
1133
|
+
continue
|
|
1134
|
+
for line in proc.stdout.splitlines():
|
|
1135
|
+
m = re.match(r"^(.*?):(\d+):(.*)$", line)
|
|
1136
|
+
if m:
|
|
1137
|
+
results.append((m.group(1), int(m.group(2)), truncate(m.group(3))))
|
|
1138
|
+
if len(results) >= max_results:
|
|
1139
|
+
return results, None
|
|
1140
|
+
return results, None
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def git_log(path, n=10, timeout=None):
|
|
1144
|
+
"""输出文件的最近提交历史(git log --oneline)。"""
|
|
1145
|
+
p = Path(path).expanduser()
|
|
1146
|
+
if not p.is_file():
|
|
1147
|
+
return f"local-seek: {path} 不是文件", 1
|
|
1148
|
+
proc = run(["git", "-C", str(p.parent), "log", "--oneline", f"-{n}", "--", p.name],
|
|
1149
|
+
timeout=timeout)
|
|
1150
|
+
if proc is None:
|
|
1151
|
+
return "local-seek: git 执行失败", 1
|
|
1152
|
+
if proc.returncode == 128 or (proc.returncode == 0 and not proc.stdout.strip()):
|
|
1153
|
+
inside = run(["git", "-C", str(p.parent), "rev-parse", "--is-inside-work-tree"])
|
|
1154
|
+
if inside is None or inside.returncode != 0:
|
|
1155
|
+
return f"local-seek: {p.name} 不在 git 仓库中", 1
|
|
1156
|
+
return f"local-seek: {p.name} 无提交历史", 0
|
|
1157
|
+
lines = proc.stdout.splitlines()
|
|
1158
|
+
return "\n".join([f"local-seek: {p.name} 最近 {len(lines)} 条提交"] + lines), 0
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def git_blame(path, line, timeout=None):
|
|
1162
|
+
"""输出文件第 N 行的 blame 信息(谁在哪个提交改的)。"""
|
|
1163
|
+
p = Path(path).expanduser()
|
|
1164
|
+
if not p.is_file():
|
|
1165
|
+
return f"local-seek: {path} 不是文件", 1
|
|
1166
|
+
proc = run(["git", "-C", str(p.parent), "blame", "-L", f"{line},{line}", "--", p.name],
|
|
1167
|
+
timeout=timeout)
|
|
1168
|
+
if proc is None or proc.returncode != 0:
|
|
1169
|
+
return f"local-seek: git blame 失败({p.name} 可能在 git 仓库外或行号无效)", 1
|
|
1170
|
+
return f"local-seek: {p.name} 第 {line} 行\n{proc.stdout.strip()}", 0
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _run_grep_fallback(args, patterns, fixed, path, excludes, exts, max_results,
|
|
1174
|
+
timeout=None, since_ts=None, until_ts=None):
|
|
1175
|
+
"""rg 缺失时的 grep 保底(目录内/全盘共用)。
|
|
1176
|
+
|
|
1177
|
+
返回 (results, err, mode);rg 与 grep 都不可用时 results=None 且 err
|
|
1178
|
+
说明缺什么——调用方必须把「工具缺失」与「未找到匹配」区分开。
|
|
1179
|
+
"""
|
|
1180
|
+
grep_exe = resolve_grep()
|
|
1181
|
+
if not grep_exe:
|
|
1182
|
+
return None, "本机无 rg 与 grep 可用,请安装 ripgrep", ""
|
|
1183
|
+
dots = getattr(args, "dot", False)
|
|
1184
|
+
mode = "fast"
|
|
1185
|
+
if not args.exact and len(patterns) > 1:
|
|
1186
|
+
results, err = grep_search(grep_exe, [args.query], path, excludes, exts,
|
|
1187
|
+
args.context, args.count, max_results,
|
|
1188
|
+
is_literal(args.query), dots=dots, timeout=timeout,
|
|
1189
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
1190
|
+
if not results and not err:
|
|
1191
|
+
results, err = grep_search(grep_exe, patterns, path, excludes, exts,
|
|
1192
|
+
args.context, args.count, max_results,
|
|
1193
|
+
fixed, dots=dots, timeout=timeout,
|
|
1194
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
1195
|
+
if results:
|
|
1196
|
+
mode += "+扩展"
|
|
1197
|
+
else:
|
|
1198
|
+
results, err = grep_search(grep_exe, patterns, path, excludes, exts,
|
|
1199
|
+
args.context, args.count, max_results, fixed,
|
|
1200
|
+
dots=dots, timeout=timeout,
|
|
1201
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
1202
|
+
return results, err, mode
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def run_query(argv=None, time_budget=None) -> tuple[str, int]:
|
|
1206
|
+
"""执行一次 seek 查询,返回 (输出文本, 退出码)。
|
|
1207
|
+
|
|
1208
|
+
H2(2026-09-29):从 main() 重构出的**进程内可调用核心**——CLI 壳与
|
|
1209
|
+
include-local / MCP argo_local_search 的进程内调用共用同一实现(单一
|
|
1210
|
+
来源),CLI 行为逐字节不变(main 只负责 print + exit code)。
|
|
1211
|
+
time_budget:下传给各搜索器内部子进程(rg/fd/grep/mdfind)的超时上限;
|
|
1212
|
+
None = 沿用 30s 历史默认。进程内调用必须传紧值——子进程可硬杀,线程
|
|
1213
|
+
不可杀,不传会让挂死的 rg 占死单线程 executor。
|
|
1214
|
+
"""
|
|
1215
|
+
ap = argparse.ArgumentParser(prog="seek", description="本地高效搜索统一入口")
|
|
1216
|
+
ap.add_argument("query", nargs="?", help="搜索查询词")
|
|
1217
|
+
ap.add_argument("--path", default=".", help="搜索目录(默认当前目录)")
|
|
1218
|
+
ap.add_argument("--scope", choices=["code", "doc", "all"], default="code",
|
|
1219
|
+
help="code=正文+代码;doc=文档类;all=Spotlight 兜底")
|
|
1220
|
+
ap.add_argument("--filename", action="store_true", help="按文件名查找(fd)")
|
|
1221
|
+
ap.add_argument("--spotlight", action="store_true", help="Spotlight 全盘兜底")
|
|
1222
|
+
ap.add_argument("--dot", action="store_true",
|
|
1223
|
+
help="连以 . 开头的目录和软链一起搜(默认关;搜 ~/.agents、~/.zcode 时要加)")
|
|
1224
|
+
ap.add_argument("--include-noise", action="store_true",
|
|
1225
|
+
help="不降权:repos/、tests/、tmp/、日期归档目录与真源平权"
|
|
1226
|
+
"(默认排在真源之后,但仍可达,故通常无需显式加)")
|
|
1227
|
+
ap.add_argument("--type", default="", help="限定扩展名,逗号分隔(py,ts,md)")
|
|
1228
|
+
ap.add_argument("--count", action="store_true", help="只输出每文件命中数")
|
|
1229
|
+
ap.add_argument("--context", type=int, default=0, help="上下文行数(默认 0)")
|
|
1230
|
+
ap.add_argument("--json", action="store_true", help="JSON 输出")
|
|
1231
|
+
ap.add_argument("--max", type=int, default=0, help="最大结果数(默认读配置)")
|
|
1232
|
+
ap.add_argument("--exact", action="store_true", help="关闭中文扩展(精确匹配)")
|
|
1233
|
+
ap.add_argument("--exclude", action="append", default=[], metavar="GLOB",
|
|
1234
|
+
help="额外排除 glob(可重复指定,如 --exclude eval_seek.py)")
|
|
1235
|
+
ap.add_argument("--since", default=None,
|
|
1236
|
+
help="文件修改时间下限(7d / 2026-08-01),过滤命中文件的 mtime")
|
|
1237
|
+
ap.add_argument("--until", default=None,
|
|
1238
|
+
help="文件修改时间上限(7d / 2026-08-01),过滤命中文件的 mtime")
|
|
1239
|
+
ap.add_argument("--outline", action="store_true", help="输出文件结构(文件路径为位置参数)")
|
|
1240
|
+
ap.add_argument("--lines", default="", metavar="N-M",
|
|
1241
|
+
help="按行读取文件(文件路径为位置参数)")
|
|
1242
|
+
ap.add_argument("--structural", action="store_true",
|
|
1243
|
+
help="结构搜索:按语义检索(裸except/空catch/装饰函数/函数定义等)")
|
|
1244
|
+
ap.add_argument("--git-log", action="store_true",
|
|
1245
|
+
help="输出文件的最近提交历史(文件路径为位置参数)")
|
|
1246
|
+
ap.add_argument("--git-blame", default="", metavar="N",
|
|
1247
|
+
help="输出文件第 N 行的 blame 信息(文件路径为位置参数)")
|
|
1248
|
+
ap.add_argument("--domains", action="store_true", help="列出知识域与排除规则")
|
|
1249
|
+
args = ap.parse_args(argv)
|
|
1250
|
+
|
|
1251
|
+
# 知识域展示模式
|
|
1252
|
+
if args.domains:
|
|
1253
|
+
if DOMAINS_FILE.exists():
|
|
1254
|
+
return DOMAINS_FILE.read_text(encoding="utf-8"), 0
|
|
1255
|
+
return "未找到 config/domains.yaml,使用内置默认规则", 0
|
|
1256
|
+
|
|
1257
|
+
# 文件结构 / 按行读取模式(文件路径优先取位置参数,否则取 --path)
|
|
1258
|
+
if args.outline or args.lines:
|
|
1259
|
+
target = args.query or str(args.path)
|
|
1260
|
+
if args.lines:
|
|
1261
|
+
return read_lines(target, args.lines)
|
|
1262
|
+
return outline_file(target)
|
|
1263
|
+
|
|
1264
|
+
# git 联动模式:查文件提交历史 / 单行归属
|
|
1265
|
+
if args.git_log or args.git_blame:
|
|
1266
|
+
target = args.query or str(args.path)
|
|
1267
|
+
if args.git_blame:
|
|
1268
|
+
return git_blame(target, args.git_blame, timeout=time_budget)
|
|
1269
|
+
return git_log(target, timeout=time_budget)
|
|
1270
|
+
|
|
1271
|
+
# 结构搜索模式:按语义规则检索(裸except/空catch/装饰函数等)
|
|
1272
|
+
if args.structural:
|
|
1273
|
+
if not args.query:
|
|
1274
|
+
return ap.format_help(), 0
|
|
1275
|
+
start = time.time()
|
|
1276
|
+
results, err = structural_search(args.query, Path(args.path).expanduser(),
|
|
1277
|
+
load_excludes() + args.exclude,
|
|
1278
|
+
args.max or 30, args.dot,
|
|
1279
|
+
timeout=time_budget)
|
|
1280
|
+
elapsed = int((time.time() - start) * 1000)
|
|
1281
|
+
results = filter_by_mtime(results, args.since, args.until)
|
|
1282
|
+
if err:
|
|
1283
|
+
return f"local-seek: {err}", 1
|
|
1284
|
+
if not results:
|
|
1285
|
+
return f"local-seek: 未找到匹配(rg-structural · {args.path} · {elapsed}ms)", 1
|
|
1286
|
+
if args.json:
|
|
1287
|
+
return to_json(results, "rg-structural", "structural", args.scope,
|
|
1288
|
+
args.path, elapsed, args.query,
|
|
1289
|
+
since=args.since, until=args.until), 0
|
|
1290
|
+
return format_output(results, "rg-structural", "structural",
|
|
1291
|
+
args.path, elapsed, args.query, len(results)), 0
|
|
1292
|
+
|
|
1293
|
+
if not args.query:
|
|
1294
|
+
return ap.format_help(), 0
|
|
1295
|
+
|
|
1296
|
+
max_results = args.max or 30
|
|
1297
|
+
excludes = load_excludes() + args.exclude
|
|
1298
|
+
exts = [e.strip().lstrip(".") for e in args.type.split(",") if e.strip()]
|
|
1299
|
+
|
|
1300
|
+
scope = args.scope
|
|
1301
|
+
if args.spotlight:
|
|
1302
|
+
scope = "all"
|
|
1303
|
+
# doc 场景补充文档扩展名
|
|
1304
|
+
if scope == "doc" and not exts:
|
|
1305
|
+
# 文本档(md/txt/html…)rg 直接搜全文;pdf/docx 等二进制 rg 搜不出
|
|
1306
|
+
# 内容,那是 --spotlight(Spotlight 内容索引)的职责。此前把 md/txt
|
|
1307
|
+
# 从 exts 里减掉,恰好漏掉最常搜的笔记(2026-10-03 实测:--scope doc
|
|
1308
|
+
# 搜 md 零命中,doc 场景等于虚设)。
|
|
1309
|
+
exts = sorted(DOC_EXTS)
|
|
1310
|
+
scope = "code"
|
|
1311
|
+
if not tool_exists("rg"):
|
|
1312
|
+
scope = "all"
|
|
1313
|
+
|
|
1314
|
+
path = Path(args.path).expanduser()
|
|
1315
|
+
if not path.exists():
|
|
1316
|
+
# 返回 (text, rc) 而非直接 print:run_query 是进程内可调用核心,
|
|
1317
|
+
# main 只负责 print。此前在这里 print + 裸 return 1,违反契约,
|
|
1318
|
+
# MCP/include-local 等进程内调用方解包 (text, rc) 直接 TypeError。
|
|
1319
|
+
return f"local-seek: 目录不存在 {path}", 1
|
|
1320
|
+
|
|
1321
|
+
engine = "rg"
|
|
1322
|
+
mode = "fast"
|
|
1323
|
+
start = time.time()
|
|
1324
|
+
results, err = [], None
|
|
1325
|
+
patterns, fixed = build_patterns(args.query, args.exact)
|
|
1326
|
+
# 时间窗时间戳只解析一次,下传各搜索器在**截断前**过滤
|
|
1327
|
+
since_ts, until_ts = parse_time(args.since), parse_time(args.until)
|
|
1328
|
+
|
|
1329
|
+
if scope == "all":
|
|
1330
|
+
# 全盘搜索:mdfind 优先(rg/grep 扫全盘太慢),缺 mdfind 退 grep
|
|
1331
|
+
if tool_exists("mdfind"):
|
|
1332
|
+
engine, mode = "mdfind", "deep"
|
|
1333
|
+
results, err = mdfind_search(args.query, None if args.spotlight else path,
|
|
1334
|
+
max_results, timeout=time_budget,
|
|
1335
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
1336
|
+
else:
|
|
1337
|
+
results, err, gmode = _run_grep_fallback(
|
|
1338
|
+
args, patterns, fixed, path, excludes, exts, max_results,
|
|
1339
|
+
timeout=time_budget, since_ts=since_ts, until_ts=until_ts)
|
|
1340
|
+
if results is None:
|
|
1341
|
+
engine, mode = "none", "fast"
|
|
1342
|
+
else:
|
|
1343
|
+
engine, mode = "grep", gmode
|
|
1344
|
+
elif not tool_exists("rg"):
|
|
1345
|
+
# 目录内搜索缺 rg 必须落 grep,不能落 mdfind:Spotlight 不索引
|
|
1346
|
+
# 源码内容,会把「工具缺失」伪装成「搜索结论」(实测搜 argo 自身
|
|
1347
|
+
# 符号返回 24 条外部陈旧副本、漏掉正主,还报「未找到匹配」)。
|
|
1348
|
+
results, err, gmode = _run_grep_fallback(
|
|
1349
|
+
args, patterns, fixed, path, excludes, exts, max_results,
|
|
1350
|
+
timeout=time_budget, since_ts=since_ts, until_ts=until_ts)
|
|
1351
|
+
if results is None:
|
|
1352
|
+
engine, mode = "none", "fast"
|
|
1353
|
+
else:
|
|
1354
|
+
engine, mode = "grep", gmode
|
|
1355
|
+
elif args.filename:
|
|
1356
|
+
engine, mode = "fd", "fast"
|
|
1357
|
+
if tool_exists("fd"):
|
|
1358
|
+
results, err = fd_search(args.query, path, exts, max_results, args.dot,
|
|
1359
|
+
timeout=time_budget,
|
|
1360
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
1361
|
+
# 拼音首字母补充:中文查询 → 双查拼音缩写(「新建夹」↔ xjj)。
|
|
1362
|
+
# 先窄后宽:仅原结果 <3 且中文 ≥2 字时做(单字「新」→'x' 太宽泛,会引入噪音)
|
|
1363
|
+
if not err and len(results or []) < 3:
|
|
1364
|
+
_cjk_len = sum(1 for c in args.query if "\u4e00" <= c <= "\u9fff")
|
|
1365
|
+
q_py = pinyin_initials(args.query)
|
|
1366
|
+
if _cjk_len >= 2 and q_py and q_py.lower() != args.query.strip().lower():
|
|
1367
|
+
py_res, py_err = fd_search(q_py, path, exts, max_results, args.dot,
|
|
1368
|
+
timeout=time_budget,
|
|
1369
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
1370
|
+
if not py_err and py_res:
|
|
1371
|
+
results = _merge_dedup(results or [], py_res, max_results)
|
|
1372
|
+
# 拼音缩写反推:结果少且疑似缩写(xjj)→ 枚举候选按拼音首字母过滤
|
|
1373
|
+
if not err and (not results or len(results) < 3) and _looks_like_pinyin_abbrev(args.query):
|
|
1374
|
+
all_res, all_err = fd_search("", path, exts, 3000, args.dot,
|
|
1375
|
+
timeout=time_budget,
|
|
1376
|
+
since_ts=since_ts, until_ts=until_ts)
|
|
1377
|
+
if not all_err and all_res:
|
|
1378
|
+
py_hits = [it for it in all_res if _file_pinyin_bonus(it[0], args.query) > 0]
|
|
1379
|
+
if py_hits:
|
|
1380
|
+
results = _merge_dedup(results or [], py_hits, max_results)
|
|
1381
|
+
else:
|
|
1382
|
+
err = "fd 未安装:按文件名搜索需要 fd(内容搜索走 rg/grep)"
|
|
1383
|
+
else:
|
|
1384
|
+
mode = "deep" if (args.context > 0 or args.count) else "fast"
|
|
1385
|
+
# FTS 快路资格:带扩展名过滤或自定义排除时关闭(快路不做 rg 侧 glob 过滤,
|
|
1386
|
+
# 见 rg_search 的 fts_ok 说明)。--scope doc 会自动填 exts,同样走 rg 主路。
|
|
1387
|
+
fts_ok = not exts and not args.exclude
|
|
1388
|
+
if not args.exact and len(patterns) > 1:
|
|
1389
|
+
# 中文扩展遵循「先窄后宽」:先精确匹配,命中不足才放宽到扩展词
|
|
1390
|
+
_dn = not args.include_noise
|
|
1391
|
+
results, err = rg_search([args.query], path, excludes, exts,
|
|
1392
|
+
args.context, args.count, max_results,
|
|
1393
|
+
is_literal(args.query), args.query, args.dot,
|
|
1394
|
+
drop_noise=_dn, timeout=time_budget,
|
|
1395
|
+
since_ts=since_ts, until_ts=until_ts,
|
|
1396
|
+
fts_ok=fts_ok)
|
|
1397
|
+
if not results and not err:
|
|
1398
|
+
results, err = rg_search(patterns, path, excludes, exts,
|
|
1399
|
+
args.context, args.count, max_results,
|
|
1400
|
+
fixed, args.query, args.dot, drop_noise=_dn,
|
|
1401
|
+
timeout=time_budget,
|
|
1402
|
+
since_ts=since_ts, until_ts=until_ts,
|
|
1403
|
+
fts_ok=fts_ok)
|
|
1404
|
+
if results:
|
|
1405
|
+
mode += "+扩展"
|
|
1406
|
+
else:
|
|
1407
|
+
results, err = rg_search(patterns, path, excludes, exts,
|
|
1408
|
+
args.context, args.count, max_results,
|
|
1409
|
+
fixed, args.query, args.dot,
|
|
1410
|
+
drop_noise=not args.include_noise,
|
|
1411
|
+
timeout=time_budget,
|
|
1412
|
+
since_ts=since_ts, until_ts=until_ts,
|
|
1413
|
+
fts_ok=fts_ok)
|
|
1414
|
+
|
|
1415
|
+
# 注:噪声档**只降权不排除**(见 _apply_noise_floor),所以这里没有
|
|
1416
|
+
# 「搜不到就回落重搜」的分支——噪声内容始终在结果池里,只是排在真源之后。
|
|
1417
|
+
# 早先版本用 rg 的 -g glob 排除,那会连搜索根自己叫 tests/ 的情况一起打死
|
|
1418
|
+
# (实测整棵树被判空、且回落也不触发),故改成 Python 层排序。
|
|
1419
|
+
|
|
1420
|
+
elapsed = int((time.time() - start) * 1000)
|
|
1421
|
+
# 时间窗:统一出口按文件 mtime 过滤(rg/fd/mdfind 共用)
|
|
1422
|
+
results = filter_by_mtime(results, args.since, args.until)
|
|
1423
|
+
# 按文件定位场景(fd/spotlight):fzf 相关性评分 + 拼音加分 + mtime 新优先排序
|
|
1424
|
+
if (args.filename or args.spotlight) and results:
|
|
1425
|
+
results = _rank_path_results(results, args.query)
|
|
1426
|
+
|
|
1427
|
+
# frecency 记录(opt-in,ARGO_SEEK_FRECENCY=1):只记最终交付的 top 路径,
|
|
1428
|
+
# 关闭时零 IO、零行为差异(见 seek_frecency.py)
|
|
1429
|
+
if results:
|
|
1430
|
+
try:
|
|
1431
|
+
import seek_frecency
|
|
1432
|
+
if seek_frecency.enabled():
|
|
1433
|
+
seek_frecency.record([r[0] if isinstance(r, (list, tuple)) else str(r)
|
|
1434
|
+
for r in results])
|
|
1435
|
+
except Exception:
|
|
1436
|
+
pass
|
|
1437
|
+
|
|
1438
|
+
if err:
|
|
1439
|
+
return f"local-seek: {err}", 1
|
|
1440
|
+
if not results:
|
|
1441
|
+
return f"local-seek: 未找到匹配({engine} · {path} · {elapsed}ms)", 1
|
|
1442
|
+
|
|
1443
|
+
if args.json:
|
|
1444
|
+
return to_json(results, engine, mode, scope, path, elapsed, args.query,
|
|
1445
|
+
since=args.since, until=args.until), 0
|
|
1446
|
+
return format_output(results, engine, mode, path, elapsed, args.query,
|
|
1447
|
+
len(results)), 0
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def main(argv=None):
|
|
1451
|
+
"""CLI 薄壳:print + exit code(进程内调用方直接用 run_query)。"""
|
|
1452
|
+
text, rc = run_query(argv)
|
|
1453
|
+
if text:
|
|
1454
|
+
print(text)
|
|
1455
|
+
return rc
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
if __name__ == "__main__":
|
|
1459
|
+
for _s in (sys.stdout, sys.stderr):
|
|
1460
|
+
try:
|
|
1461
|
+
if _s.encoding and _s.encoding.lower().replace("-", "") != "utf8":
|
|
1462
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
1463
|
+
except Exception:
|
|
1464
|
+
pass
|
|
1465
|
+
sys.exit(main())
|