@herbertgao/pi-extensions 2026.8.13 → 2026.8.15

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 (263) hide show
  1. package/README.md +19 -14
  2. package/THIRD_PARTY_NOTICES.md +0 -1
  3. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +19 -0
  4. package/node_modules/@czottmann/pi-automode/README.md +5 -1
  5. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +126 -13
  6. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
  9. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  10. package/node_modules/@herbertgao/{pi-stash → pi-bark}/LICENSE +4 -5
  11. package/node_modules/@herbertgao/pi-bark/README.md +44 -0
  12. package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
  13. package/node_modules/@herbertgao/pi-bark/package.json +53 -0
  14. package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
  15. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  16. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  17. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  18. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  19. package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
  20. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  22. package/node_modules/@narumitw/pi-btw/README.md +125 -88
  23. package/node_modules/@narumitw/pi-btw/dist/index.ts +140 -45
  24. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
  25. package/node_modules/@narumitw/pi-btw/package.json +5 -8
  26. package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
  27. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +106 -17
  28. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  29. package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
  30. package/node_modules/@tifan/pi-handoff/README.md +36 -13
  31. package/node_modules/@tifan/pi-handoff/package.json +2 -2
  32. package/node_modules/@tifan/pi-handoff/src/index.ts +160 -4
  33. package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
  34. package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
  35. package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
  36. package/node_modules/@tifan/pi-recap/README.md +1 -1
  37. package/node_modules/@tifan/pi-recap/package.json +2 -2
  38. package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
  39. package/node_modules/@tifan/pi-rename/README.md +42 -21
  40. package/node_modules/@tifan/pi-rename/package.json +1 -1
  41. package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
  42. package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
  43. package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
  44. package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
  45. package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
  46. package/node_modules/pi-lens/CHANGELOG.md +320 -0
  47. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
  48. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
  50. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
  51. package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
  52. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
  53. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
  54. package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
  55. package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
  56. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
  57. package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
  58. package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
  59. package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
  60. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
  61. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
  62. package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
  63. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
  64. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
  65. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
  66. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
  67. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
  68. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
  69. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
  70. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
  71. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
  72. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
  73. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
  74. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
  75. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
  76. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
  77. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  85. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
  86. package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
  87. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
  88. package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
  89. package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
  90. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
  91. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
  92. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
  93. package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
  94. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
  95. package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
  96. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
  97. package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
  98. package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
  99. package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
  100. package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
  101. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  102. package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
  103. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
  104. package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
  105. package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
  106. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
  107. package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
  108. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
  109. package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
  110. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
  111. package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
  112. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
  113. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
  114. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  115. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
  116. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
  117. package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
  118. package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
  119. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
  120. package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
  121. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
  122. package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
  123. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
  124. package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
  125. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
  126. package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
  127. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  128. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
  129. package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
  130. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
  131. package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
  132. package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
  133. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
  134. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
  135. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
  136. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
  137. package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
  138. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
  139. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
  140. package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
  141. package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
  142. package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
  143. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
  144. package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
  145. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
  146. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
  147. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
  148. package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
  149. package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
  150. package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
  151. package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
  152. package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
  153. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
  154. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
  155. package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
  156. package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
  157. package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
  158. package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
  159. package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
  160. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
  161. package/node_modules/pi-lens/dist/index.js +11243 -6651
  162. package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
  163. package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
  164. package/node_modules/pi-lens/dist/mcp/server.js +6 -1
  165. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
  166. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
  167. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
  168. package/node_modules/pi-lens/docs/agent-guide.md +4 -2
  169. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
  170. package/node_modules/pi-lens/docs/astplayground.md +26 -17
  171. package/node_modules/pi-lens/package.json +5 -1
  172. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  173. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  174. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  175. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  176. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  177. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  178. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  179. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  180. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  181. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  182. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  183. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  184. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  185. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  186. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  187. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  188. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  189. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  190. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  191. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  192. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  193. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  194. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  195. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  196. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  197. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  198. package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  199. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  200. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  201. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  202. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  203. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  204. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  205. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  206. package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  207. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  208. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  209. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  210. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  211. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  212. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  213. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  214. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  215. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
  216. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  217. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  218. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  219. package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  220. package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  221. package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  222. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  223. package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  224. package/node_modules/pi-mcp-adapter/CHANGELOG.md +35 -0
  225. package/node_modules/pi-mcp-adapter/README.md +5 -2
  226. package/node_modules/pi-mcp-adapter/cli.js +4 -4
  227. package/node_modules/pi-mcp-adapter/commands.ts +1 -1
  228. package/node_modules/pi-mcp-adapter/config.ts +1 -0
  229. package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
  230. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  231. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
  232. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
  233. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
  234. package/node_modules/pi-mcp-adapter/dist/types.d.ts +3 -1
  235. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  236. package/node_modules/pi-mcp-adapter/index.ts +90 -6
  237. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +124 -32
  238. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
  239. package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
  240. package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
  241. package/node_modules/pi-mcp-adapter/package.json +1 -1
  242. package/node_modules/pi-mcp-adapter/proxy-modes.ts +62 -22
  243. package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
  244. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -0
  245. package/node_modules/pi-mcp-adapter/types.ts +3 -1
  246. package/node_modules/pi-web-access/CHANGELOG.md +42 -0
  247. package/node_modules/pi-web-access/README.md +26 -10
  248. package/node_modules/pi-web-access/curator-page.ts +12 -3
  249. package/node_modules/pi-web-access/curator-server.ts +3 -1
  250. package/node_modules/pi-web-access/extract.ts +88 -13
  251. package/node_modules/pi-web-access/gemini-search.ts +10 -5
  252. package/node_modules/pi-web-access/github-extract.ts +47 -3
  253. package/node_modules/pi-web-access/index.ts +40 -40
  254. package/node_modules/pi-web-access/package.json +1 -1
  255. package/node_modules/pi-web-access/page-query.ts +61 -8
  256. package/node_modules/pi-web-access/xcrawl.ts +264 -0
  257. package/package.json +19 -22
  258. package/node_modules/@herbertgao/pi-stash/README.md +0 -34
  259. package/node_modules/@herbertgao/pi-stash/package.json +0 -51
  260. package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
  261. package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
  262. package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
  263. package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
@@ -16,11 +16,14 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import { createDeadline, forEachCooperatively, yieldIfOverBudget, } from "./cooperative-budget.js";
19
+ import { incrementDegradationCount } from "./degradation-ledger.js";
19
20
  import { KIND_EXTENSIONS } from "./file-kinds.js";
20
21
  import { PathKeyedMap } from "./path-keyed-map.js";
21
22
  import { isAtOrAboveHomeDir, normalizeEphemeralMapKey } from "./path-utils.js";
23
+ import { createSingleFlight } from "./single-flight.js";
22
24
  import { createDebounceScheduler, } from "./persist-debounce.js";
23
25
  import { getWordIndexMaxFilesDerived } from "./project-scale.js";
26
+ import { compactPostingsIntoArena, compactPostingsIntoArenaCooperatively, countPostingBackingStores, countPostingEntries, estimateWordIndexStoreBytes, WordForwardEntry, WordIndexFileTable, WordPostingList, } from "./word-index-store.js";
24
27
  import { logWordIndex } from "./word-index-logger.js";
25
28
  /**
26
29
  * The single normalizer every word-index path key folds through (#1025). The
@@ -160,6 +163,7 @@ const WORD_INDEX_LONG_LINE_YIELD_CHARS = 4096;
160
163
  function createEmptyWordIndex(truncated) {
161
164
  return {
162
165
  postings: new Map(),
166
+ fileTable: new WordIndexFileTable(),
163
167
  docLengths: new PathKeyedMap(wordIndexKey),
164
168
  totalTokens: 0,
165
169
  docCount: 0,
@@ -167,38 +171,274 @@ function createEmptyWordIndex(truncated) {
167
171
  forward: new PathKeyedMap(wordIndexKey),
168
172
  fileMtimes: new PathKeyedMap(wordIndexKey),
169
173
  fileSizes: new PathKeyedMap(wordIndexKey),
174
+ replacementStats: { count: 0, totalMs: 0, maxMs: 0 },
175
+ dirtyFiles: new Set(),
176
+ postingStoreCount: 0,
177
+ recompactFlight: createSingleFlight(),
170
178
  };
171
179
  }
172
- function indexWordLine(index, filePath, line, lineNumber, tokenLineCounts) {
180
+ export function countWordIndexPostingEntries(index) {
181
+ return countPostingEntries(index);
182
+ }
183
+ /**
184
+ * Estimated resident bytes of the index's two packed stores (#2069).
185
+ * Reported on the `full_rebuild`, `incremental_refresh`, and `cold_build`
186
+ * word-index log records and on the `memory_sample` word-index subsystem, so
187
+ * a heap census can be reconciled against the log without taking a snapshot —
188
+ * the gap that let #1999 under-count this subsystem by a factor of sixty.
189
+ */
190
+ export function estimateWordIndexResidentBytes(index) {
191
+ return estimateWordIndexStoreBytes(index);
192
+ }
193
+ /**
194
+ * Record a posting whose file id the file table cannot resolve (#2069).
195
+ *
196
+ * Unreachable by construction: {@link WordIndexFileTable.release} only runs
197
+ * after the forward index has enumerated and removed every posting naming that
198
+ * id. If it fires, that invariant broke, and the visible symptom is a query
199
+ * returning FEWER results with nothing to distinguish it from a smaller match
200
+ * set — the clean-versus-errored ambiguity AGENTS.md catalogs as shape 10.
201
+ *
202
+ * `incrementDegradationCount` rather than `recordDegradationOnce` because
203
+ * ranking is a hot loop: the tally stays exact for every occurrence while only
204
+ * the first per (kind, orphaned id) writes a durable row. Subject is the id
205
+ * itself, so aggregation keeps the discriminating identity.
206
+ */
207
+ function recordOrphanWordIndexFileId(fileId, token, seam) {
208
+ incrementDegradationCount({
209
+ kind: "word-index-orphan-file-id",
210
+ subject: `fileId:${fileId}`,
211
+ reason: `${seam} dropped a posting for token "${token}": the file table has no path for this id`,
212
+ });
213
+ }
214
+ /**
215
+ * Decode one token’s postings into `{ file, line }` objects. Allocates, so it
216
+ * is for callers that genuinely need the display form (tests, diagnostics) —
217
+ * never for the hot ranking loop, which reads the packed lanes directly.
218
+ */
219
+ export function wordIndexPostingHits(index, token) {
220
+ const list = index.postings.get(token);
221
+ if (!list)
222
+ return [];
223
+ const hits = [];
224
+ for (let i = 0; i < list.length; i += 1) {
225
+ const fileId = list.fileIdAt(i);
226
+ const file = index.fileTable.pathFor(fileId);
227
+ if (file === undefined) {
228
+ recordOrphanWordIndexFileId(fileId, token, "decode");
229
+ continue;
230
+ }
231
+ hits.push({ file, line: list.lineAt(i) });
232
+ }
233
+ return hits;
234
+ }
235
+ /**
236
+ * Settle a freshly built index's posting store into its compact resident form
237
+ * (#2069): re-home every list into one shared arena, sized from the live entry
238
+ * counts. That both releases the doubling growth slack a build leaves behind
239
+ * and stops the long tail of rare tokens paying for an `ArrayBuffer` header
240
+ * each.
241
+ */
242
+ function compactWordIndexPostings(index) {
243
+ compactPostingsIntoArena(index.postings);
244
+ index.postingStoreCount = countPostingBackingStores(index.postings);
245
+ }
246
+ /** Bump the running distinct-backing-store tally by `count` (#2117). */
247
+ function notePostingStoresAllocated(index, count) {
248
+ index.postingStoreCount = (index.postingStoreCount ?? 0) + count;
249
+ }
250
+ /**
251
+ * Floor for the recompaction gate: below this many backing stores the arena is
252
+ * never repacked, so a small index never pays an O(vocab) walk it cannot amortize
253
+ * (#2117). This is also the whole gate for indexes whose vocabulary is under
254
+ * `WORD_INDEX_RECOMPACT_STORE_FLOOR / WORD_INDEX_RECOMPACT_STORE_FRACTION`
255
+ * tokens, keeping the pre-#2246 behaviour for small corpora bit-identical.
256
+ */
257
+ const WORD_INDEX_RECOMPACT_STORE_FLOOR = 64;
258
+ /**
259
+ * Recompaction gate as a share of the live vocabulary (#2246). The flat 64-store
260
+ * threshold #2117 shipped was crossed by every single edit: one document
261
+ * replacement raises `postingStoreCount` by roughly the edited document's
262
+ * distinct-token count (hundreds), so the whole O(vocab) arena was rebuilt after
263
+ * every edit. Gating on a fraction of `postings.size` instead lets fragmentation
264
+ * accumulate proportionally to the index, so recompaction fires once per many
265
+ * edits rather than once per edit.
266
+ *
267
+ * Memory ceiling: the store count can reach a tenth of the vocabulary in private
268
+ * stores before a repack, each carrying a fixed header plus growth slack. #2117
269
+ * measured the fully-churned ceiling (every token private, store count =
270
+ * vocabulary) at +22% resident. The cost does not scale with the store count
271
+ * alone, because the tokens that churn most are also the ones carrying the
272
+ * longest posting lists. PR #2246's adversarial hot-token probe measured this
273
+ * gate at +13.7% resident at rest and +19.9% peak over base — inside the +22%
274
+ * #2117 accepted, but not by the wide margin a naive store-count ratio would
275
+ * predict. Raising the fraction further would cross that bound.
276
+ */
277
+ const WORD_INDEX_RECOMPACT_STORE_FRACTION = 0.1;
278
+ /**
279
+ * Backing-store count that triggers a repack for `index`: a fixed floor, or a
280
+ * share of the live vocabulary once the index is large enough for the share to
281
+ * exceed the floor. O(1) — reads `postings.size`, never walks the vocabulary.
282
+ */
283
+ function wordIndexRecompactThreshold(index) {
284
+ return Math.max(WORD_INDEX_RECOMPACT_STORE_FLOOR, Math.floor(index.postings.size * WORD_INDEX_RECOMPACT_STORE_FRACTION));
285
+ }
286
+ /**
287
+ * Pack the arena if churn has spread the postings across more than the
288
+ * threshold of backing stores (#2117, #2246). Runs the exact O(vocab) store
289
+ * count as the authoritative gate — the per-edit hot path uses the O(1)
290
+ * `postingStoreCount` estimate, so this expensive walk only happens inside the
291
+ * bounded, off-hot-path recompaction it guards. Resets the estimate to the
292
+ * true post-compaction count.
293
+ */
294
+ async function recompactWordIndexPostingsIfNeeded(index, root) {
295
+ const threshold = wordIndexRecompactThreshold(index);
296
+ const beforeStores = countPostingBackingStores(index.postings);
297
+ if (beforeStores <= threshold) {
298
+ index.postingStoreCount = beforeStores;
299
+ return;
300
+ }
301
+ const beforeBytes = estimateWordIndexResidentBytes(index);
302
+ await compactPostingsIntoArenaCooperatively(index.postings);
303
+ const afterStores = countPostingBackingStores(index.postings);
304
+ index.postingStoreCount = afterStores;
305
+ const afterBytes = estimateWordIndexResidentBytes(index);
306
+ const firstForRoot = incrementDegradationCount({
307
+ kind: "word-index-arena-recompact",
308
+ subject: path.resolve(root),
309
+ reason: `arena store threshold ${threshold} exceeded`,
310
+ metadata: { beforeBytes, afterBytes, beforeStores, afterStores },
311
+ });
312
+ // Keep the detailed record bounded per root. The ledger still counts every
313
+ // repeated recompaction and emits its own power-of-two summaries.
314
+ if (firstForRoot) {
315
+ logWordIndex({
316
+ phase: "incremental_refresh",
317
+ cwd: path.resolve(root),
318
+ trigger: "incremental_refresh",
319
+ reason: `arena_recompact beforeBytes=${beforeBytes} afterBytes=${afterBytes} beforeStores=${beforeStores} afterStores=${afterStores}`,
320
+ });
321
+ }
322
+ }
323
+ /**
324
+ * Serialize a recompaction through the per-index async operation queue so no
325
+ * async edit or refresh interleaves it (#2117). The queue plus the corruption-
326
+ * proof cooperative compactor together close the review-F2 data race: the queue
327
+ * excludes async operations, and the compactor's snapshot-and-skip publish
328
+ * excludes the synchronous per-edit path the queue cannot see. The per-index
329
+ * `recompactFlight` single-flight (#1753) keeps a burst of edits from stacking
330
+ * duplicates: a caller that arrives while one recompaction runs joins it rather
331
+ * than starting a second. The single slot uses a constant key.
332
+ */
333
+ function enqueueWordIndexRecompact(index, root) {
334
+ if ((index.postingStoreCount ?? 0) <= wordIndexRecompactThreshold(index)) {
335
+ return Promise.resolve();
336
+ }
337
+ const flight = (index.recompactFlight ??= createSingleFlight());
338
+ return flight.run("recompact", () => enqueueAsyncWordIndexOperation(index, () => recompactWordIndexPostingsIfNeeded(index, root)));
339
+ }
340
+ /**
341
+ * O(1) hot-path gate for the synchronous per-edit seam. Reads the running
342
+ * store-count estimate, never a fresh `Set` over the vocabulary, then defers
343
+ * the actual work onto the async queue so it cannot stall the edit (#2117).
344
+ */
345
+ function scheduleWordIndexRecompact(index, filePath) {
346
+ void enqueueWordIndexRecompact(index, path.dirname(filePath));
347
+ }
348
+ /**
349
+ * Test hook: settle any queued arena recompaction for `index`. Draining the
350
+ * index's own async operation queue is enough because the recompaction runs
351
+ * through it; there is no module-level scheduler to flush.
352
+ */
353
+ export async function flushWordIndexRecompactionsForTests(index) {
354
+ await (asyncWordIndexOperations.get(index) ?? Promise.resolve());
355
+ }
356
+ function recordWordIndexReplacement(index, startedAt) {
357
+ const stats = index.replacementStats ?? { count: 0, totalMs: 0, maxMs: 0 };
358
+ const durationMs = Math.max(0, Date.now() - startedAt);
359
+ stats.count += 1;
360
+ stats.totalMs += durationMs;
361
+ stats.maxMs = Math.max(stats.maxMs, durationMs);
362
+ index.replacementStats = stats;
363
+ }
364
+ /** Intern a document path once per index; posting removal compares this id. */
365
+ function internWordIndexFile(index, filePath) {
366
+ return index.fileTable.intern(wordIndexKey(filePath), filePath);
367
+ }
368
+ function appendWordIndexPostings(index, filePath, perTokenHits) {
369
+ const fileId = internWordIndexFile(index, filePath);
370
+ const tokenLineCounts = new Map();
371
+ for (const [token, lineNumbers] of perTokenHits) {
372
+ let list = index.postings.get(token);
373
+ if (list) {
374
+ // A grow allocates a fresh private store; count it so the O(1) gate
375
+ // tracks fragmentation without re-walking the vocabulary (#2117).
376
+ if (list.reserve(list.length + lineNumbers.length)) {
377
+ notePostingStoresAllocated(index, 1);
378
+ }
379
+ }
380
+ else {
381
+ list = new WordPostingList(token, lineNumbers.length);
382
+ index.postings.set(token, list);
383
+ notePostingStoresAllocated(index, 1);
384
+ }
385
+ // `list.token` is the canonical instance, so the forward entry points at
386
+ // the same string the postings map is keyed by instead of retaining this
387
+ // document’s own tokenizer allocation (#2069).
388
+ tokenLineCounts.set(list.token, lineNumbers.length);
389
+ for (const line of lineNumbers)
390
+ list.push(fileId, line);
391
+ }
392
+ return tokenLineCounts;
393
+ }
394
+ function commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt) {
395
+ const tokenLineCounts = appendWordIndexPostings(index, doc.path, perTokenHits);
396
+ index.docLengths.set(doc.path, docLength);
397
+ index.forward.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
398
+ index.fileMtimes.set(doc.path, -1);
399
+ index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
400
+ index.dirtyFiles?.add(wordIndexKey(doc.path));
401
+ index.totalTokens += docLength;
402
+ index.docCount += 1;
403
+ recordWordIndexReplacement(index, startedAt);
404
+ }
405
+ function indexWordLine(index, fileId, line, lineNumber, tokenLineCounts) {
173
406
  const lineTokens = tokenizeLine(line);
174
407
  const seenOnLine = new Set();
175
408
  for (const token of lineTokens) {
176
409
  if (seenOnLine.has(token))
177
410
  continue;
178
411
  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);
412
+ let list = index.postings.get(token);
413
+ if (!list) {
414
+ list = new WordPostingList(token, 1);
415
+ index.postings.set(token, list);
416
+ }
417
+ list.push(fileId, lineNumber);
418
+ // Canonical instance, not this line’s fresh tokenizer allocation (#2069).
419
+ const canonical = list.token;
420
+ tokenLineCounts.set(canonical, (tokenLineCounts.get(canonical) ?? 0) + 1);
185
421
  }
186
422
  return lineTokens.length;
187
423
  }
188
424
  function finishWordIndexDocument(index, doc, docLength, tokenLineCounts) {
189
425
  index.docLengths.set(doc.path, docLength);
190
- index.forward?.set(doc.path, tokenLineCounts);
426
+ index.forward?.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
191
427
  index.fileMtimes.set(doc.path, doc.mtimeMs ?? 0);
192
428
  index.fileSizes.set(doc.path, doc.size ?? Buffer.byteLength(doc.content, "utf-8"));
429
+ // A build has no prior wire cache. Keeping the marker makes a future
430
+ // serializer cache explicit and gives incremental refresh one uniform seam.
431
+ index.dirtyFiles?.add(wordIndexKey(doc.path));
193
432
  index.totalTokens += docLength;
194
433
  index.docCount += 1;
195
434
  }
196
435
  function indexWordDocument(index, doc) {
436
+ const fileId = internWordIndexFile(index, doc.path);
197
437
  const lines = doc.content.split(/\r?\n/);
198
438
  const tokenLineCounts = new Map();
199
439
  let docLength = 0;
200
440
  for (let i = 0; i < lines.length; i += 1) {
201
- docLength += indexWordLine(index, doc.path, lines[i], i + 1, tokenLineCounts);
441
+ docLength += indexWordLine(index, fileId, lines[i], i + 1, tokenLineCounts);
202
442
  }
203
443
  finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
204
444
  }
@@ -206,6 +446,7 @@ export function buildWordIndex(files) {
206
446
  const index = createEmptyWordIndex(files.truncated ?? false);
207
447
  for (const doc of files)
208
448
  indexWordDocument(index, doc);
449
+ compactWordIndexPostings(index);
209
450
  return index;
210
451
  }
211
452
  /**
@@ -221,12 +462,13 @@ export async function buildWordIndexAsync(files, shouldContinue = () => true) {
221
462
  for (const doc of files) {
222
463
  if (!shouldContinue())
223
464
  throw new Error("word index build superseded");
465
+ const fileId = internWordIndexFile(index, doc.path);
224
466
  const lines = doc.content.split(/\r?\n/);
225
467
  const tokenLineCounts = new Map();
226
468
  let docLength = 0;
227
469
  for (let i = 0; i < lines.length; i += 1) {
228
470
  const line = lines[i];
229
- docLength += indexWordLine(index, doc.path, line, i + 1, tokenLineCounts);
471
+ docLength += indexWordLine(index, fileId, line, i + 1, tokenLineCounts);
230
472
  if (line.length >= WORD_INDEX_LONG_LINE_YIELD_CHARS ||
231
473
  deadline.expired()) {
232
474
  await yieldIfOverBudget(deadline);
@@ -240,6 +482,7 @@ export async function buildWordIndexAsync(files, shouldContinue = () => true) {
240
482
  throw new Error("word index build superseded");
241
483
  }
242
484
  }
485
+ compactWordIndexPostings(index);
243
486
  return index;
244
487
  }
245
488
  /**
@@ -256,19 +499,27 @@ export function removeWordIndexDocument(index, filePath) {
256
499
  const tokenLineCounts = index.forward.get(filePath);
257
500
  if (!tokenLineCounts)
258
501
  return false;
259
- // `postings` is token-keyed (not a PathKeyedMap), so its `WordHit.file`
260
- // display strings must be compared through the SAME normalizer the path maps
261
- // use — otherwise a build-form hit (`SUB/a.ts`) survives an edit-form removal
502
+ // `postings` is token-keyed (not a PathKeyedMap), so the file identity it
503
+ // carries must be resolved through the SAME normalizer the path maps use —
504
+ // otherwise a build-form hit (`SUB/a.ts`) survives an edit-form removal
262
505
  // (`sub/a.ts`) on a case-insensitive FS and lingers as a stale posting
263
- // (the #1025 item #2 bug this fix closes).
506
+ // (the #1025 item #2 bug this fix closes). Since #2069 that identity is the
507
+ // file table’s integer id, so the comparison below is an int compare rather
508
+ // than a per-element string compare.
264
509
  const removedKey = wordIndexKey(filePath);
510
+ const removedId = index.fileTable.idFor(removedKey);
511
+ if (removedId === undefined)
512
+ return false;
265
513
  for (const token of tokenLineCounts.keys()) {
266
- const arr = index.postings.get(token);
267
- if (!arr)
514
+ const list = index.postings.get(token);
515
+ if (!list)
268
516
  continue;
269
- const next = arr.filter((hit) => wordIndexKey(hit.file) !== removedKey);
270
- if (next.length > 0)
517
+ const next = list.withoutFile(removedId);
518
+ // `withoutFile` always returns a freshly allocated private store (#2117).
519
+ if (next.length > 0) {
271
520
  index.postings.set(token, next);
521
+ notePostingStoresAllocated(index, 1);
522
+ }
272
523
  else
273
524
  index.postings.delete(token);
274
525
  }
@@ -277,6 +528,8 @@ export function removeWordIndexDocument(index, filePath) {
277
528
  index.forward.delete(filePath);
278
529
  index.fileMtimes.delete(filePath);
279
530
  index.fileSizes.delete(filePath);
531
+ index.dirtyFiles?.add(wordIndexKey(filePath));
532
+ index.fileTable.release(removedKey);
280
533
  index.totalTokens -= docLength;
281
534
  index.docCount = Math.max(0, index.docCount - 1);
282
535
  return true;
@@ -297,6 +550,7 @@ export function removeWordIndexDocument(index, filePath) {
297
550
  export function updateWordIndexDocument(index, doc) {
298
551
  if (!index.forward)
299
552
  return false;
553
+ const startedAt = Date.now();
300
554
  // Remove the old contribution first (no-op if this is a brand new doc).
301
555
  if (index.forward.has(doc.path)) {
302
556
  removeWordIndexDocument(index, doc.path);
@@ -322,34 +576,27 @@ export function updateWordIndexDocument(index, doc) {
322
576
  perTokenHits.set(token, [i + 1]);
323
577
  }
324
578
  }
325
- const tokenLineCounts = new Map();
326
- for (const [token, lineNumbers] of perTokenHits) {
327
- tokenLineCounts.set(token, lineNumbers.length);
328
- const hits = lineNumbers.map((line) => ({ file: doc.path, line }));
329
- const arr = index.postings.get(token);
330
- if (arr)
331
- arr.push(...hits);
332
- else
333
- index.postings.set(token, hits);
334
- }
335
- index.docLengths.set(doc.path, docLength);
336
- index.forward.set(doc.path, tokenLineCounts);
337
579
  // Per-edit callers generally already have content but not a stat. -1 is an
338
580
  // impossible real mtime, so it deliberately makes the document stale at the
339
581
  // next startup refresh (`-1 !== realMtime` always) — unlike 0, which is a
340
582
  // legal on-disk mtime (SOURCE_DATE_EPOCH=0, archive extraction) and would
341
583
  // collide, leaving such a file never re-tokenized (#958 review F2).
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;
584
+ commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
585
+ scheduleWordIndexRecompact(index, doc.path);
351
586
  return true;
352
587
  }
588
+ const asyncWordIndexOperations = new WeakMap();
589
+ function enqueueAsyncWordIndexOperation(index, operation) {
590
+ const previous = asyncWordIndexOperations.get(index) ?? Promise.resolve();
591
+ const run = previous.catch(() => undefined).then(operation);
592
+ const settled = run.then(() => undefined, () => undefined);
593
+ asyncWordIndexOperations.set(index, settled);
594
+ return run.finally(() => {
595
+ if (asyncWordIndexOperations.get(index) === settled) {
596
+ asyncWordIndexOperations.delete(index);
597
+ }
598
+ });
599
+ }
353
600
  async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
354
601
  if (!index.forward)
355
602
  return undefined;
@@ -357,31 +604,43 @@ async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
357
604
  if (!tokenLineCounts)
358
605
  return undefined;
359
606
  const removedKey = wordIndexKey(filePath);
607
+ const removedId = index.fileTable.idFor(removedKey);
608
+ if (removedId === undefined)
609
+ return undefined;
360
610
  const postings = new Map();
361
611
  const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
362
612
  for (const token of tokenLineCounts.keys()) {
363
613
  if (!shouldContinue())
364
614
  throw new Error("word index refresh superseded");
365
- const arr = index.postings.get(token);
366
- if (!arr)
615
+ const list = index.postings.get(token);
616
+ if (!list)
367
617
  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
- }
618
+ // One token's postings, filtered by the SAME packed primitive the
619
+ // synchronous path uses, so the staged result is identical rather than
620
+ // merely equivalent. The work unit is the token, not the posting element:
621
+ // a per-element deadline check cost one `performance.now()` per entry —
622
+ // on the order of a million per edit on a 2.2M-posting corpus — which is
623
+ // the tax that made this path unusable from the per-edit seam (#2067).
624
+ // One list's scan is a bounded, non-yieldable unit; the deadline is
625
+ // checked between lists.
626
+ const survivors = list.withoutFile(removedId);
627
+ postings.set(token, survivors.length > 0 ? survivors : undefined);
628
+ if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
629
+ if (!shouldContinue())
630
+ throw new Error("word index refresh superseded");
376
631
  }
377
- postings.set(token, next.length > 0 ? next : undefined);
378
632
  }
379
633
  return { postings, docLength: index.docLengths.get(filePath) ?? 0 };
380
634
  }
381
635
  function commitWordIndexDocumentRemoval(index, filePath, staged) {
382
- for (const [token, hits] of staged.postings) {
383
- if (hits)
384
- index.postings.set(token, hits);
636
+ for (const [token, list] of staged.postings) {
637
+ // Staged survivors come from `WordPostingList.withoutFile` — a fresh
638
+ // private store each, so the O(1) fragmentation gate must count them
639
+ // (#2117).
640
+ if (list) {
641
+ index.postings.set(token, list);
642
+ notePostingStoresAllocated(index, 1);
643
+ }
385
644
  else
386
645
  index.postings.delete(token);
387
646
  }
@@ -389,23 +648,49 @@ function commitWordIndexDocumentRemoval(index, filePath, staged) {
389
648
  index.forward?.delete(filePath);
390
649
  index.fileMtimes.delete(filePath);
391
650
  index.fileSizes.delete(filePath);
651
+ index.dirtyFiles?.add(wordIndexKey(filePath));
652
+ index.fileTable.release(wordIndexKey(filePath));
392
653
  index.totalTokens -= staged.docLength;
393
654
  index.docCount = Math.max(0, index.docCount - 1);
394
655
  }
395
656
  /** Cooperative, atomically-published removal used only by bulk refresh. */
396
657
  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;
658
+ return enqueueAsyncWordIndexOperation(index, async () => {
659
+ const staged = await stageWordIndexDocumentRemoval(index, filePath, shouldContinue);
660
+ if (!staged)
661
+ return false;
662
+ if (!shouldContinue())
663
+ throw new Error("word index refresh superseded");
664
+ commitWordIndexDocumentRemoval(index, filePath, staged);
665
+ return true;
666
+ });
667
+ }
668
+ /**
669
+ * The per-edit replacement primitive the cascade seam calls (#2067).
670
+ *
671
+ * `updateWordIndexDocumentAsync` plus the arena-recompaction gate the
672
+ * synchronous variant carries, which is what makes this the per-edit entry
673
+ * point rather than a rename. Bulk refresh must NOT use it: one refreshed
674
+ * document raises `postingStoreCount` by roughly its distinct-token count, so a
675
+ * per-document schedule would drive an O(vocabulary) recompaction between
676
+ * nearly every pair of documents. `refreshWordIndexIncrementally` therefore
677
+ * drives `updateWordIndexDocumentAsync` directly and recompacts ONCE after its
678
+ * loop; the per-edit seam has no such "after the loop" and schedules here.
679
+ */
680
+ export async function updateWordIndexDocumentForEdit(index, doc) {
681
+ const updated = await updateWordIndexDocumentAsync(index, doc);
682
+ if (updated)
683
+ scheduleWordIndexRecompact(index, doc.path);
684
+ return updated;
404
685
  }
405
686
  /** Cooperative replacement whose old/new state is committed without an await. */
406
687
  export async function updateWordIndexDocumentAsync(index, doc, shouldContinue = () => true) {
688
+ return enqueueAsyncWordIndexOperation(index, () => updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue));
689
+ }
690
+ async function updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue) {
407
691
  if (!index.forward)
408
692
  return false;
693
+ const startedAt = Date.now();
409
694
  const removal = index.forward.has(doc.path)
410
695
  ? await stageWordIndexDocumentRemoval(index, doc.path, shouldContinue)
411
696
  : undefined;
@@ -437,22 +722,7 @@ export async function updateWordIndexDocumentAsync(index, doc, shouldContinue =
437
722
  throw new Error("word index refresh superseded");
438
723
  if (removal)
439
724
  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"));
454
- index.totalTokens += docLength;
455
- index.docCount += 1;
725
+ commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
456
726
  return true;
457
727
  }
458
728
  /** Bounds shared by every word-index build path — keep the walk off the
@@ -567,6 +837,12 @@ const WORD_INDEX_FILE_READ_TOKEN_COST = 300;
567
837
  * against a 1,436 ms rebuild + re-read). The synthetic high-df corpus in the
568
838
  * #1197 probe gives 0.09, so this is the conservative (incremental-favouring)
569
839
  * end of the observed range.
840
+ *
841
+ * #2069 made the scan an Int32 compare over packed lanes instead of a string
842
+ * compare over boxed objects, so the real per-element cost is now BELOW this
843
+ * constant. The constant is left as measured: over-stating incremental cost
844
+ * only ever routes work to a full rebuild, which is the bounded side of the
845
+ * decision. Re-measuring it is #2067/#2068 territory, not this change.
570
846
  */
571
847
  const WORD_INDEX_POSTING_SCAN_TOKEN_COST = 0.2;
572
848
  /**
@@ -703,9 +979,9 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
703
979
  // this decision exists to avoid.
704
980
  let postingEntries = 0;
705
981
  let weightedPostingEntries = 0;
706
- for (const hits of index.postings.values()) {
707
- postingEntries += hits.length;
708
- weightedPostingEntries += hits.length * hits.length;
982
+ for (const list of index.postings.values()) {
983
+ postingEntries += list.length;
984
+ weightedPostingEntries += list.length * list.length;
709
985
  }
710
986
  // The mean posting-array length weighted by OCCURRENCES, not the plain mean:
711
987
  // a document's tokens are drawn from the frequency distribution, so the arrays
@@ -802,6 +1078,9 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
802
1078
  }
803
1079
  }
804
1080
  timings.refreshReadsMs = Date.now() - refreshReadsStartMs;
1081
+ // Route through the same guarded queue the per-edit seam uses, so a refresh
1082
+ // and a concurrent cascade edit cannot both drive a recompaction (#2117).
1083
+ await enqueueWordIndexRecompact(index, root);
805
1084
  index.truncated = walked.length === maxFiles;
806
1085
  return {
807
1086
  mode: "incremental",
@@ -1026,17 +1305,29 @@ export function searchWordIndex(index, query, options = {}) {
1026
1305
  const posting = index.postings.get(token);
1027
1306
  if (!posting)
1028
1307
  continue;
1029
- const linesByFile = new Map();
1030
- for (const hit of posting) {
1031
- const arr = linesByFile.get(hit.file);
1308
+ // Grouped by the packed file id, then resolved to the display path once
1309
+ // per (token, file) instead of once per posting (#2069). Insertion order,
1310
+ // document frequency, and the per-file line lists are identical to the
1311
+ // boxed representation, so scores and ordering are unchanged.
1312
+ const linesByFileId = new Map();
1313
+ for (let i = 0; i < posting.length; i += 1) {
1314
+ const fileId = posting.fileIdAt(i);
1315
+ const arr = linesByFileId.get(fileId);
1032
1316
  if (arr)
1033
- arr.push(hit.line);
1317
+ arr.push(posting.lineAt(i));
1034
1318
  else
1035
- linesByFile.set(hit.file, [hit.line]);
1319
+ linesByFileId.set(fileId, [posting.lineAt(i)]);
1036
1320
  }
1037
- const docFrequency = linesByFile.size;
1321
+ const docFrequency = linesByFileId.size;
1038
1322
  const idf = Math.log(1 + (docCount - docFrequency + 0.5) / (docFrequency + 0.5));
1039
- for (const [file, lines] of linesByFile) {
1323
+ for (const [fileId, lines] of linesByFileId) {
1324
+ const file = index.fileTable.pathFor(fileId);
1325
+ if (file === undefined) {
1326
+ // Dropping this silently would shorten the result list with nothing to
1327
+ // tell it apart from a smaller match set. Record, then drop.
1328
+ recordOrphanWordIndexFileId(fileId, token, "search");
1329
+ continue;
1330
+ }
1040
1331
  if (combinedFilter && !combinedFilter(file))
1041
1332
  continue;
1042
1333
  const termFrequency = lines.length;
@@ -1100,18 +1391,32 @@ export function centralityFromReverseDeps(index, reverseDeps, normalizeKey = (fi
1100
1391
  }
1101
1392
  /** Persisted word-index serialization format version. Bump on breaking format changes. */
1102
1393
  export const WORD_INDEX_FORMAT_VERSION = 2;
1103
- export function serializeWordIndex(index) {
1394
+ const serializedWordIndexCaches = new WeakMap();
1395
+ let _lastSerializeWork;
1396
+ /** Test-only: work stats for the most recent {@link serializeWordIndex} call. */
1397
+ export function getLastWordIndexSerializeWork() {
1398
+ return _lastSerializeWork;
1399
+ }
1400
+ function serializeWordIndexFull(index) {
1104
1401
  const files = [...index.docLengths.keys()];
1105
- const fileIndex = new Map();
1106
- files.forEach((file, i) => fileIndex.set(file, i));
1402
+ // `files` carries whatever spelling `docLengths` last stored, which can
1403
+ // differ from the spelling the file table interned. Both fold through
1404
+ // `wordIndexKey`, so the slot lookup runs in the folded key space and a
1405
+ // separator- or case-divergent spelling still finds its postings (#1025).
1406
+ const slotByFileId = new Map();
1407
+ files.forEach((file, i) => {
1408
+ const fileId = index.fileTable.idFor(wordIndexKey(file));
1409
+ if (fileId !== undefined)
1410
+ slotByFileId.set(fileId, i);
1411
+ });
1107
1412
  const postings = [];
1108
- for (const [token, hits] of index.postings) {
1413
+ for (const [token, list] of index.postings) {
1109
1414
  const flat = [];
1110
- for (const hit of hits) {
1111
- const idx = fileIndex.get(hit.file);
1112
- if (idx === undefined)
1415
+ for (let i = 0; i < list.length; i += 1) {
1416
+ const slot = slotByFileId.get(list.fileIdAt(i));
1417
+ if (slot === undefined)
1113
1418
  continue;
1114
- flat.push(idx, hit.line);
1419
+ flat.push(slot, list.lineAt(i));
1115
1420
  }
1116
1421
  if (flat.length > 0)
1117
1422
  postings.push([token, flat]);
@@ -1119,10 +1424,10 @@ export function serializeWordIndex(index) {
1119
1424
  const forward = index.forward
1120
1425
  ? files.map((file, i) => [
1121
1426
  i,
1122
- [...(index.forward.get(file) ?? new Map()).entries()],
1427
+ [...(index.forward.get(file)?.entries() ?? [])],
1123
1428
  ])
1124
1429
  : undefined;
1125
- return {
1430
+ const serialized = {
1126
1431
  version: WORD_INDEX_FORMAT_VERSION,
1127
1432
  files,
1128
1433
  postings,
@@ -1134,6 +1439,145 @@ export function serializeWordIndex(index) {
1134
1439
  fileSizes: files.map((file) => index.fileSizes.get(file) ?? 0),
1135
1440
  forward,
1136
1441
  };
1442
+ const tokensByFile = new Map();
1443
+ if (index.forward) {
1444
+ for (const file of files) {
1445
+ tokensByFile.set(wordIndexKey(file), new Set(index.forward.get(file)?.keys() ?? []));
1446
+ }
1447
+ }
1448
+ _lastSerializeWork = {
1449
+ affectedTokenCount: postings.length,
1450
+ tookFullPath: true,
1451
+ };
1452
+ return { serialized, slotByFileId, tokensByFile };
1453
+ }
1454
+ /**
1455
+ * Refresh the cached wire view without walking untouched posting lanes.
1456
+ * Existing files keep their slots, so a document replacement only rebuilds
1457
+ * the token lists named by that document's old or new forward entry. A file
1458
+ * removal or reordering changes slot identity and deliberately takes the
1459
+ * bounded full path.
1460
+ */
1461
+ function serializeWordIndexIncrementally(index, cache) {
1462
+ const files = [...index.docLengths.keys()];
1463
+ const priorFiles = cache.serialized.files;
1464
+ const currentKeys = new Set(files.map(wordIndexKey));
1465
+ const priorKeys = new Set(priorFiles.map(wordIndexKey));
1466
+ if ([...priorKeys].some((key) => !currentKeys.has(key))) {
1467
+ return serializeWordIndexFull(index);
1468
+ }
1469
+ // Re-flattening most lanes costs more than rebuilding the compact wire view.
1470
+ // The review fixture crosses over between 10% and 100% dirty, so use the
1471
+ // midpoint as a conservative bound until a workload-specific model exists.
1472
+ const dirty = index.dirtyFiles;
1473
+ if (dirty && dirty.size * 2 > files.length) {
1474
+ return serializeWordIndexFull(index);
1475
+ }
1476
+ // A replacement deletes and re-adds a PathKeyedMap entry, which changes Map
1477
+ // insertion order. Keep the cached wire slots stable instead of rewriting
1478
+ // every posting just because one document changed order.
1479
+ const filesInWireOrder = [
1480
+ ...priorFiles,
1481
+ ...files.filter((file) => !priorKeys.has(wordIndexKey(file))),
1482
+ ];
1483
+ if (!dirty || dirty.size === 0) {
1484
+ _lastSerializeWork = { affectedTokenCount: 0, tookFullPath: false };
1485
+ return cache;
1486
+ }
1487
+ const slotByFileId = new Map(cache.slotByFileId);
1488
+ for (let i = priorFiles.length; i < filesInWireOrder.length; i += 1) {
1489
+ const fileId = index.fileTable.idFor(wordIndexKey(filesInWireOrder[i]));
1490
+ if (fileId !== undefined)
1491
+ slotByFileId.set(fileId, i);
1492
+ }
1493
+ const postings = cache.serialized.postings.slice();
1494
+ const postingAt = new Map();
1495
+ postings.forEach(([token], i) => postingAt.set(token, i));
1496
+ const removedTokens = new Set();
1497
+ const tokensByFile = new Map(cache.tokensByFile);
1498
+ const fileByKey = new Map(filesInWireOrder.map((file) => [wordIndexKey(file), file]));
1499
+ const affectedTokens = new Set();
1500
+ for (const fileKey of dirty) {
1501
+ const file = fileByKey.get(fileKey);
1502
+ const oldTokens = tokensByFile.get(fileKey) ?? new Set();
1503
+ const currentEntry = file === undefined ? undefined : index.forward?.get(file);
1504
+ const currentTokens = new Set(currentEntry?.keys() ?? []);
1505
+ for (const token of oldTokens)
1506
+ affectedTokens.add(token);
1507
+ for (const token of currentTokens)
1508
+ affectedTokens.add(token);
1509
+ if (file === undefined)
1510
+ tokensByFile.delete(fileKey);
1511
+ else
1512
+ tokensByFile.set(fileKey, currentTokens);
1513
+ }
1514
+ for (const token of affectedTokens) {
1515
+ const list = index.postings.get(token);
1516
+ const flat = [];
1517
+ if (list) {
1518
+ for (let i = 0; i < list.length; i += 1) {
1519
+ const slot = slotByFileId.get(list.fileIdAt(i));
1520
+ if (slot !== undefined)
1521
+ flat.push(slot, list.lineAt(i));
1522
+ }
1523
+ }
1524
+ const at = postingAt.get(token);
1525
+ if (flat.length === 0) {
1526
+ if (at !== undefined)
1527
+ removedTokens.add(token);
1528
+ }
1529
+ else if (at === undefined) {
1530
+ postings.push([token, flat]);
1531
+ postingAt.set(token, postings.length - 1);
1532
+ }
1533
+ else {
1534
+ postings[at] = [postings[at][0], flat];
1535
+ }
1536
+ }
1537
+ const compactedPostings = removedTokens.size
1538
+ ? postings.filter(([token]) => !removedTokens.has(token))
1539
+ : postings;
1540
+ const forward = index.forward
1541
+ ? (cache.serialized.forward
1542
+ ?.slice()
1543
+ .map((entry) => [entry[0], entry[1]]) ?? [])
1544
+ : undefined;
1545
+ for (const fileKey of dirty) {
1546
+ const file = fileByKey.get(fileKey);
1547
+ const fileId = file === undefined
1548
+ ? undefined
1549
+ : index.fileTable.idFor(wordIndexKey(file));
1550
+ const slot = fileId === undefined ? undefined : slotByFileId.get(fileId);
1551
+ if (slot === undefined || !forward || file === undefined)
1552
+ continue;
1553
+ forward[slot] = [slot, [...(index.forward?.get(file)?.entries() ?? [])]];
1554
+ }
1555
+ const serialized = {
1556
+ ...cache.serialized,
1557
+ files: filesInWireOrder,
1558
+ postings: compactedPostings,
1559
+ docLengths: filesInWireOrder.map((file) => index.docLengths.get(file) ?? 0),
1560
+ fileMtimes: filesInWireOrder.map((file) => index.fileMtimes.get(file) ?? 0),
1561
+ fileSizes: filesInWireOrder.map((file) => index.fileSizes.get(file) ?? 0),
1562
+ forward,
1563
+ totalTokens: index.totalTokens,
1564
+ indexedFileCount: index.docCount,
1565
+ truncated: index.truncated,
1566
+ };
1567
+ _lastSerializeWork = {
1568
+ affectedTokenCount: affectedTokens.size,
1569
+ tookFullPath: false,
1570
+ };
1571
+ return { serialized, slotByFileId, tokensByFile };
1572
+ }
1573
+ export function serializeWordIndex(index) {
1574
+ const prior = serializedWordIndexCaches.get(index);
1575
+ const cache = prior
1576
+ ? serializeWordIndexIncrementally(index, prior)
1577
+ : serializeWordIndexFull(index);
1578
+ serializedWordIndexCaches.set(index, cache);
1579
+ index.dirtyFiles?.clear();
1580
+ return cache.serialized;
1137
1581
  }
1138
1582
  export function deserializeWordIndex(data) {
1139
1583
  if (!data ||
@@ -1146,6 +1590,12 @@ export function deserializeWordIndex(data) {
1146
1590
  return null;
1147
1591
  }
1148
1592
  const docLengths = new PathKeyedMap(wordIndexKey);
1593
+ // Slot i of the wire `files` array becomes file id `fileIdBySlot[i]`. Two
1594
+ // slots whose spellings fold to one key share one id, exactly as a build
1595
+ // would intern them, so a divergent snapshot cannot deserialize into two
1596
+ // posting identities for one document.
1597
+ const fileTable = new WordIndexFileTable();
1598
+ const fileIdBySlot = data.files.map((file) => fileTable.intern(wordIndexKey(file), file));
1149
1599
  const fileMtimes = new PathKeyedMap(wordIndexKey);
1150
1600
  const fileSizes = new PathKeyedMap(wordIndexKey);
1151
1601
  data.files.forEach((file, i) => docLengths.set(file, data.docLengths[i] ?? 0));
@@ -1162,17 +1612,23 @@ export function deserializeWordIndex(data) {
1162
1612
  for (const [token, flat] of data.postings) {
1163
1613
  if (typeof token !== "string" || !Array.isArray(flat))
1164
1614
  continue;
1165
- const hits = [];
1615
+ const lanes = [];
1166
1616
  for (let i = 0; i + 1 < flat.length; i += 2) {
1167
- const file = data.files[flat[i]];
1617
+ const fileId = fileIdBySlot[flat[i]];
1168
1618
  const line = flat[i + 1];
1169
- if (typeof file === "string" && typeof line === "number") {
1170
- hits.push({ file, line });
1619
+ if (typeof fileId === "number" && typeof line === "number") {
1620
+ lanes.push(fileId, line);
1171
1621
  }
1172
1622
  }
1173
- if (hits.length > 0)
1174
- postings.set(token, hits);
1623
+ if (lanes.length > 0) {
1624
+ postings.set(token, WordPostingList.fromLanes(token, lanes));
1625
+ }
1175
1626
  }
1627
+ // Every list above was sized exactly from the wire payload, so there is no
1628
+ // growth slack to release — but the per-list `ArrayBuffer` headers are the
1629
+ // same third-of-the-store cost a fresh build pays, and a deserialized index
1630
+ // is the LONG-lived one (a warm session reuses it across turns).
1631
+ compactPostingsIntoArena(postings);
1176
1632
  let forward;
1177
1633
  if (Array.isArray(data.forward)) {
1178
1634
  forward = new PathKeyedMap(wordIndexKey);
@@ -1189,14 +1645,17 @@ export function deserializeWordIndex(data) {
1189
1645
  continue;
1190
1646
  const [token, count] = pair;
1191
1647
  if (typeof token === "string" && typeof count === "number") {
1192
- perToken.set(token, count);
1648
+ // Point at the canonical instance the postings map holds, not
1649
+ // this document’s own JSON.parse allocation (#2069).
1650
+ perToken.set(postings.get(token)?.token ?? token, count);
1193
1651
  }
1194
1652
  }
1195
- forward.set(file, perToken);
1653
+ forward.set(file, WordForwardEntry.fromTally(perToken));
1196
1654
  }
1197
1655
  }
1198
1656
  return {
1199
1657
  postings,
1658
+ fileTable,
1200
1659
  docLengths,
1201
1660
  totalTokens: typeof data.totalTokens === "number" ? data.totalTokens : 0,
1202
1661
  docCount: data.files.length,
@@ -1204,6 +1663,10 @@ export function deserializeWordIndex(data) {
1204
1663
  forward,
1205
1664
  fileMtimes,
1206
1665
  fileSizes,
1666
+ // Postings were just packed into one arena above, so the running gate
1667
+ // starts from the exact post-compaction store count (#2117).
1668
+ postingStoreCount: countPostingBackingStores(postings),
1669
+ recompactFlight: createSingleFlight(),
1207
1670
  };
1208
1671
  }
1209
1672
  const buildStatuses = new Map();
@@ -1278,6 +1741,8 @@ export function triggerBackgroundWordIndexBuild(cwd, dbg, options = {}) {
1278
1741
  durationMs: Date.now() - startMs,
1279
1742
  indexedFileCount: index.docCount,
1280
1743
  tokens: index.postings.size,
1744
+ postingEntries: countWordIndexPostingEntries(index),
1745
+ residentBytes: estimateWordIndexResidentBytes(index),
1281
1746
  truncated: index.truncated,
1282
1747
  skipped: docs.skipped,
1283
1748
  });
@@ -1332,6 +1797,7 @@ function getWordIndexPersistScheduler() {
1332
1797
  return wordIndexPersistScheduler;
1333
1798
  }
1334
1799
  async function writeWordIndexSnapshot(cwd, index, dbg) {
1800
+ const persistStartedAt = Date.now();
1335
1801
  try {
1336
1802
  const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
1337
1803
  const existing = loadProjectSnapshot(cwd);
@@ -1346,8 +1812,11 @@ async function writeWordIndexSnapshot(cwd, index, dbg) {
1346
1812
  cachedExports: [],
1347
1813
  };
1348
1814
  snapshot.generatedAt = new Date().toISOString();
1815
+ const serializeStartedAt = performance.now();
1349
1816
  snapshot.wordIndex = serializeWordIndex(index);
1817
+ const serializeMs = performance.now() - serializeStartedAt;
1350
1818
  saveProjectSnapshot(cwd, snapshot);
1819
+ const writeMs = performance.now() - serializeStartedAt - serializeMs;
1351
1820
  dbg?.(`word-index persist: ${index.docCount} files, ${index.postings.size} tokens`);
1352
1821
  // #958 review F1: durably record persist SUCCESS too, not just failures —
1353
1822
  // in the MCP host (`dbg` is a no-op) this is the only signal that the index
@@ -1357,9 +1826,18 @@ async function writeWordIndexSnapshot(cwd, index, dbg) {
1357
1826
  phase: "persist_succeeded",
1358
1827
  cwd: path.resolve(cwd),
1359
1828
  trigger: "per_edit",
1829
+ durationMs: Date.now() - persistStartedAt,
1830
+ serializeMs,
1831
+ writeMs,
1360
1832
  indexedFileCount: index.docCount,
1361
1833
  tokens: index.postings.size,
1834
+ postingEntries: countWordIndexPostingEntries(index),
1835
+ residentBytes: estimateWordIndexResidentBytes(index),
1836
+ replacementCount: index.replacementStats?.count ?? 0,
1837
+ totalReplacementMs: index.replacementStats?.totalMs ?? 0,
1838
+ maxReplacementMs: index.replacementStats?.maxMs ?? 0,
1362
1839
  });
1840
+ index.replacementStats = { count: 0, totalMs: 0, maxMs: 0 };
1363
1841
  }
1364
1842
  catch (err) {
1365
1843
  dbg?.(`word-index persist: failed: ${err}`);