@herbertgao/pi-extensions 2026.8.4 → 2026.8.6

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 +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +53 -14
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  14. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  15. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +18 -0
  16. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +20 -4
  21. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  22. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  26. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  31. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  32. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  33. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  36. package/node_modules/@narumitw/pi-btw/README.md +32 -24
  37. package/node_modules/@narumitw/pi-btw/package.json +6 -6
  38. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  39. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
  40. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  41. package/node_modules/pi-lens/CHANGELOG.md +2535 -954
  42. package/node_modules/pi-lens/README.md +13 -0
  43. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  44. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  45. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  46. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  47. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  48. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  50. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  51. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  52. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  53. package/node_modules/pi-lens/dist/clients/biome-client.js +136 -24
  54. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  55. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  56. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  57. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  58. package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
  59. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  60. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  61. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  62. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  63. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  64. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  65. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  66. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  67. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  68. package/node_modules/pi-lens/dist/clients/console-guard-install.js +26 -0
  69. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  70. package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
  71. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  72. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  73. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  74. package/node_modules/pi-lens/dist/clients/dependency-checker.js +361 -77
  75. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
  76. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
  77. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
  78. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  79. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  80. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  81. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  83. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1172 -683
  84. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  86. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  87. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +47 -6
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +473 -42
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  114. package/node_modules/pi-lens/dist/clients/disposition-publish.js +18 -4
  115. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  116. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  117. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  118. package/node_modules/pi-lens/dist/clients/extension-log.js +432 -0
  119. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  120. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  121. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  122. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  123. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  124. package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
  125. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  126. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  127. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  128. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  129. package/node_modules/pi-lens/dist/clients/git-guard.js +678 -11
  130. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  131. package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
  132. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
  133. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  134. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  135. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  136. package/node_modules/pi-lens/dist/clients/installer/index.js +395 -100
  137. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  138. package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
  139. package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
  140. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  141. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  142. package/node_modules/pi-lens/dist/clients/latency-logger.js +72 -1
  143. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  144. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  145. package/node_modules/pi-lens/dist/clients/lens-events.js +63 -9
  146. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  147. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  148. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
  149. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  150. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  151. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
  152. package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
  153. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  154. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
  155. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  156. package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
  157. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  158. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  159. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  160. package/node_modules/pi-lens/dist/clients/lsp/server.js +437 -86
  161. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  162. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  163. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  164. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  165. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  166. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  167. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +127 -32
  168. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  169. package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
  170. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  171. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  172. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  173. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  174. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  175. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  176. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  177. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  178. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  179. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  180. package/node_modules/pi-lens/dist/clients/pipeline.js +76 -15
  181. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  182. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  183. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  184. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  185. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  186. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  187. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  188. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  189. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  190. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  191. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  192. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  193. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  194. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  195. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  196. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  197. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  198. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  199. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  201. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  202. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  203. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  204. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  205. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  206. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  207. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  208. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +257 -77
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -117
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +342 -33
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  217. package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
  218. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  219. package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
  220. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  221. package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -0
  222. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  223. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  224. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  225. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  226. package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
  227. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  228. package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
  229. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  230. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  233. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  234. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  235. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  237. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  238. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  239. package/node_modules/pi-lens/dist/clients/warm-attach.js +50 -13
  240. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  241. package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
  242. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  243. package/node_modules/pi-lens/dist/index.js +51785 -36118
  244. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  245. package/node_modules/pi-lens/dist/mcp/server.js +219 -46
  246. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  247. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  248. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  249. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  250. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
  251. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
  252. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  253. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  254. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  255. package/node_modules/pi-lens/docs/agent-guide.md +40 -15
  256. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  257. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  258. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  259. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +14 -3
  260. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  261. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  262. package/node_modules/pi-lens/docs/fable.md +27 -0
  263. package/node_modules/pi-lens/docs/features.md +45 -3
  264. package/node_modules/pi-lens/docs/globalconfig.md +56 -1
  265. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  266. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  267. package/node_modules/pi-lens/docs/mcp.md +37 -4
  268. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  269. package/node_modules/pi-lens/docs/settings.md +18 -1
  270. package/node_modules/pi-lens/docs/tools.md +4 -1
  271. package/node_modules/pi-lens/docs/usage.md +41 -5
  272. package/node_modules/pi-lens/docs/word-index.md +35 -0
  273. package/node_modules/pi-lens/package.json +10 -10
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  300. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  301. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  302. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +134 -2
  303. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  304. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  305. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  306. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  307. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  308. package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
  309. package/node_modules/pi-mcp-adapter/README.md +38 -4
  310. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  311. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  312. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  313. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  314. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  315. package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
  316. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  317. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  318. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  319. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  320. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  321. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  322. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  323. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  324. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  325. package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
  326. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  327. package/node_modules/pi-mcp-adapter/package.json +4 -3
  328. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  329. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  330. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  331. package/node_modules/pi-mcp-adapter/server-manager.ts +41 -7
  332. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  333. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  334. package/node_modules/pi-mcp-adapter/tool-metadata.ts +62 -4
  335. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  336. package/node_modules/pi-mcp-adapter/types.ts +176 -27
  337. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  338. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  339. package/node_modules/pi-web-access/README.md +18 -12
  340. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  341. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  342. package/node_modules/pi-web-access/curator-page.ts +5 -3
  343. package/node_modules/pi-web-access/curator-server.ts +2 -1
  344. package/node_modules/pi-web-access/extract.ts +106 -34
  345. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  346. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  347. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  348. package/node_modules/pi-web-access/index.ts +120 -48
  349. package/node_modules/pi-web-access/package.json +2 -2
  350. package/node_modules/pi-web-access/summary-review.ts +11 -5
  351. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  352. package/package.json +12 -13
  353. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  354. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  355. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  356. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  357. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  358. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  359. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  360. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
+ import { createDeadline, forEachCooperatively, yieldIfOverBudget, } from "./cooperative-budget.js";
19
+ import { KIND_EXTENSIONS } from "./file-kinds.js";
18
20
  import { PathKeyedMap } from "./path-keyed-map.js";
19
21
  import { isAtOrAboveHomeDir, normalizeEphemeralMapKey } from "./path-utils.js";
20
22
  import { createDebounceScheduler, } from "./persist-debounce.js";
@@ -37,11 +39,50 @@ export const wordIndexKey = normalizeEphemeralMapKey;
37
39
  // postings without improving relevance. Kept deliberately small and
38
40
  // language-agnostic.
39
41
  const STOPWORDS = new Set([
40
- "the", "and", "for", "let", "var", "const", "function", "return", "if",
41
- "else", "import", "export", "from", "class", "interface", "type", "enum",
42
- "new", "this", "self", "void", "null", "true", "false", "async", "await",
43
- "public", "private", "protected", "static", "def", "fn", "func", "struct",
44
- "impl", "pub", "use", "mod", "in", "of", "as", "is", "not", "with",
42
+ "the",
43
+ "and",
44
+ "for",
45
+ "let",
46
+ "var",
47
+ "const",
48
+ "function",
49
+ "return",
50
+ "if",
51
+ "else",
52
+ "import",
53
+ "export",
54
+ "from",
55
+ "class",
56
+ "interface",
57
+ "type",
58
+ "enum",
59
+ "new",
60
+ "this",
61
+ "self",
62
+ "void",
63
+ "null",
64
+ "true",
65
+ "false",
66
+ "async",
67
+ "await",
68
+ "public",
69
+ "private",
70
+ "protected",
71
+ "static",
72
+ "def",
73
+ "fn",
74
+ "func",
75
+ "struct",
76
+ "impl",
77
+ "pub",
78
+ "use",
79
+ "mod",
80
+ "in",
81
+ "of",
82
+ "as",
83
+ "is",
84
+ "not",
85
+ "with",
45
86
  ]);
46
87
  const TEST_VENDOR_RE = /(?:(^|[\\/])(?:tests?|__tests__|spec|specs|__mocks__|vendor|node_modules|examples?|fixtures?|\.git|dist|build|coverage)([\\/]|$))|(?:\.(?:test|spec)\.[a-z]+$)/i;
47
88
  const DOC_FILE_RE = /\.(?:md|mdx|markdown|json|json5|jsonc|txt|rst|lock|ya?ml|toml|csv)$/i;
@@ -94,52 +135,112 @@ export function tokenizeLine(line) {
94
135
  }
95
136
  return tokens;
96
137
  }
138
+ const WORD_INDEX_BUILD_YIELD_BUDGET_MS = 8;
97
139
  /**
98
- * Build the inverted index from file contents. One posting per (token, file,
99
- * line) — a token repeated on the same line counts once — so term frequency is
100
- * "lines mentioning the token", a stable signal that doesn't over-weight a line
101
- * that repeats an identifier. Document length is the total indexed token count.
140
+ * A line at or above this length is treated as a yield checkpoint on its own.
141
+ * The per-50-lines cadence is a proxy for work that only holds on hand-written
142
+ * source: a minified/bundled single-line file up to {@link WORD_INDEX_MAX_BYTES}
143
+ * that the source filter did not exclude would otherwise get ZERO in-document
144
+ * yields — one unbroken tokenize+push burst far above the budget (#1197 review
145
+ * finding 4). Checking the clock after any long line makes the cooperativeness
146
+ * claim true per BYTE, not just per line count.
102
147
  */
148
+ const WORD_INDEX_LONG_LINE_YIELD_CHARS = 4096;
149
+ /**
150
+ * Shared cooperative time-slicer for every bulk word-index path (#1197).
151
+ *
152
+ * Time-based, not count-based: "yield every N items" is only a bound when the
153
+ * per-item cost is bounded too, and the incremental refresh's per-document cost
154
+ * grows with the corpus (each replacement filters shared posting arrays). The
155
+ * #1197 outage was exactly that shape — 239 stale documents held pi's event loop
156
+ * for seconds between two "every 100 files" checkpoints. Callers combine this
157
+ * The shared deadline is checked at every bounded work unit so abort latency
158
+ * and occupancy are governed by the same monotonic budget.
159
+ */
160
+ function createEmptyWordIndex(truncated) {
161
+ return {
162
+ postings: new Map(),
163
+ docLengths: new PathKeyedMap(wordIndexKey),
164
+ totalTokens: 0,
165
+ docCount: 0,
166
+ truncated,
167
+ forward: new PathKeyedMap(wordIndexKey),
168
+ fileMtimes: new PathKeyedMap(wordIndexKey),
169
+ fileSizes: new PathKeyedMap(wordIndexKey),
170
+ };
171
+ }
172
+ function indexWordLine(index, filePath, line, lineNumber, tokenLineCounts) {
173
+ const lineTokens = tokenizeLine(line);
174
+ const seenOnLine = new Set();
175
+ for (const token of lineTokens) {
176
+ if (seenOnLine.has(token))
177
+ continue;
178
+ seenOnLine.add(token);
179
+ const arr = index.postings.get(token);
180
+ if (arr)
181
+ arr.push({ file: filePath, line: lineNumber });
182
+ else
183
+ index.postings.set(token, [{ file: filePath, line: lineNumber }]);
184
+ tokenLineCounts.set(token, (tokenLineCounts.get(token) ?? 0) + 1);
185
+ }
186
+ return lineTokens.length;
187
+ }
188
+ function finishWordIndexDocument(index, doc, docLength, tokenLineCounts) {
189
+ index.docLengths.set(doc.path, docLength);
190
+ index.forward?.set(doc.path, tokenLineCounts);
191
+ index.fileMtimes.set(doc.path, doc.mtimeMs ?? 0);
192
+ index.fileSizes.set(doc.path, doc.size ?? Buffer.byteLength(doc.content, "utf-8"));
193
+ index.totalTokens += docLength;
194
+ index.docCount += 1;
195
+ }
196
+ function indexWordDocument(index, doc) {
197
+ const lines = doc.content.split(/\r?\n/);
198
+ const tokenLineCounts = new Map();
199
+ let docLength = 0;
200
+ for (let i = 0; i < lines.length; i += 1) {
201
+ docLength += indexWordLine(index, doc.path, lines[i], i + 1, tokenLineCounts);
202
+ }
203
+ finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
204
+ }
103
205
  export function buildWordIndex(files) {
104
- const postings = new Map();
105
- const docLengths = new PathKeyedMap(wordIndexKey);
106
- const forward = new PathKeyedMap(wordIndexKey);
107
- const fileMtimes = new PathKeyedMap(wordIndexKey);
108
- let totalTokens = 0;
109
- for (const { path: filePath, content, mtimeMs } of files) {
110
- const lines = content.split(/\r?\n/);
111
- let docLength = 0;
206
+ const index = createEmptyWordIndex(files.truncated ?? false);
207
+ for (const doc of files)
208
+ indexWordDocument(index, doc);
209
+ return index;
210
+ }
211
+ /**
212
+ * Cooperative production builder for bulk/cold paths. It produces the exact
213
+ * same index as {@link buildWordIndex}, but time-slices both between documents
214
+ * and within a large document so startup warmup cannot monopolize pi's TUI
215
+ * event loop (#1197). The index is private until this resolves, so a superseded
216
+ * build never publishes a partial replacement.
217
+ */
218
+ export async function buildWordIndexAsync(files, shouldContinue = () => true) {
219
+ const index = createEmptyWordIndex(files.truncated ?? false);
220
+ const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
221
+ for (const doc of files) {
222
+ if (!shouldContinue())
223
+ throw new Error("word index build superseded");
224
+ const lines = doc.content.split(/\r?\n/);
112
225
  const tokenLineCounts = new Map();
226
+ let docLength = 0;
113
227
  for (let i = 0; i < lines.length; i += 1) {
114
- const lineTokens = tokenizeLine(lines[i]);
115
- docLength += lineTokens.length;
116
- const seenOnLine = new Set();
117
- for (const token of lineTokens) {
118
- if (seenOnLine.has(token))
119
- continue;
120
- seenOnLine.add(token);
121
- const arr = postings.get(token);
122
- if (arr)
123
- arr.push({ file: filePath, line: i + 1 });
124
- else
125
- postings.set(token, [{ file: filePath, line: i + 1 }]);
126
- tokenLineCounts.set(token, (tokenLineCounts.get(token) ?? 0) + 1);
228
+ const line = lines[i];
229
+ docLength += indexWordLine(index, doc.path, line, i + 1, tokenLineCounts);
230
+ if (line.length >= WORD_INDEX_LONG_LINE_YIELD_CHARS ||
231
+ deadline.expired()) {
232
+ await yieldIfOverBudget(deadline);
233
+ if (!shouldContinue())
234
+ throw new Error("word index build superseded");
127
235
  }
128
236
  }
129
- docLengths.set(filePath, docLength);
130
- forward.set(filePath, tokenLineCounts);
131
- fileMtimes.set(filePath, mtimeMs ?? 0);
132
- totalTokens += docLength;
237
+ finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
238
+ if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
239
+ if (!shouldContinue())
240
+ throw new Error("word index build superseded");
241
+ }
133
242
  }
134
- return {
135
- postings,
136
- docLengths,
137
- totalTokens,
138
- docCount: files.length,
139
- truncated: files.truncated ?? false,
140
- forward,
141
- fileMtimes,
142
- };
243
+ return index;
143
244
  }
144
245
  /**
145
246
  * Remove `filePath`'s postings/docLength/forward entry from `index` in place,
@@ -175,6 +276,7 @@ export function removeWordIndexDocument(index, filePath) {
175
276
  index.docLengths.delete(filePath);
176
277
  index.forward.delete(filePath);
177
278
  index.fileMtimes.delete(filePath);
279
+ index.fileSizes.delete(filePath);
178
280
  index.totalTokens -= docLength;
179
281
  index.docCount = Math.max(0, index.docCount - 1);
180
282
  return true;
@@ -238,6 +340,117 @@ export function updateWordIndexDocument(index, doc) {
238
340
  // legal on-disk mtime (SOURCE_DATE_EPOCH=0, archive extraction) and would
239
341
  // collide, leaving such a file never re-tokenized (#958 review F2).
240
342
  index.fileMtimes.set(doc.path, -1);
343
+ // Size is likewise recorded for the #1105 mtime+size refresh gate. The -1
344
+ // mtime already forces a re-read next session, so this value only keeps the
345
+ // map dense (parallel to fileMtimes); store the real byte length so a
346
+ // deserialize→reserialize round-trip before any refresh carries a truthful
347
+ // size rather than a placeholder.
348
+ index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
349
+ index.totalTokens += docLength;
350
+ index.docCount += 1;
351
+ return true;
352
+ }
353
+ async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
354
+ if (!index.forward)
355
+ return undefined;
356
+ const tokenLineCounts = index.forward.get(filePath);
357
+ if (!tokenLineCounts)
358
+ return undefined;
359
+ const removedKey = wordIndexKey(filePath);
360
+ const postings = new Map();
361
+ const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
362
+ for (const token of tokenLineCounts.keys()) {
363
+ if (!shouldContinue())
364
+ throw new Error("word index refresh superseded");
365
+ const arr = index.postings.get(token);
366
+ if (!arr)
367
+ continue;
368
+ const next = [];
369
+ for (const hit of arr) {
370
+ if (wordIndexKey(hit.file) !== removedKey)
371
+ next.push(hit);
372
+ if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
373
+ if (!shouldContinue())
374
+ throw new Error("word index refresh superseded");
375
+ }
376
+ }
377
+ postings.set(token, next.length > 0 ? next : undefined);
378
+ }
379
+ return { postings, docLength: index.docLengths.get(filePath) ?? 0 };
380
+ }
381
+ function commitWordIndexDocumentRemoval(index, filePath, staged) {
382
+ for (const [token, hits] of staged.postings) {
383
+ if (hits)
384
+ index.postings.set(token, hits);
385
+ else
386
+ index.postings.delete(token);
387
+ }
388
+ index.docLengths.delete(filePath);
389
+ index.forward?.delete(filePath);
390
+ index.fileMtimes.delete(filePath);
391
+ index.fileSizes.delete(filePath);
392
+ index.totalTokens -= staged.docLength;
393
+ index.docCount = Math.max(0, index.docCount - 1);
394
+ }
395
+ /** Cooperative, atomically-published removal used only by bulk refresh. */
396
+ export async function removeWordIndexDocumentAsync(index, filePath, shouldContinue = () => true) {
397
+ const staged = await stageWordIndexDocumentRemoval(index, filePath, shouldContinue);
398
+ if (!staged)
399
+ return false;
400
+ if (!shouldContinue())
401
+ throw new Error("word index refresh superseded");
402
+ commitWordIndexDocumentRemoval(index, filePath, staged);
403
+ return true;
404
+ }
405
+ /** Cooperative replacement whose old/new state is committed without an await. */
406
+ export async function updateWordIndexDocumentAsync(index, doc, shouldContinue = () => true) {
407
+ if (!index.forward)
408
+ return false;
409
+ const removal = index.forward.has(doc.path)
410
+ ? await stageWordIndexDocumentRemoval(index, doc.path, shouldContinue)
411
+ : undefined;
412
+ if (index.forward.has(doc.path) && !removal)
413
+ return false;
414
+ const perTokenHits = new Map();
415
+ let docLength = 0;
416
+ const lines = doc.content.split(/\r?\n/);
417
+ await forEachCooperatively(lines, (line, i) => {
418
+ const lineTokens = tokenizeLine(line);
419
+ docLength += lineTokens.length;
420
+ const seenOnLine = new Set();
421
+ for (const token of lineTokens) {
422
+ if (seenOnLine.has(token))
423
+ continue;
424
+ seenOnLine.add(token);
425
+ const arr = perTokenHits.get(token);
426
+ if (arr)
427
+ arr.push(i + 1);
428
+ else
429
+ perTokenHits.set(token, [i + 1]);
430
+ }
431
+ }, {
432
+ budgetMs: WORD_INDEX_BUILD_YIELD_BUDGET_MS,
433
+ shouldContinue,
434
+ abortMessage: "word index refresh superseded",
435
+ });
436
+ if (!shouldContinue())
437
+ throw new Error("word index refresh superseded");
438
+ if (removal)
439
+ commitWordIndexDocumentRemoval(index, doc.path, removal);
440
+ const tokenLineCounts = new Map();
441
+ for (const [token, lineNumbers] of perTokenHits) {
442
+ tokenLineCounts.set(token, lineNumbers.length);
443
+ const hits = lineNumbers.map((line) => ({ file: doc.path, line }));
444
+ const arr = index.postings.get(token);
445
+ if (arr)
446
+ arr.push(...hits);
447
+ else
448
+ index.postings.set(token, hits);
449
+ }
450
+ index.docLengths.set(doc.path, docLength);
451
+ index.forward.set(doc.path, tokenLineCounts);
452
+ index.fileMtimes.set(doc.path, -1);
453
+ index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
241
454
  index.totalTokens += docLength;
242
455
  index.docCount += 1;
243
456
  return true;
@@ -262,7 +475,7 @@ export const WORD_INDEX_MAX_BYTES = 512 * 1024;
262
475
  * `shouldContinue` lets a session-scoped caller abort early (session
263
476
  * superseded) without this module knowing about RuntimeCoordinator.
264
477
  */
265
- export async function collectWordIndexDocs(root, shouldContinue = () => true) {
478
+ export async function collectWordIndexDocs(root, shouldContinue = () => true, preflightFiles) {
266
479
  const { collectSourceFilesAsync } = await import("./source-filter.js");
267
480
  // #747 hardening: pass the cap INTO the walk — without it,
268
481
  // `collectSourceFilesAsync` defaults to an unbounded traversal and the
@@ -272,29 +485,39 @@ export async function collectWordIndexDocs(root, shouldContinue = () => true) {
272
485
  // visited-entry budget (DEFAULT_MAX_SCAN_ENTRIES), so a mixed tree with few
273
486
  // source files among a huge pile of non-source files can't force a
274
487
  // full-tree walk either; an index over the truncated list is acceptable.
275
- const maxFiles = getWordIndexMaxFilesDerived(root);
488
+ const maxFiles = preflightFiles?.length ?? getWordIndexMaxFilesDerived(root);
276
489
  // #894 review: prioritize code kinds within the cap — with broadened
277
490
  // enumeration, thousands of data/doc files (locale JSON, fixtures, …)
278
491
  // ahead of the code dirs in walk order could exhaust `maxFiles` and evict
279
492
  // real source files from the index entirely (DOC_FILE_PENALTY can't
280
493
  // rescue a file that never made the slice).
281
- const files = await collectSourceFilesAsync(root, {
282
- maxFiles,
283
- prioritizeCodeKinds: true,
284
- });
285
- const truncated = files.length === maxFiles;
494
+ const files = preflightFiles
495
+ ? preflightFiles.map((file) => file.path)
496
+ : await collectSourceFilesAsync(root, {
497
+ maxFiles,
498
+ prioritizeCodeKinds: true,
499
+ });
500
+ const truncated = preflightFiles?.truncated ?? files.length === maxFiles;
286
501
  const docs = Object.assign([], { truncated, skipped: 0 });
287
502
  if (!shouldContinue())
288
503
  return docs;
289
- let processed = 0;
504
+ const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
290
505
  for (const file of files.slice(0, maxFiles)) {
506
+ if (!shouldContinue())
507
+ return docs;
291
508
  try {
509
+ // A preflight list saves the second full WALK, but its metadata may be
510
+ // stale by the time this rebuild reads the file. Re-stat each document at
511
+ // content-read time so the stored freshness stamp describes the bytes we
512
+ // actually index and the next incremental refresh cannot miss a change in
513
+ // the walk-to-read window (#1302).
292
514
  const stat = fs.statSync(file);
293
515
  if (stat.size <= WORD_INDEX_MAX_BYTES) {
294
516
  docs.push({
295
517
  path: file,
296
518
  content: fs.readFileSync(file, "utf-8"),
297
519
  mtimeMs: stat.mtimeMs,
520
+ size: stat.size,
298
521
  });
299
522
  }
300
523
  else {
@@ -308,30 +531,66 @@ export async function collectWordIndexDocs(root, shouldContinue = () => true) {
308
531
  // unreadable / vanished file — skip, but count it (see above).
309
532
  docs.skipped += 1;
310
533
  }
311
- if (++processed % 100 === 0) {
312
- await new Promise((resolve) => setImmediate(resolve));
534
+ if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
313
535
  if (!shouldContinue())
314
536
  return docs;
315
537
  }
316
538
  }
317
539
  return docs;
318
540
  }
541
+ // Node's fs.promises.stat runs on libuv's threadpool (default 4 slots), so
542
+ // real parallelism tops out there; the surplus workers are queue depth that
543
+ // keeps the pool saturated without starving other threadpool consumers.
544
+ export const WORD_INDEX_STAT_CONCURRENCY = 8;
319
545
  const WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD = 0.3;
546
+ // A ratio alone misclassifies one stale file in a three-file project as dense.
547
+ // Below this absolute floor, per-document replacement is bounded and cheaper
548
+ // than a second full walk/read even when the percentage is high (#1197).
549
+ const WORD_INDEX_DENSE_REFRESH_MIN_DOCUMENTS = 32;
550
+ /**
551
+ * Cost of one file's stat+read+decode expressed in tokenizer-token units, so
552
+ * the work model below can compare a path that re-reads EVERY file (full
553
+ * rebuild) against one that re-reads only the stale files (incremental) in a
554
+ * single currency. One read ≈ 300 tokens at the ~1 µs/token measured below.
555
+ * Order-of-magnitude by design — it only has to keep a tiny-vocabulary corpus
556
+ * (where posting scans are nearly free) from being sent to a full rebuild that
557
+ * re-reads everything to save a handful of array filters.
558
+ */
559
+ const WORD_INDEX_FILE_READ_TOKEN_COST = 300;
560
+ /**
561
+ * Cost of scanning ONE posting-array element (the `wordIndexKey` compare in
562
+ * {@link removeWordIndexDocument}'s filter) relative to tokenizing one token,
563
+ * so both sides of the model below are denominated in tokens. Measured on this
564
+ * repository's own 2,062-document corpus: 213 ns per element scan against 979 ns
565
+ * per token — and the resulting predicted crossover (23 stale documents) lands
566
+ * on the directly measured one (~21, from 97.8 ms per document replacement
567
+ * against a 1,436 ms rebuild + re-read). The synthetic high-df corpus in the
568
+ * #1197 probe gives 0.09, so this is the conservative (incremental-favouring)
569
+ * end of the observed range.
570
+ */
571
+ const WORD_INDEX_POSTING_SCAN_TOKEN_COST = 0.2;
320
572
  /**
321
573
  * Refresh a serializer-v2 index from the current bounded source-file set.
322
- * The walk/stat pass is cheap; only stale/new documents are read and tokenized.
323
- * Throws when the index cannot be updated safely so callers can full-rebuild.
574
+ * The walk/stat pass is cheap; only sparse stale/new documents are read and
575
+ * tokenized. Expected dense/legacy states return `full-required` BEFORE any
576
+ * mutation; unexpected corruption or supersession still throws.
324
577
  */
325
- export async function refreshWordIndexIncrementally(index, root, shouldContinue = () => true) {
326
- if (!index.forward || !index.fileMtimes) {
327
- throw new Error("word index lacks incremental metadata");
578
+ export async function refreshWordIndexIncrementally(index, root, shouldContinue = () => true, options = {}) {
579
+ if (!index.forward || !index.fileMtimes || !index.fileSizes) {
580
+ return {
581
+ mode: "full-required",
582
+ reason: "missing-incremental-metadata",
583
+ timings: { sourceWalkMs: 0, statWalkMs: 0, refreshReadsMs: 0 },
584
+ };
328
585
  }
329
586
  const { collectSourceFilesAsync } = await import("./source-filter.js");
330
587
  const maxFiles = getWordIndexMaxFilesDerived(root);
588
+ const sourceWalkStartMs = Date.now();
331
589
  const walked = await collectSourceFilesAsync(root, {
332
590
  maxFiles,
333
591
  prioritizeCodeKinds: true,
334
592
  });
593
+ const sourceWalkMs = Date.now() - sourceWalkStartMs;
335
594
  if (!shouldContinue())
336
595
  throw new Error("word index refresh superseded");
337
596
  // This set-difference must run in the SAME normalized key space the path
@@ -347,17 +606,53 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
347
606
  // `wordIndexKey` keeps build/edit/refresh convergent; `current`'s value
348
607
  // retains the raw walk path for the stat/read and for the display key.
349
608
  const current = new Map();
350
- for (const file of walked) {
351
- try {
352
- const stat = fs.statSync(file);
353
- if (stat.size <= WORD_INDEX_MAX_BYTES) {
354
- current.set(wordIndexKey(file), { path: file, mtimeMs: stat.mtimeMs });
609
+ const statWalkStartMs = Date.now();
610
+ const statFile = options.statFile ?? ((file) => fs.promises.stat(file));
611
+ const requestedConcurrency = options.statConcurrency ?? WORD_INDEX_STAT_CONCURRENCY;
612
+ const statConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0
613
+ ? Math.max(1, Math.floor(requestedConcurrency))
614
+ : WORD_INDEX_STAT_CONCURRENCY;
615
+ const statResults = new Array(walked.length);
616
+ let cursor = 0;
617
+ let superseded = false;
618
+ const worker = async () => {
619
+ while (true) {
620
+ if (!shouldContinue()) {
621
+ superseded = true;
622
+ return;
623
+ }
624
+ const slot = cursor++;
625
+ if (slot >= walked.length)
626
+ return;
627
+ const file = walked[slot];
628
+ try {
629
+ const stat = await statFile(file);
630
+ if (stat.size <= WORD_INDEX_MAX_BYTES) {
631
+ statResults[slot] = {
632
+ path: file,
633
+ mtimeMs: stat.mtimeMs,
634
+ size: stat.size,
635
+ };
636
+ }
637
+ }
638
+ catch {
639
+ // A rejection has statSync parity: the file is simply absent.
355
640
  }
356
641
  }
357
- catch {
358
- // A file vanishing between walk and stat is simply absent.
359
- }
642
+ };
643
+ await Promise.all(Array.from({ length: Math.min(statConcurrency, walked.length) }, () => worker()));
644
+ if (superseded || !shouldContinue()) {
645
+ throw new Error("word index refresh superseded");
646
+ }
647
+ const statWalkMs = Date.now() - statWalkStartMs;
648
+ for (const result of statResults) {
649
+ if (result)
650
+ current.set(wordIndexKey(result.path), result);
360
651
  }
652
+ const timings = { sourceWalkMs, statWalkMs, refreshReadsMs: 0 };
653
+ const preflightFiles = Object.assign([...current.values()], {
654
+ truncated: walked.length === maxFiles,
655
+ });
361
656
  const oldSet = new Set([...index.docLengths.keys()].map(wordIndexKey));
362
657
  let changedSet = 0;
363
658
  for (const key of oldSet)
@@ -368,14 +663,92 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
368
663
  changedSet++;
369
664
  const denominator = Math.max(oldSet.size, current.size, 1);
370
665
  if (changedSet / denominator > WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD) {
371
- throw new Error("word index file-set churn exceeds incremental threshold");
666
+ return {
667
+ mode: "full-required",
668
+ reason: "file-set-churn",
669
+ preflightFiles,
670
+ timings,
671
+ };
672
+ }
673
+ // Replacing one document filters every shared posting array for that
674
+ // document's tokens. That is excellent for a sparse edit, but repeating it
675
+ // for most of the corpus becomes effectively quadratic. Decide the dense
676
+ // transition BEFORE dropping or editing anything so the old index remains a
677
+ // valid fallback until a separately-built full replacement is ready (#1197).
678
+ let staleDocuments = 0;
679
+ for (const { path: file, mtimeMs, size } of current.values()) {
680
+ if (index.fileMtimes.get(file) !== mtimeMs ||
681
+ (index.fileSizes.get(file) ?? -1) !== size) {
682
+ staleDocuments += 1;
683
+ }
684
+ }
685
+ // Density alone is NOT a bound (#1197 review finding 1): the per-document cost
686
+ // GROWS with the corpus, so a stale set comfortably under any fixed ratio (or
687
+ // any fixed absolute ceiling) still costs orders of magnitude more than a
688
+ // rebuild on a big enough repository. 800 documents with 239 stale — 29.875%,
689
+ // just under the ratio — measured 90.6 s with a 39.6 s synchronous block
690
+ // against 1.3 s / 10.6 ms for a full build, and at the 6,000-file cap up to
691
+ // 1,799 stale documents would have stayed on that path.
692
+ //
693
+ // So compare estimated WORK, both sides denominated in tokenizer tokens:
694
+ // incremental ≈ stale x (docTokens x postingLength x scanCost + readCost)
695
+ // full ≈ totalTokens + corpusSize x readCost
696
+ // This is self-bounding in a way no constant is: the incremental path is taken
697
+ // only while its total estimated cost stays under ONE full rebuild, so the
698
+ // worst case is "about as expensive as rebuilding", whatever the corpus size.
699
+ //
700
+ // Both statistics come from cheap metadata passes — one over `postings`
701
+ // (distinct tokens, reading only `.length`) and one over `forward` (documents,
702
+ // reading only `.size`). Neither touches a posting ELEMENT, which is the work
703
+ // this decision exists to avoid.
704
+ let postingEntries = 0;
705
+ let weightedPostingEntries = 0;
706
+ for (const hits of index.postings.values()) {
707
+ postingEntries += hits.length;
708
+ weightedPostingEntries += hits.length * hits.length;
372
709
  }
710
+ // The mean posting-array length weighted by OCCURRENCES, not the plain mean:
711
+ // a document's tokens are drawn from the frequency distribution, so the arrays
712
+ // a removal actually filters skew hard toward the high-document-frequency
713
+ // tokens. The unweighted mean (dominated by the long tail of df=1 tokens)
714
+ // underestimates the real cost by an order of magnitude.
715
+ const expectedPostingLength = postingEntries > 0 ? weightedPostingEntries / postingEntries : 0;
716
+ let distinctTokenEntries = 0;
717
+ for (const tokenLineCounts of index.forward.values()) {
718
+ distinctTokenEntries += tokenLineCounts.size;
719
+ }
720
+ const expectedDocumentTokens = index.docCount > 0 ? distinctTokenEntries / index.docCount : 0;
721
+ const estimatedIncrementalWork = staleDocuments *
722
+ (expectedDocumentTokens *
723
+ expectedPostingLength *
724
+ WORD_INDEX_POSTING_SCAN_TOKEN_COST +
725
+ WORD_INDEX_FILE_READ_TOKEN_COST);
726
+ const estimatedFullRebuildWork = index.totalTokens + current.size * WORD_INDEX_FILE_READ_TOKEN_COST;
727
+ // The document-count floor guards the RATIO test only — its documented job is
728
+ // that "one stale file in a three-file project" is 33% but not dense. It must
729
+ // NOT gate the work test: a floor is another constant, and letting one
730
+ // suppress the work comparison would reintroduce exactly the unbounded-on-the
731
+ // -other-axis shape (31 documents at 98 ms each on this repository's own
732
+ // corpus, and worse as the corpus grows). The work test needs no floor — on a
733
+ // small project a full rebuild is cheap, so it never fires there.
734
+ if ((staleDocuments >= WORD_INDEX_DENSE_REFRESH_MIN_DOCUMENTS &&
735
+ staleDocuments / Math.max(current.size, 1) >
736
+ WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD) ||
737
+ estimatedIncrementalWork > estimatedFullRebuildWork) {
738
+ return {
739
+ mode: "full-required",
740
+ reason: "stale-document-churn",
741
+ preflightFiles,
742
+ timings,
743
+ };
744
+ }
745
+ const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
373
746
  let dropped = 0;
374
747
  for (const key of oldSet) {
375
748
  if (!current.has(key)) {
376
749
  // `key` is already folded; removeWordIndexDocument re-folds it
377
750
  // idempotently via the PathKeyedMap, so the drop hits the right entry.
378
- if (!removeWordIndexDocument(index, key)) {
751
+ if (!(await removeWordIndexDocumentAsync(index, key, shouldContinue))) {
379
752
  throw new Error(`failed to drop word-index document: ${key}`);
380
753
  }
381
754
  dropped++;
@@ -383,9 +756,17 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
383
756
  }
384
757
  let refreshed = 0;
385
758
  let skipped = 0;
386
- let processed = 0;
387
- for (const { path: file, mtimeMs } of current.values()) {
388
- if (index.fileMtimes.get(file) !== mtimeMs) {
759
+ const refreshReadsStartMs = Date.now();
760
+ for (const { path: file, mtimeMs, size } of current.values()) {
761
+ // #1105: mtime-first, size-second freshness — mtime alone is a stale
762
+ // signal (mtime-preserving content changes serve stale identifiers to
763
+ // symbol_search). Size is free (the stat above already read it) and
764
+ // catches every content change that alters byte length. Both come from the
765
+ // SAME stat, so a mismatch on EITHER axis re-reads. `?? -1` makes a file
766
+ // absent from the (possibly legacy, pre-#1105) size map always count as
767
+ // changed → one-time re-read that repopulates the size, the safe direction.
768
+ if (index.fileMtimes.get(file) !== mtimeMs ||
769
+ (index.fileSizes.get(file) ?? -1) !== size) {
389
770
  // A file the walk/stat pass saw can still fail to read here — a
390
771
  // transient exclusive lock (antivirus, an editor, a build step) or a
391
772
  // file that vanished in the interim. Match collectWordIndexDocs'
@@ -400,18 +781,27 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
400
781
  skipped++;
401
782
  continue;
402
783
  }
403
- if (!updateWordIndexDocument(index, { path: file, content })) {
784
+ if (!(await updateWordIndexDocumentAsync(index, { path: file, content }, shouldContinue))) {
404
785
  throw new Error(`failed to refresh word-index document: ${file}`);
405
786
  }
787
+ // updateWordIndexDocument stamps mtime=-1 (per-edit convention); the
788
+ // refresh path KNOWS the real on-disk stat, so overwrite both axes with
789
+ // the true values so this document is not needlessly re-read next pass.
406
790
  index.fileMtimes.set(file, mtimeMs);
791
+ index.fileSizes.set(file, size);
407
792
  refreshed++;
408
793
  }
409
- if (++processed % 100 === 0) {
410
- await new Promise((resolve) => setImmediate(resolve));
794
+ // Same OR as the stat loop. This is the loop the #1197 outage actually
795
+ // blocked in: replacing one document costs more the larger the corpus is,
796
+ // so a count-only checkpoint let 100 replacements run back to back for
797
+ // seconds. The gate above keeps the stale set sparse; this keeps even a
798
+ // sparse-but-expensive set off the event loop.
799
+ if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
411
800
  if (!shouldContinue())
412
801
  throw new Error("word index refresh superseded");
413
802
  }
414
803
  }
804
+ timings.refreshReadsMs = Date.now() - refreshReadsStartMs;
415
805
  index.truncated = walked.length === maxFiles;
416
806
  return {
417
807
  mode: "incremental",
@@ -419,16 +809,212 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
419
809
  dropped,
420
810
  skipped,
421
811
  reused: current.size - refreshed - skipped,
812
+ timings,
813
+ };
814
+ }
815
+ export const WORD_INDEX_QUERY_FILTER_KEYS = ["lang", "file", "ext"];
816
+ /** Thrown by {@link parseWordIndexQuery}/{@link buildWordIndexQueryFilter} for
817
+ * an unrecognized `key:` prefix or an unrecognized `lang:` value — a query
818
+ * typo fails loudly with the supported list instead of silently degrading
819
+ * into a literal search term (#1450 acceptance criterion). */
820
+ export class WordIndexQueryError extends Error {
821
+ constructor(message) {
822
+ super(message);
823
+ this.name = "WordIndexQueryError";
824
+ }
825
+ }
826
+ const WORD_INDEX_FILTER_TOKEN_RE = /^(-)?([A-Za-z][A-Za-z0-9_-]*):(.+)$/;
827
+ /**
828
+ * Split a word-index query string into plain search terms and `key:value`
829
+ * prefix filters (#1450). Whitespace-delimited, one regex per token — no
830
+ * quoting scheme invented here, matching the pre-existing query path (which
831
+ * never supported quoting either). A `-` immediately before a recognized
832
+ * `key:` negates that filter; a bare `-term` (no colon) is left as an
833
+ * ordinary token, unchanged from prior behavior — `tokenizeLine` already
834
+ * strips the leading `-` when it extracts identifiers.
835
+ *
836
+ * An unrecognized `key:` (e.g. `type:foo`) throws {@link WordIndexQueryError}
837
+ * naming the supported prefixes, rather than falling through as a literal
838
+ * term — a typo must fail loudly, never silently rank as if the filter never
839
+ * existed. An empty value (`lang:`) is not treated as a filter token at all
840
+ * (kept as a term) since there is nothing to filter by.
841
+ */
842
+ export function parseWordIndexQuery(query) {
843
+ const filters = [];
844
+ const termParts = [];
845
+ for (const raw of query.split(/\s+/)) {
846
+ if (!raw)
847
+ continue;
848
+ const match = raw.match(WORD_INDEX_FILTER_TOKEN_RE);
849
+ if (!match) {
850
+ termParts.push(raw);
851
+ continue;
852
+ }
853
+ const [, negation, keyRaw, value] = match;
854
+ const key = keyRaw.toLowerCase();
855
+ if (!value) {
856
+ termParts.push(raw);
857
+ continue;
858
+ }
859
+ if (!WORD_INDEX_QUERY_FILTER_KEYS.includes(key)) {
860
+ // Not a recognized filter key. Ordinary search terms legitimately
861
+ // contain colons (std::vector, error:foo, http://…, C:\ paths, a
862
+ // TODO:tag), and the old tokenizer searched them fine — a hard
863
+ // error here is a usability regression, not typo protection. The
864
+ // token passes through as a plain term; the loud-failure contract
865
+ // survives where it is unambiguous: a KNOWN key with a bad value
866
+ // (lang:notalang) still throws.
867
+ termParts.push(raw);
868
+ continue;
869
+ }
870
+ filters.push({
871
+ key: key,
872
+ value,
873
+ negated: negation === "-",
874
+ });
875
+ }
876
+ return { terms: termParts.join(" "), filters };
877
+ }
878
+ /** `lang:X` → the extension set for FileKind X (case-insensitive), drawn
879
+ * exclusively from `KIND_EXTENSIONS` (clients/file-kinds.ts) — the single
880
+ * source of truth (#894 invariant: no second, hand-maintained language list).
881
+ * An unrecognized kind throws {@link WordIndexQueryError} listing the accepted
882
+ * kinds (KIND_EXTENSIONS' own keys), the same loud-failure contract as an
883
+ * unsupported `key:` prefix. */
884
+ function resolveLangExtensions(value) {
885
+ const kind = value.toLowerCase();
886
+ const extensions = KIND_EXTENSIONS[kind];
887
+ if (!extensions) {
888
+ const known = Object.keys(KIND_EXTENSIONS).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(", ");
889
+ throw new WordIndexQueryError(`Unknown lang: "${value}" in word-index query — supported languages: ${known}.`);
890
+ }
891
+ return extensions;
892
+ }
893
+ /** `ext:ts` / `ext:.ts` → normalized to a single leading-dot, lowercased form for extname comparison. */
894
+ function normalizeExtFilterValue(value) {
895
+ return `.${value.replace(/^\.+/, "").toLowerCase()}`;
896
+ }
897
+ function resolveWordIndexFilter(filter) {
898
+ if (filter.key === "lang") {
899
+ const extensions = resolveLangExtensions(filter.value);
900
+ return {
901
+ key: filter.key,
902
+ negated: filter.negated,
903
+ test: (file) => extensions.includes(path.extname(file).toLowerCase()),
904
+ };
905
+ }
906
+ if (filter.key === "ext") {
907
+ const normalized = normalizeExtFilterValue(filter.value);
908
+ return {
909
+ key: filter.key,
910
+ negated: filter.negated,
911
+ test: (file) => path.extname(file).toLowerCase() === normalized,
912
+ };
913
+ }
914
+ // "file": substring match against the index's own normalized display path
915
+ // (#1450 — see the doc comment on {@link buildWordIndexQueryFilter} for the
916
+ // case-behavior choice). Both sides fold through `wordIndexKey` so the
917
+ // comparison matches the SAME normalization the index's own path keys use.
918
+ const needle = wordIndexKey(filter.value);
919
+ return {
920
+ key: filter.key,
921
+ negated: filter.negated,
922
+ test: (_file, displayPath) => displayPath.includes(needle),
923
+ };
924
+ }
925
+ /**
926
+ * Build a pre-ranking predicate from parsed query filters (#1450). Composes
927
+ * (AND) with `searchWordIndex`'s pre-existing `fileFilter` option (#771) —
928
+ * `symbol_search`'s structured `paths`/`lang` params and this query's inline
929
+ * filters both apply when both are present.
930
+ *
931
+ * Semantics: multiple positive filters of the SAME key OR together
932
+ * (`lang:ts lang:go` matches either); filters of DIFFERENT keys AND
933
+ * (`lang:ts file:clients/` requires both); a negated filter always subtracts,
934
+ * regardless of any positive filter sharing its key.
935
+ *
936
+ * All filters are RESOLVED EAGERLY (extension lookups, `WordIndexQueryError`
937
+ * thrown) here at build time — before any candidate file is tested — so a bad
938
+ * `lang:` value fails once, loudly, rather than per-file during the scoring
939
+ * loop.
940
+ *
941
+ * `file:` case behavior: matched via `wordIndexKey` (this module's single
942
+ * path-key normalizer, `normalizeEphemeralMapKey`) on BOTH the candidate path
943
+ * and the filter value — slash-folded everywhere, and case-folded ONLY on
944
+ * win32 (Windows' case-insensitive filesystem), preserved on POSIX. This
945
+ * mirrors the SAME normalization the word index's own path-keyed maps already
946
+ * apply (#1025), rather than inventing a second, divergent case rule for this
947
+ * one filter. The candidate path matched is the RAW index-stored path (not
948
+ * cwd-relativized) — the index already stores paths in the form the caller
949
+ * built it with (relative in tests, absolute in the real project walk), and
950
+ * an absolute path's tail always contains its project-relative suffix, so
951
+ * `file:clients/word-index.ts` still matches naturally either way.
952
+ */
953
+ export function buildWordIndexQueryFilter(filters) {
954
+ if (filters.length === 0)
955
+ return undefined;
956
+ const resolved = filters.map(resolveWordIndexFilter);
957
+ const positivesByKey = new Map();
958
+ const negatives = [];
959
+ for (const r of resolved) {
960
+ if (r.negated) {
961
+ negatives.push(r);
962
+ continue;
963
+ }
964
+ const group = positivesByKey.get(r.key) ?? [];
965
+ group.push(r);
966
+ positivesByKey.set(r.key, group);
967
+ }
968
+ return (file) => {
969
+ const displayPath = wordIndexKey(file);
970
+ for (const negative of negatives) {
971
+ if (negative.test(file, displayPath))
972
+ return false;
973
+ }
974
+ for (const group of positivesByKey.values()) {
975
+ if (!group.some((r) => r.test(file, displayPath)))
976
+ return false;
977
+ }
978
+ return true;
422
979
  };
423
980
  }
981
+ function combineFileFilters(a, b) {
982
+ if (!a)
983
+ return b;
984
+ if (!b)
985
+ return a;
986
+ return (file) => a(file) && b(file);
987
+ }
424
988
  /**
425
989
  * Rank files for a query by BM25 over the query's identifier tokens, then apply
426
990
  * priors: demote test/vendor and doc/data files, and boost by graph centrality
427
991
  * when supplied. Returns the top {@link RankOptions.limit} files, highest first.
992
+ *
993
+ * The query string may mix plain terms with `lang:`/`file:`/`ext:` prefix
994
+ * filters (+ `-` negation, #1450) — parsed by {@link parseWordIndexQuery} and
995
+ * applied as a `fileFilter` (composed, AND, with any caller-supplied one)
996
+ * BEFORE scoring, same as the pre-existing `paths`/`lang` options (#771): a
997
+ * surviving file's score is unaffected by filtering. A query with no filter
998
+ * tokens reproduces prior output byte-for-byte.
999
+ *
1000
+ * DF-normalization note: BM25's per-token `idf` is computed from `docFrequency`
1001
+ * over the FULL (unfiltered) postings for that token — `fileFilter` (whether
1002
+ * from `options.fileFilter` or from this query's inline filters) is applied
1003
+ * AFTER `idf` is computed, per file, inside the same loop. This means a
1004
+ * filtered query reuses the GLOBAL, corpus-wide document frequency rather than
1005
+ * recomputing it over just the filtered subset. That is the pre-existing #771
1006
+ * behavior this change does not alter; it is an acceptable approximation
1007
+ * (idf reflects true corpus rarity, not an artifact of the filter) and keeps
1008
+ * filtered/unfiltered scores for the SAME file directly comparable, which is
1009
+ * the property #1450 asks for ("BM25 and centrality stay comparable within
1010
+ * the filtered set").
428
1011
  */
429
1012
  export function searchWordIndex(index, query, options = {}) {
430
1013
  const { demoteTestVendor = true, demoteDocs = true, centrality, limit = 20, fileFilter, } = options;
431
- const queryTokens = [...new Set(tokenizeLine(query))];
1014
+ const parsedQuery = parseWordIndexQuery(query);
1015
+ const queryFilter = buildWordIndexQueryFilter(parsedQuery.filters);
1016
+ const combinedFilter = combineFileFilters(fileFilter, queryFilter);
1017
+ const queryTokens = [...new Set(tokenizeLine(parsedQuery.terms))];
432
1018
  if (queryTokens.length === 0)
433
1019
  return [];
434
1020
  const docCount = index.docCount || 1;
@@ -449,7 +1035,7 @@ export function searchWordIndex(index, query, options = {}) {
449
1035
  const docFrequency = linesByFile.size;
450
1036
  const idf = Math.log(1 + (docCount - docFrequency + 0.5) / (docFrequency + 0.5));
451
1037
  for (const [file, lines] of linesByFile) {
452
- if (fileFilter && !fileFilter(file))
1038
+ if (combinedFilter && !combinedFilter(file))
453
1039
  continue;
454
1040
  const termFrequency = lines.length;
455
1041
  const docLength = index.docLengths.get(file) ?? avgDocLength;
@@ -510,6 +1096,8 @@ export function centralityFromReverseDeps(index, reverseDeps, normalizeKey = (fi
510
1096
  }
511
1097
  return centrality;
512
1098
  }
1099
+ /** Persisted word-index serialization format version. Bump on breaking format changes. */
1100
+ export const WORD_INDEX_FORMAT_VERSION = 2;
513
1101
  export function serializeWordIndex(index) {
514
1102
  const files = [...index.docLengths.keys()];
515
1103
  const fileIndex = new Map();
@@ -533,7 +1121,7 @@ export function serializeWordIndex(index) {
533
1121
  ])
534
1122
  : undefined;
535
1123
  return {
536
- version: 2,
1124
+ version: WORD_INDEX_FORMAT_VERSION,
537
1125
  files,
538
1126
  postings,
539
1127
  docLengths: files.map((file) => index.docLengths.get(file) ?? 0),
@@ -541,12 +1129,13 @@ export function serializeWordIndex(index) {
541
1129
  indexedFileCount: index.docCount,
542
1130
  truncated: index.truncated,
543
1131
  fileMtimes: files.map((file) => index.fileMtimes.get(file) ?? 0),
1132
+ fileSizes: files.map((file) => index.fileSizes.get(file) ?? 0),
544
1133
  forward,
545
1134
  };
546
1135
  }
547
1136
  export function deserializeWordIndex(data) {
548
1137
  if (!data ||
549
- data.version !== 2 ||
1138
+ data.version !== WORD_INDEX_FORMAT_VERSION ||
550
1139
  !Array.isArray(data.files) ||
551
1140
  !Array.isArray(data.postings) ||
552
1141
  !Array.isArray(data.docLengths) ||
@@ -556,8 +1145,17 @@ export function deserializeWordIndex(data) {
556
1145
  }
557
1146
  const docLengths = new PathKeyedMap(wordIndexKey);
558
1147
  const fileMtimes = new PathKeyedMap(wordIndexKey);
1148
+ const fileSizes = new PathKeyedMap(wordIndexKey);
559
1149
  data.files.forEach((file, i) => docLengths.set(file, data.docLengths[i] ?? 0));
560
1150
  data.files.forEach((file, i) => fileMtimes.set(file, data.fileMtimes[i] ?? 0));
1151
+ // #1105: `fileSizes` is optional on the wire (pre-#1105 snapshots omit it).
1152
+ // Only populate when the array is present AND parallel to `files`; otherwise
1153
+ // leave it empty so the refresh gate re-reads every file once to repopulate,
1154
+ // rather than trusting a bogus/misaligned size. Never treated as "current".
1155
+ if (Array.isArray(data.fileSizes) &&
1156
+ data.fileSizes.length === data.files.length) {
1157
+ data.files.forEach((file, i) => fileSizes.set(file, data.fileSizes?.[i] ?? 0));
1158
+ }
561
1159
  const postings = new Map();
562
1160
  for (const [token, flat] of data.postings) {
563
1161
  if (typeof token !== "string" || !Array.isArray(flat))
@@ -603,6 +1201,7 @@ export function deserializeWordIndex(data) {
603
1201
  truncated: data.truncated === true,
604
1202
  forward,
605
1203
  fileMtimes,
1204
+ fileSizes,
606
1205
  };
607
1206
  }
608
1207
  const buildStatuses = new Map();
@@ -650,9 +1249,9 @@ export function triggerBackgroundWordIndexBuild(cwd, dbg, options = {}) {
650
1249
  void (async () => {
651
1250
  const startMs = Date.now();
652
1251
  try {
653
- const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION } = await import("./project-snapshot.js");
1252
+ const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
654
1253
  const docs = await collectWordIndexDocs(key);
655
- const index = buildWordIndex(docs);
1254
+ const index = await buildWordIndexAsync(docs);
656
1255
  const existing = loadProjectSnapshot(key);
657
1256
  const snapshot = existing ?? {
658
1257
  version: PROJECT_SNAPSHOT_VERSION,
@@ -732,7 +1331,7 @@ function getWordIndexPersistScheduler() {
732
1331
  }
733
1332
  async function writeWordIndexSnapshot(cwd, index, dbg) {
734
1333
  try {
735
- const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION } = await import("./project-snapshot.js");
1334
+ const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
736
1335
  const existing = loadProjectSnapshot(cwd);
737
1336
  const snapshot = existing ?? {
738
1337
  version: PROJECT_SNAPSHOT_VERSION,