@mxalbert/context-mode 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (365) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +31 -0
  3. package/.codex-plugin/hooks.json +65 -0
  4. package/.codex-plugin/mcp.json +13 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.openclaw-plugin/index.ts +12 -0
  7. package/.openclaw-plugin/openclaw.plugin.json +38 -0
  8. package/.openclaw-plugin/package.json +28 -0
  9. package/LICENSE +94 -0
  10. package/README.md +1619 -0
  11. package/bin/statusline.mjs +378 -0
  12. package/build/adapters/antigravity/index.d.ts +51 -0
  13. package/build/adapters/antigravity/index.js +169 -0
  14. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  15. package/build/adapters/antigravity-cli/index.js +342 -0
  16. package/build/adapters/base.d.ts +92 -0
  17. package/build/adapters/base.js +135 -0
  18. package/build/adapters/claude-code/config.d.ts +8 -0
  19. package/build/adapters/claude-code/config.js +8 -0
  20. package/build/adapters/claude-code/hooks.d.ts +106 -0
  21. package/build/adapters/claude-code/hooks.js +183 -0
  22. package/build/adapters/claude-code/index.d.ts +80 -0
  23. package/build/adapters/claude-code/index.js +569 -0
  24. package/build/adapters/claude-code-base.d.ts +49 -0
  25. package/build/adapters/claude-code-base.js +113 -0
  26. package/build/adapters/client-map.d.ts +10 -0
  27. package/build/adapters/client-map.js +42 -0
  28. package/build/adapters/codex/config.d.ts +8 -0
  29. package/build/adapters/codex/config.js +8 -0
  30. package/build/adapters/codex/hooks.d.ts +55 -0
  31. package/build/adapters/codex/hooks.js +64 -0
  32. package/build/adapters/codex/index.d.ts +87 -0
  33. package/build/adapters/codex/index.js +995 -0
  34. package/build/adapters/codex/paths.d.ts +1 -0
  35. package/build/adapters/codex/paths.js +12 -0
  36. package/build/adapters/codex/usage.d.ts +107 -0
  37. package/build/adapters/codex/usage.js +227 -0
  38. package/build/adapters/copilot-base.d.ts +94 -0
  39. package/build/adapters/copilot-base.js +297 -0
  40. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  41. package/build/adapters/copilot-cli/hooks.js +64 -0
  42. package/build/adapters/copilot-cli/index.d.ts +48 -0
  43. package/build/adapters/copilot-cli/index.js +341 -0
  44. package/build/adapters/cursor/config.d.ts +4 -0
  45. package/build/adapters/cursor/config.js +4 -0
  46. package/build/adapters/cursor/hooks.d.ts +55 -0
  47. package/build/adapters/cursor/hooks.js +82 -0
  48. package/build/adapters/cursor/index.d.ts +64 -0
  49. package/build/adapters/cursor/index.js +517 -0
  50. package/build/adapters/detect.d.ts +136 -0
  51. package/build/adapters/detect.js +623 -0
  52. package/build/adapters/gemini-cli/config.d.ts +8 -0
  53. package/build/adapters/gemini-cli/config.js +8 -0
  54. package/build/adapters/gemini-cli/hooks.d.ts +83 -0
  55. package/build/adapters/gemini-cli/hooks.js +115 -0
  56. package/build/adapters/gemini-cli/index.d.ts +80 -0
  57. package/build/adapters/gemini-cli/index.js +483 -0
  58. package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
  59. package/build/adapters/jetbrains-copilot/config.js +8 -0
  60. package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
  61. package/build/adapters/jetbrains-copilot/hooks.js +87 -0
  62. package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
  63. package/build/adapters/jetbrains-copilot/index.js +131 -0
  64. package/build/adapters/kimi/config.d.ts +8 -0
  65. package/build/adapters/kimi/config.js +8 -0
  66. package/build/adapters/kimi/hooks.d.ts +28 -0
  67. package/build/adapters/kimi/hooks.js +34 -0
  68. package/build/adapters/kimi/index.d.ts +66 -0
  69. package/build/adapters/kimi/index.js +537 -0
  70. package/build/adapters/kimi/paths.d.ts +21 -0
  71. package/build/adapters/kimi/paths.js +52 -0
  72. package/build/adapters/kimi/usage.d.ts +82 -0
  73. package/build/adapters/kimi/usage.js +217 -0
  74. package/build/adapters/kiro/hooks.d.ts +61 -0
  75. package/build/adapters/kiro/hooks.js +94 -0
  76. package/build/adapters/kiro/index.d.ts +61 -0
  77. package/build/adapters/kiro/index.js +301 -0
  78. package/build/adapters/omp/index.d.ts +65 -0
  79. package/build/adapters/omp/index.js +182 -0
  80. package/build/adapters/omp/plugin.d.ts +81 -0
  81. package/build/adapters/omp/plugin.js +331 -0
  82. package/build/adapters/omp/usage.d.ts +49 -0
  83. package/build/adapters/omp/usage.js +110 -0
  84. package/build/adapters/openclaw/config.d.ts +8 -0
  85. package/build/adapters/openclaw/config.js +8 -0
  86. package/build/adapters/openclaw/hooks.d.ts +50 -0
  87. package/build/adapters/openclaw/hooks.js +61 -0
  88. package/build/adapters/openclaw/index.d.ts +74 -0
  89. package/build/adapters/openclaw/index.js +439 -0
  90. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  91. package/build/adapters/openclaw/mcp-tools.js +207 -0
  92. package/build/adapters/openclaw/plugin.d.ts +141 -0
  93. package/build/adapters/openclaw/plugin.js +818 -0
  94. package/build/adapters/openclaw/session-db.d.ts +55 -0
  95. package/build/adapters/openclaw/session-db.js +88 -0
  96. package/build/adapters/openclaw/usage.d.ts +34 -0
  97. package/build/adapters/openclaw/usage.js +52 -0
  98. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  99. package/build/adapters/openclaw/workspace-router.js +64 -0
  100. package/build/adapters/opencode/config.d.ts +8 -0
  101. package/build/adapters/opencode/config.js +8 -0
  102. package/build/adapters/opencode/hooks.d.ts +38 -0
  103. package/build/adapters/opencode/hooks.js +50 -0
  104. package/build/adapters/opencode/index.d.ts +144 -0
  105. package/build/adapters/opencode/index.js +638 -0
  106. package/build/adapters/opencode/plugin.d.ts +249 -0
  107. package/build/adapters/opencode/plugin.js +1536 -0
  108. package/build/adapters/opencode/v2.d.ts +117 -0
  109. package/build/adapters/opencode/v2.js +224 -0
  110. package/build/adapters/opencode/zod3tov4.d.ts +1 -0
  111. package/build/adapters/opencode/zod3tov4.js +132 -0
  112. package/build/adapters/pi/extension.d.ts +81 -0
  113. package/build/adapters/pi/extension.js +861 -0
  114. package/build/adapters/pi/index.d.ts +57 -0
  115. package/build/adapters/pi/index.js +173 -0
  116. package/build/adapters/pi/mcp-bridge.d.ts +268 -0
  117. package/build/adapters/pi/mcp-bridge.js +894 -0
  118. package/build/adapters/qwen-code/hooks.d.ts +26 -0
  119. package/build/adapters/qwen-code/hooks.js +29 -0
  120. package/build/adapters/qwen-code/index.d.ts +40 -0
  121. package/build/adapters/qwen-code/index.js +361 -0
  122. package/build/adapters/qwen-code/usage.d.ts +90 -0
  123. package/build/adapters/qwen-code/usage.js +222 -0
  124. package/build/adapters/types.d.ts +360 -0
  125. package/build/adapters/types.js +118 -0
  126. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  127. package/build/adapters/vscode-copilot/config.js +8 -0
  128. package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
  129. package/build/adapters/vscode-copilot/hooks.js +98 -0
  130. package/build/adapters/vscode-copilot/index.d.ts +32 -0
  131. package/build/adapters/vscode-copilot/index.js +222 -0
  132. package/build/adapters/zed/index.d.ts +40 -0
  133. package/build/adapters/zed/index.js +163 -0
  134. package/build/cli.d.ts +34 -0
  135. package/build/cli.js +1828 -0
  136. package/build/db-base.d.ts +250 -0
  137. package/build/db-base.js +756 -0
  138. package/build/executor.d.ts +72 -0
  139. package/build/executor.js +678 -0
  140. package/build/exit-classify.d.ts +19 -0
  141. package/build/exit-classify.js +12 -0
  142. package/build/fetch/blocks.d.ts +145 -0
  143. package/build/fetch/blocks.js +244 -0
  144. package/build/fetch/extract.d.ts +68 -0
  145. package/build/fetch/extract.js +123 -0
  146. package/build/fetch/page-store.d.ts +66 -0
  147. package/build/fetch/page-store.js +174 -0
  148. package/build/fetch-cache.d.ts +13 -0
  149. package/build/fetch-cache.js +15 -0
  150. package/build/lifecycle.d.ts +124 -0
  151. package/build/lifecycle.js +278 -0
  152. package/build/package-identity.d.ts +26 -0
  153. package/build/package-identity.js +28 -0
  154. package/build/runPool.d.ts +36 -0
  155. package/build/runPool.js +51 -0
  156. package/build/runtime.d.ts +71 -0
  157. package/build/runtime.js +659 -0
  158. package/build/search/auto-memory.d.ts +47 -0
  159. package/build/search/auto-memory.js +170 -0
  160. package/build/search/ctx-search-schema.d.ts +90 -0
  161. package/build/search/ctx-search-schema.js +135 -0
  162. package/build/search/flood-guard.d.ts +57 -0
  163. package/build/search/flood-guard.js +80 -0
  164. package/build/search/unified.d.ts +56 -0
  165. package/build/search/unified.js +104 -0
  166. package/build/security.d.ts +206 -0
  167. package/build/security.js +780 -0
  168. package/build/server.d.ts +240 -0
  169. package/build/server.js +5034 -0
  170. package/build/session/analytics.d.ts +773 -0
  171. package/build/session/analytics.js +2420 -0
  172. package/build/session/db.d.ts +468 -0
  173. package/build/session/db.js +1330 -0
  174. package/build/session/error-classifier.d.ts +87 -0
  175. package/build/session/error-classifier.js +303 -0
  176. package/build/session/event-emit.d.ts +48 -0
  177. package/build/session/event-emit.js +101 -0
  178. package/build/session/extract.d.ts +333 -0
  179. package/build/session/extract.js +2688 -0
  180. package/build/session/model-prices.json +429 -0
  181. package/build/session/persist-tool-calls.d.ts +54 -0
  182. package/build/session/persist-tool-calls.js +105 -0
  183. package/build/session/pricing.d.ts +64 -0
  184. package/build/session/pricing.js +151 -0
  185. package/build/session/project-attribution.d.ts +73 -0
  186. package/build/session/project-attribution.js +245 -0
  187. package/build/session/purge.d.ts +138 -0
  188. package/build/session/purge.js +240 -0
  189. package/build/session/retrieval-marker.d.ts +39 -0
  190. package/build/session/retrieval-marker.js +65 -0
  191. package/build/session/snapshot.d.ts +46 -0
  192. package/build/session/snapshot.js +532 -0
  193. package/build/store-directory.d.ts +56 -0
  194. package/build/store-directory.js +254 -0
  195. package/build/store.d.ts +147 -0
  196. package/build/store.js +1714 -0
  197. package/build/truncate.d.ts +57 -0
  198. package/build/truncate.js +142 -0
  199. package/build/types.d.ts +117 -0
  200. package/build/types.js +20 -0
  201. package/build/util/claude-config.d.ts +32 -0
  202. package/build/util/claude-config.js +84 -0
  203. package/build/util/hook-config.d.ts +27 -0
  204. package/build/util/hook-config.js +76 -0
  205. package/build/util/jsonc.d.ts +14 -0
  206. package/build/util/jsonc.js +104 -0
  207. package/build/util/plugin-cache-integrity.d.ts +51 -0
  208. package/build/util/plugin-cache-integrity.js +146 -0
  209. package/build/util/project-dir.d.ts +149 -0
  210. package/build/util/project-dir.js +340 -0
  211. package/build/util/sibling-mcp.d.ts +79 -0
  212. package/build/util/sibling-mcp.js +181 -0
  213. package/cli.bundle.mjs +1495 -0
  214. package/configs/antigravity/GEMINI.md +68 -0
  215. package/configs/antigravity/mcp_config.json +7 -0
  216. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  217. package/configs/antigravity-cli/hooks.json +37 -0
  218. package/configs/antigravity-cli/mcp_config.json +10 -0
  219. package/configs/antigravity-cli/plugin.json +14 -0
  220. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  221. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  222. package/configs/claude-code/CLAUDE.md +91 -0
  223. package/configs/codex/AGENTS.md +99 -0
  224. package/configs/codex/config.toml +8 -0
  225. package/configs/codex/hooks.json +47 -0
  226. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  227. package/configs/copilot-cli/.mcp.json +12 -0
  228. package/configs/copilot-cli/README.md +47 -0
  229. package/configs/copilot-cli/hooks.json +41 -0
  230. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  231. package/configs/cursor/context-mode.mdc +76 -0
  232. package/configs/cursor/hooks.json +21 -0
  233. package/configs/cursor/mcp.json +7 -0
  234. package/configs/gemini-cli/GEMINI.md +88 -0
  235. package/configs/gemini-cli/mcp.json +7 -0
  236. package/configs/gemini-cli/settings.json +60 -0
  237. package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
  238. package/configs/jetbrains-copilot/hooks.json +16 -0
  239. package/configs/jetbrains-copilot/mcp.json +7 -0
  240. package/configs/kilo/AGENTS.md +84 -0
  241. package/configs/kilo/kilo.json +6 -0
  242. package/configs/kimi/hooks.json +54 -0
  243. package/configs/kiro/KIRO.md +84 -0
  244. package/configs/kiro/agent.json +18 -0
  245. package/configs/kiro/mcp.json +7 -0
  246. package/configs/omp/SYSTEM.md +85 -0
  247. package/configs/omp/mcp.json +7 -0
  248. package/configs/openclaw/AGENTS.md +87 -0
  249. package/configs/openclaw/openclaw.json +13 -0
  250. package/configs/opencode/AGENTS.md +84 -0
  251. package/configs/opencode/opencode.json +6 -0
  252. package/configs/pi/AGENTS.md +3 -0
  253. package/configs/qwen-code/QWEN.md +91 -0
  254. package/configs/vscode-copilot/copilot-instructions.md +80 -0
  255. package/configs/vscode-copilot/hooks.json +16 -0
  256. package/configs/vscode-copilot/mcp.json +7 -0
  257. package/configs/zed/AGENTS.md +68 -0
  258. package/hooks/antigravity-cli/payload.mjs +98 -0
  259. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  260. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  261. package/hooks/antigravity-cli/stop.mjs +58 -0
  262. package/hooks/auto-injection.mjs +102 -0
  263. package/hooks/cache-heal-utils.mjs +379 -0
  264. package/hooks/codex/platform.mjs +1 -0
  265. package/hooks/codex/posttooluse.mjs +66 -0
  266. package/hooks/codex/precompact.mjs +70 -0
  267. package/hooks/codex/pretooluse.mjs +42 -0
  268. package/hooks/codex/sessionstart.mjs +121 -0
  269. package/hooks/codex/stop.mjs +138 -0
  270. package/hooks/codex/userpromptsubmit.mjs +75 -0
  271. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  272. package/hooks/copilot-cli/precompact.mjs +66 -0
  273. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  274. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  275. package/hooks/copilot-cli/stop.mjs +59 -0
  276. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  277. package/hooks/core/codex-caps.mjs +112 -0
  278. package/hooks/core/formatters.mjs +356 -0
  279. package/hooks/core/mcp-ready.mjs +109 -0
  280. package/hooks/core/platform-detect.mjs +49 -0
  281. package/hooks/core/routing.mjs +1050 -0
  282. package/hooks/core/stdin.mjs +90 -0
  283. package/hooks/core/tool-naming.mjs +57 -0
  284. package/hooks/cursor/afteragentresponse.mjs +74 -0
  285. package/hooks/cursor/hooks.json +31 -0
  286. package/hooks/cursor/posttooluse.mjs +72 -0
  287. package/hooks/cursor/pretooluse.mjs +26 -0
  288. package/hooks/cursor/sessionstart.mjs +97 -0
  289. package/hooks/cursor/stop.mjs +48 -0
  290. package/hooks/ensure-deps.mjs +254 -0
  291. package/hooks/formatters/claude-code.mjs +113 -0
  292. package/hooks/formatters/cursor.mjs +37 -0
  293. package/hooks/formatters/gemini-cli.mjs +55 -0
  294. package/hooks/formatters/vscode-copilot.mjs +55 -0
  295. package/hooks/gemini-cli/aftermodel.mjs +70 -0
  296. package/hooks/gemini-cli/aftertool.mjs +61 -0
  297. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  298. package/hooks/gemini-cli/beforetool.mjs +27 -0
  299. package/hooks/gemini-cli/precompress.mjs +55 -0
  300. package/hooks/gemini-cli/sessionstart.mjs +137 -0
  301. package/hooks/heal-partial-install.mjs +712 -0
  302. package/hooks/hooks.json +143 -0
  303. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  304. package/hooks/jetbrains-copilot/precompact.mjs +55 -0
  305. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  306. package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
  307. package/hooks/kimi/platform.mjs +1 -0
  308. package/hooks/kimi/posttooluse.mjs +72 -0
  309. package/hooks/kimi/precompact.mjs +80 -0
  310. package/hooks/kimi/pretooluse.mjs +42 -0
  311. package/hooks/kimi/sessionend.mjs +61 -0
  312. package/hooks/kimi/sessionstart.mjs +113 -0
  313. package/hooks/kimi/stop.mjs +132 -0
  314. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  315. package/hooks/kiro/agentspawn.mjs +97 -0
  316. package/hooks/kiro/posttooluse.mjs +51 -0
  317. package/hooks/kiro/pretooluse.mjs +64 -0
  318. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  319. package/hooks/normalize-hooks.mjs +323 -0
  320. package/hooks/platform-bridge.mjs +340 -0
  321. package/hooks/posttooluse.mjs +225 -0
  322. package/hooks/precompact.mjs +100 -0
  323. package/hooks/pretooluse.mjs +227 -0
  324. package/hooks/qwen-code/platform.mjs +1 -0
  325. package/hooks/qwen-code/stop.mjs +168 -0
  326. package/hooks/routing-block.mjs +105 -0
  327. package/hooks/run-hook.mjs +95 -0
  328. package/hooks/security.bundle.mjs +2 -0
  329. package/hooks/session-attribution.bundle.mjs +1 -0
  330. package/hooks/session-db.bundle.mjs +145 -0
  331. package/hooks/session-directive.mjs +520 -0
  332. package/hooks/session-extract.bundle.mjs +3 -0
  333. package/hooks/session-helpers.mjs +428 -0
  334. package/hooks/session-loaders.mjs +448 -0
  335. package/hooks/session-snapshot.bundle.mjs +32 -0
  336. package/hooks/sessionstart.mjs +464 -0
  337. package/hooks/stop.mjs +82 -0
  338. package/hooks/suppress-stderr.mjs +25 -0
  339. package/hooks/userpromptsubmit.mjs +108 -0
  340. package/hooks/vscode-copilot/posttooluse.mjs +61 -0
  341. package/hooks/vscode-copilot/precompact.mjs +55 -0
  342. package/hooks/vscode-copilot/pretooluse.mjs +27 -0
  343. package/hooks/vscode-copilot/sessionstart.mjs +129 -0
  344. package/openclaw.plugin.json +38 -0
  345. package/package.json +132 -0
  346. package/scripts/heal-better-sqlite3.mjs +430 -0
  347. package/scripts/heal-installed-plugins.mjs +635 -0
  348. package/scripts/plugin-cache-integrity.mjs +248 -0
  349. package/scripts/postinstall.mjs +397 -0
  350. package/server.bundle.mjs +1421 -0
  351. package/server.js +5 -0
  352. package/skills/.ignore +7 -0
  353. package/skills/context-mode/SKILL.md +300 -0
  354. package/skills/context-mode/references/anti-patterns.md +283 -0
  355. package/skills/context-mode/references/patterns-javascript.md +298 -0
  356. package/skills/context-mode/references/patterns-python.md +304 -0
  357. package/skills/context-mode/references/patterns-shell.md +277 -0
  358. package/skills/ctx-doctor/SKILL.md +22 -0
  359. package/skills/ctx-index/SKILL.md +46 -0
  360. package/skills/ctx-insight/SKILL.md +24 -0
  361. package/skills/ctx-purge/SKILL.md +49 -0
  362. package/skills/ctx-search/SKILL.md +35 -0
  363. package/skills/ctx-stats/SKILL.md +26 -0
  364. package/skills/ctx-upgrade/SKILL.md +31 -0
  365. package/start.mjs +616 -0
package/build/store.js ADDED
@@ -0,0 +1,1714 @@
1
+ /**
2
+ * ContentStore — FTS5 BM25-based knowledge base for context-mode.
3
+ *
4
+ * Chunks markdown content by headings (keeping code blocks intact),
5
+ * stores in SQLite FTS5, and retrieves via BM25-ranked search.
6
+ *
7
+ * Use for documentation, API references, and any content where
8
+ * you need EXACT text later — not summaries.
9
+ */
10
+ var _a;
11
+ import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError } from "./db-base.js";
12
+ import { readFileSync, readdirSync, unlinkSync, existsSync, statSync, openSync, fstatSync, closeSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { walkDirectoryDetailed } from "./store-directory.js";
17
+ // ─────────────────────────────────────────────────────────
18
+ // Constants
19
+ // ─────────────────────────────────────────────────────────
20
+ const STOPWORDS = new Set([
21
+ "the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
22
+ "her", "was", "one", "our", "out", "has", "his", "how", "its", "may",
23
+ "new", "now", "old", "see", "way", "who", "did", "get", "got", "let",
24
+ "say", "she", "too", "use", "will", "with", "this", "that", "from",
25
+ "they", "been", "have", "many", "some", "them", "than", "each", "make",
26
+ "like", "just", "over", "such", "take", "into", "year", "your", "good",
27
+ "could", "would", "about", "which", "their", "there", "other", "after",
28
+ "should", "through", "also", "more", "most", "only", "very", "when",
29
+ "what", "then", "these", "those", "being", "does", "done", "both",
30
+ "same", "still", "while", "where", "here", "were", "much",
31
+ // Common in code/changelogs
32
+ "update", "updates", "updated", "deps", "dev", "tests", "test",
33
+ "add", "added", "fix", "fixed", "run", "running", "using",
34
+ ]);
35
+ // ─────────────────────────────────────────────────────────
36
+ // Helpers
37
+ // ─────────────────────────────────────────────────────────
38
+ /**
39
+ * Remove case-insensitive duplicate tokens while preserving the first
40
+ * occurrence's original casing. FTS5's unicode61 tokenizer lowercases on
41
+ * both sides, so `"Error" OR "error"` produces no extra recall — just
42
+ * redundant index lookups. Dedup keeps the compiled query minimal.
43
+ */
44
+ function dedupeTokens(tokens) {
45
+ const seen = new Set();
46
+ const out = [];
47
+ for (const t of tokens) {
48
+ const key = t.toLowerCase();
49
+ if (!seen.has(key)) {
50
+ seen.add(key);
51
+ out.push(t);
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ export function sanitizeQuery(query, mode = "AND") {
57
+ const words = dedupeTokens(query
58
+ .replace(/['"(){}[\]*:^~]/g, " ")
59
+ .split(/\s+/)
60
+ .filter((w) => w.length > 0 &&
61
+ !["AND", "OR", "NOT", "NEAR"].includes(w.toUpperCase())));
62
+ if (words.length === 0)
63
+ return '""';
64
+ // Filter stopwords to improve BM25 ranking — common terms like "update",
65
+ // "test", "fix" appear everywhere and dilute relevance scoring.
66
+ // Fall back to unfiltered words if ALL terms are stopwords.
67
+ const meaningful = words.filter((w) => !STOPWORDS.has(w.toLowerCase()));
68
+ const final = meaningful.length > 0 ? meaningful : words;
69
+ return final.map((w) => `"${w}"`).join(mode === "OR" ? " OR " : " ");
70
+ }
71
+ export function sanitizeTrigramQuery(query, mode = "AND") {
72
+ const cleaned = query.replace(/["'(){}[\]*:^~]/g, "").trim();
73
+ if (cleaned.length < 3)
74
+ return "";
75
+ const words = dedupeTokens(cleaned.split(/\s+/).filter((w) => w.length >= 3));
76
+ if (words.length === 0)
77
+ return "";
78
+ const meaningful = words.filter((w) => !STOPWORDS.has(w.toLowerCase()));
79
+ const final = meaningful.length > 0 ? meaningful : words;
80
+ return final.map((w) => `"${w}"`).join(mode === "OR" ? " OR " : " ");
81
+ }
82
+ function levenshtein(a, b) {
83
+ if (a.length === 0)
84
+ return b.length;
85
+ if (b.length === 0)
86
+ return a.length;
87
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
88
+ for (let i = 1; i <= a.length; i++) {
89
+ const curr = [i];
90
+ for (let j = 1; j <= b.length; j++) {
91
+ curr[j] =
92
+ a[i - 1] === b[j - 1]
93
+ ? prev[j - 1]
94
+ : 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]);
95
+ }
96
+ prev = curr;
97
+ }
98
+ return prev[b.length];
99
+ }
100
+ function maxEditDistance(wordLength) {
101
+ if (wordLength <= 4)
102
+ return 1;
103
+ if (wordLength <= 12)
104
+ return 2;
105
+ return 3;
106
+ }
107
+ // Oversized chunks (e.g., a 50KB section between two headings) hurt BM25
108
+ // length normalization and produce unwieldy search results. Split at paragraph
109
+ // boundaries when a chunk exceeds this cap.
110
+ const MAX_CHUNK_BYTES = 4096;
111
+ // Blank-line sectioning is used only for output that is *naturally* sectioned:
112
+ // at least a few sections, not an unbounded explosion, and no single section so
113
+ // large that the split is clearly not the real structure (those fall back to
114
+ // line-grouping). Sections that pass the heuristic but still exceed
115
+ // MAX_CHUNK_BYTES are sub-split so no persisted chunk breaks the cap.
116
+ const MIN_BLANK_LINE_SECTIONS = 3;
117
+ const MAX_BLANK_LINE_SECTIONS = 200;
118
+ const BLANK_SECTION_STRATEGY_MAX_BYTES = 5000;
119
+ // Number of leading characters of a chunk's first line used as its title.
120
+ const CHUNK_TITLE_MAX_CHARS = 80;
121
+ // When byte-splitting an oversized single line, prefer to break at a whitespace
122
+ // boundary for readability — but only if that boundary is past this fraction of
123
+ // the slice, otherwise we'd waste too much of the byte budget.
124
+ const WHITESPACE_BREAK_RATIO = 0.5;
125
+ // ─────────────────────────────────────────────────────────
126
+ // ContentStore
127
+ // ─────────────────────────────────────────────────────────
128
+ /**
129
+ * Remove stale DB files from previous sessions whose processes no longer exist.
130
+ */
131
+ export function cleanupStaleDBs() {
132
+ const dir = tmpdir();
133
+ let cleaned = 0;
134
+ try {
135
+ const files = readdirSync(dir);
136
+ for (const file of files) {
137
+ const match = file.match(/^context-mode-(\d+)\.db$/);
138
+ if (!match)
139
+ continue;
140
+ const pid = parseInt(match[1], 10);
141
+ if (pid === process.pid)
142
+ continue;
143
+ try {
144
+ process.kill(pid, 0);
145
+ }
146
+ catch {
147
+ const base = join(dir, file);
148
+ for (const suffix of ["", "-wal", "-shm"]) {
149
+ try {
150
+ unlinkSync(base + suffix);
151
+ }
152
+ catch { /* ignore */ }
153
+ }
154
+ cleaned++;
155
+ }
156
+ }
157
+ }
158
+ catch { /* ignore readdir errors */ }
159
+ return cleaned;
160
+ }
161
+ /**
162
+ * Check if a PID is still alive (not a zombie holding a WAL lock).
163
+ * Returns true if the process exists, false if it's dead.
164
+ */
165
+ function isProcessAlive(pid) {
166
+ try {
167
+ process.kill(pid, 0);
168
+ return true;
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ /**
175
+ * Clean up stale per-project content store DBs older than maxAgeDays.
176
+ * Scans the given directory for *.db files and checks mtime.
177
+ * Also detects zombie processes holding WAL locks — if a WAL file exists
178
+ * but the owning PID is dead, the DB files are cleaned up regardless of age.
179
+ */
180
+ export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
181
+ let cleaned = 0;
182
+ try {
183
+ if (!existsSync(contentDir))
184
+ return 0;
185
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
186
+ const files = readdirSync(contentDir).filter(f => f.endsWith(".db"));
187
+ for (const file of files) {
188
+ try {
189
+ const filePath = join(contentDir, file);
190
+ const mtime = statSync(filePath).mtimeMs;
191
+ let shouldClean = mtime < cutoff;
192
+ // Detect zombie processes holding WAL locks:
193
+ // If a WAL file exists, try to read the WAL header to extract the PID.
194
+ // WAL files from dead processes can block new connections.
195
+ if (!shouldClean) {
196
+ const walPath = filePath + "-wal";
197
+ if (existsSync(walPath)) {
198
+ try {
199
+ const walStat = statSync(walPath);
200
+ // If WAL file is non-empty and DB hasn't been modified in >1 hour,
201
+ // the owning process may be dead — check via mtime staleness
202
+ if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
203
+ shouldClean = true;
204
+ }
205
+ }
206
+ catch { /* ignore WAL check errors */ }
207
+ }
208
+ }
209
+ if (shouldClean) {
210
+ for (const suffix of ["", "-wal", "-shm"]) {
211
+ try {
212
+ unlinkSync(filePath + suffix);
213
+ }
214
+ catch { /* ignore */ }
215
+ }
216
+ cleaned++;
217
+ }
218
+ }
219
+ catch { /* ignore per-file errors */ }
220
+ }
221
+ }
222
+ catch { /* ignore readdir errors */ }
223
+ return cleaned;
224
+ }
225
+ // ── Proximity helpers (pure functions) ──
226
+ /** Find all positions of a term in text. */
227
+ function findAllPositions(text, term) {
228
+ const positions = [];
229
+ let idx = text.indexOf(term);
230
+ while (idx !== -1) {
231
+ positions.push(idx);
232
+ idx = text.indexOf(term, idx + 1);
233
+ }
234
+ return positions;
235
+ }
236
+ /**
237
+ * Count matched adjacent pairs across consecutive query terms.
238
+ * For each pair (term[i], term[i+1]), pairs each left position with at most one
239
+ * right position whose offset falls within `gap` chars of `p + len(term[i])`.
240
+ * `positionLists` must be sorted ascending (output of `findAllPositions` is).
241
+ * Each right position is consumed by at most one left, so `"foo foo bar"`
242
+ * counts 1 pair, not 2 — matches IR phrase-occurrence intent and avoids
243
+ * inflating boosts for repeated-token queries.
244
+ * Used by reranker to layer a frequency signal on top of minSpan proximity:
245
+ * 30-char gap covers natural prose without rewarding distant matches.
246
+ */
247
+ function countAdjacentPairs(positionLists, terms, gap = 30) {
248
+ if (positionLists.length < 2 || terms.length < 2)
249
+ return 0;
250
+ let total = 0;
251
+ const pairs = Math.min(positionLists.length, terms.length) - 1;
252
+ for (let i = 0; i < pairs; i++) {
253
+ const left = positionLists[i];
254
+ const right = positionLists[i + 1];
255
+ const leftLen = terms[i].length;
256
+ let j = 0;
257
+ for (const p of left) {
258
+ const minStart = p + leftLen;
259
+ const maxStart = minStart + gap;
260
+ while (j < right.length && right[j] < minStart)
261
+ j++;
262
+ if (j < right.length && right[j] <= maxStart) {
263
+ total++;
264
+ j++;
265
+ }
266
+ }
267
+ }
268
+ return total;
269
+ }
270
+ /**
271
+ * Find minimum span (window) covering at least one position from each list.
272
+ * Uses a sweep-line approach: advance the pointer at the current minimum.
273
+ */
274
+ function findMinSpan(positionLists) {
275
+ if (positionLists.length === 0)
276
+ return Infinity;
277
+ if (positionLists.length === 1)
278
+ return 0;
279
+ const sorted = positionLists;
280
+ const ptrs = new Array(sorted.length).fill(0);
281
+ let minSpan = Infinity;
282
+ while (true) {
283
+ let curMin = Infinity;
284
+ let curMax = -Infinity;
285
+ let minIdx = 0;
286
+ for (let i = 0; i < sorted.length; i++) {
287
+ const val = sorted[i][ptrs[i]];
288
+ if (val < curMin) {
289
+ curMin = val;
290
+ minIdx = i;
291
+ }
292
+ if (val > curMax) {
293
+ curMax = val;
294
+ }
295
+ }
296
+ const span = curMax - curMin;
297
+ if (span < minSpan)
298
+ minSpan = span;
299
+ ptrs[minIdx]++;
300
+ if (ptrs[minIdx] >= sorted[minIdx].length)
301
+ break;
302
+ }
303
+ return minSpan;
304
+ }
305
+ export class ContentStore {
306
+ #db;
307
+ #dbPath;
308
+ // Optional deny-policy callback. When set (by server.ts at startup),
309
+ // #refreshStaleSources consults it before re-reading file_path during
310
+ // auto-refresh. This catches policy edits between initial indexing and
311
+ // a later search: a file that was allowed at index time may have been
312
+ // added to the Read deny list afterwards. Without this hook, refresh
313
+ // would re-read and re-expose the file. See #442 round-3.
314
+ #denyChecker;
315
+ // ── Cached Prepared Statements ──
316
+ // Prepared once at construction, reused on every call to avoid
317
+ // re-compiling SQL on each invocation.
318
+ // Write path
319
+ #stmtInsertSourceEmpty;
320
+ #stmtInsertSource;
321
+ #stmtInsertChunk;
322
+ #stmtInsertChunkTrigram;
323
+ #stmtInsertVocab;
324
+ // Dedup path (delete previous source with same label before re-indexing)
325
+ #stmtDeleteChunksByLabel;
326
+ #stmtDeleteChunksTrigramByLabel;
327
+ #stmtDeleteSourcesByLabel;
328
+ // Search path (hot)
329
+ #stmtSearchPorter;
330
+ #stmtSearchPorterFiltered;
331
+ #stmtSearchPorterExact;
332
+ #stmtSearchTrigram;
333
+ #stmtSearchTrigramFiltered;
334
+ #stmtSearchTrigramExact;
335
+ #stmtFuzzyVocab;
336
+ #stmtSearchPorterContentType;
337
+ #stmtSearchPorterFilteredContentType;
338
+ #stmtSearchPorterExactContentType;
339
+ #stmtSearchTrigramContentType;
340
+ #stmtSearchTrigramFilteredContentType;
341
+ #stmtSearchTrigramExactContentType;
342
+ // Read path
343
+ #stmtListSources;
344
+ #stmtChunksBySource;
345
+ #stmtSourceChunkCount;
346
+ #stmtChunkContent;
347
+ #stmtStats;
348
+ #stmtSourceMeta;
349
+ // Cleanup path
350
+ #stmtCleanupChunks;
351
+ #stmtCleanupChunksTrigram;
352
+ #stmtCleanupSources;
353
+ // FTS5 optimization: track inserts and optimize periodically to defragment
354
+ // the index. FTS5 b-trees fragment over many insert/delete cycles, degrading
355
+ // search performance. SQLite's built-in 'optimize' merges b-tree segments.
356
+ #insertCount = 0;
357
+ static OPTIMIZE_EVERY = 50;
358
+ // Fuzzy correction cache (process-local LRU). fuzzyCorrect() hits the vocab
359
+ // DB and runs levenshtein against every candidate within length tolerance,
360
+ // which is CPU-linear in |candidates|. Repeated queries ("erro", "erro" …)
361
+ // recompute the same answer. The vocabulary table is insert-only, so cache
362
+ // entries only become stale when new words enter — we clear on actual insert.
363
+ #fuzzyCache = new Map();
364
+ static FUZZY_CACHE_SIZE = 256;
365
+ constructor(dbPath) {
366
+ const Database = loadDatabase();
367
+ this.#dbPath =
368
+ dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
369
+ cleanOrphanedWALFiles(this.#dbPath);
370
+ let db;
371
+ try {
372
+ db = new Database(this.#dbPath, { timeout: 30000 });
373
+ applyWALPragmas(db);
374
+ }
375
+ catch (err) {
376
+ const msg = err instanceof Error ? err.message : String(err);
377
+ if (isSQLiteCorruptionError(msg)) {
378
+ deleteDBFiles(this.#dbPath);
379
+ cleanOrphanedWALFiles(this.#dbPath);
380
+ try {
381
+ db = new Database(this.#dbPath, { timeout: 30000 });
382
+ applyWALPragmas(db);
383
+ }
384
+ catch (retryErr) {
385
+ throw new Error(`Failed to create fresh DB after deleting corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
386
+ }
387
+ }
388
+ else {
389
+ throw err;
390
+ }
391
+ }
392
+ this.#db = db;
393
+ this.#initSchema();
394
+ this.#prepareStatements();
395
+ }
396
+ /** Delete this session's DB files. Call on process exit. */
397
+ cleanup() {
398
+ try {
399
+ this.#db.close();
400
+ }
401
+ catch { /* ignore */ }
402
+ for (const suffix of ["", "-wal", "-shm"]) {
403
+ try {
404
+ unlinkSync(this.#dbPath + suffix);
405
+ }
406
+ catch { /* ignore */ }
407
+ }
408
+ }
409
+ // ── Schema ──
410
+ #initSchema() {
411
+ this.#db.exec(`
412
+ CREATE TABLE IF NOT EXISTS sources (
413
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
414
+ label TEXT NOT NULL,
415
+ chunk_count INTEGER NOT NULL DEFAULT 0,
416
+ code_chunk_count INTEGER NOT NULL DEFAULT 0,
417
+ indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
418
+ file_path TEXT,
419
+ content_hash TEXT
420
+ );
421
+
422
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
423
+ title,
424
+ content,
425
+ source_id UNINDEXED,
426
+ content_type UNINDEXED,
427
+ source_category UNINDEXED,
428
+ session_id UNINDEXED,
429
+ event_id UNINDEXED,
430
+ timestamp UNINDEXED,
431
+ tokenize='porter unicode61'
432
+ );
433
+
434
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_trigram USING fts5(
435
+ title,
436
+ content,
437
+ source_id UNINDEXED,
438
+ content_type UNINDEXED,
439
+ source_category UNINDEXED,
440
+ session_id UNINDEXED,
441
+ event_id UNINDEXED,
442
+ timestamp UNINDEXED,
443
+ tokenize='trigram'
444
+ );
445
+
446
+ CREATE TABLE IF NOT EXISTS vocabulary (
447
+ word TEXT PRIMARY KEY
448
+ );
449
+
450
+ CREATE INDEX IF NOT EXISTS idx_sources_label ON sources(label);
451
+ `);
452
+ // FTS5 schema migration: old schema (4 cols) → new schema (8 cols).
453
+ // FTS5 virtual tables do not support ALTER TABLE ADD COLUMN, so we must
454
+ // DROP + re-CREATE. Detection: check for sentinel column `source_category`
455
+ // via pragma_table_xinfo. Three states:
456
+ // 1. No table → CREATE above handled it (fresh DB)
457
+ // 2. Old schema (4 cols) → DROP + CREATE new
458
+ // 3. New schema (8 cols) → do nothing
459
+ try {
460
+ const cols = this.#db.prepare("SELECT name FROM pragma_table_xinfo('chunks')").all();
461
+ const colNames = new Set(cols.map(c => c.name));
462
+ if (cols.length > 0 && !colNames.has("source_category")) {
463
+ // Old schema detected — drop both FTS5 tables and re-create with new columns
464
+ this.#db.exec("DROP TABLE IF EXISTS chunks");
465
+ this.#db.exec("DROP TABLE IF EXISTS chunks_trigram");
466
+ this.#db.exec(`
467
+ CREATE VIRTUAL TABLE chunks USING fts5(
468
+ title,
469
+ content,
470
+ source_id UNINDEXED,
471
+ content_type UNINDEXED,
472
+ source_category UNINDEXED,
473
+ session_id UNINDEXED,
474
+ event_id UNINDEXED,
475
+ timestamp UNINDEXED,
476
+ tokenize='porter unicode61'
477
+ );
478
+ CREATE VIRTUAL TABLE chunks_trigram USING fts5(
479
+ title,
480
+ content,
481
+ source_id UNINDEXED,
482
+ content_type UNINDEXED,
483
+ source_category UNINDEXED,
484
+ session_id UNINDEXED,
485
+ event_id UNINDEXED,
486
+ timestamp UNINDEXED,
487
+ tokenize='trigram'
488
+ );
489
+ `);
490
+ }
491
+ }
492
+ catch { /* pragma_table_xinfo may fail if table doesn't exist yet — safe to ignore */ }
493
+ // Stale detection columns — safe for existing DBs (ALTER is O(1) in SQLite)
494
+ try {
495
+ this.#db.exec("ALTER TABLE sources ADD COLUMN file_path TEXT");
496
+ }
497
+ catch { /* already exists */ }
498
+ try {
499
+ this.#db.exec("ALTER TABLE sources ADD COLUMN content_hash TEXT");
500
+ }
501
+ catch { /* already exists */ }
502
+ }
503
+ #prepareStatements() {
504
+ // Write path
505
+ this.#stmtInsertSourceEmpty = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count, file_path, content_hash) VALUES (?, 0, 0, ?, ?)");
506
+ this.#stmtInsertSource = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count, file_path, content_hash) VALUES (?, ?, ?, ?, ?)");
507
+ this.#stmtInsertChunk = this.#db.prepare("INSERT INTO chunks (title, content, source_id, content_type, source_category, session_id, event_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
508
+ this.#stmtInsertChunkTrigram = this.#db.prepare("INSERT INTO chunks_trigram (title, content, source_id, content_type, source_category, session_id, event_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
509
+ this.#stmtInsertVocab = this.#db.prepare("INSERT OR IGNORE INTO vocabulary (word) VALUES (?)");
510
+ // Dedup path: delete previous source with same label before re-indexing
511
+ // Prevents stale outputs from accumulating in iterative workflows (build-fix-build)
512
+ this.#stmtDeleteChunksByLabel = this.#db.prepare("DELETE FROM chunks WHERE source_id IN (SELECT id FROM sources WHERE label = ?)");
513
+ this.#stmtDeleteChunksTrigramByLabel = this.#db.prepare("DELETE FROM chunks_trigram WHERE source_id IN (SELECT id FROM sources WHERE label = ?)");
514
+ this.#stmtDeleteSourcesByLabel = this.#db.prepare("DELETE FROM sources WHERE label = ?");
515
+ // Search path (hot)
516
+ this.#stmtSearchPorter = this.#db.prepare(`
517
+ SELECT
518
+ chunks.title,
519
+ chunks.content,
520
+ chunks.content_type,
521
+ chunks.timestamp,
522
+ sources.label,
523
+ bm25(chunks, 5.0, 1.0) AS rank,
524
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
525
+ chunks.session_id
526
+ FROM chunks
527
+ JOIN sources ON sources.id = chunks.source_id
528
+ WHERE chunks MATCH ?
529
+ ORDER BY rank
530
+ LIMIT ?
531
+ `);
532
+ this.#stmtSearchPorterFiltered = this.#db.prepare(`
533
+ SELECT
534
+ chunks.title,
535
+ chunks.content,
536
+ chunks.content_type,
537
+ chunks.timestamp,
538
+ sources.label,
539
+ bm25(chunks, 5.0, 1.0) AS rank,
540
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
541
+ chunks.session_id
542
+ FROM chunks
543
+ JOIN sources ON sources.id = chunks.source_id
544
+ WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\'
545
+ ORDER BY rank
546
+ LIMIT ?
547
+ `);
548
+ this.#stmtSearchPorterExact = this.#db.prepare(`
549
+ SELECT
550
+ chunks.title,
551
+ chunks.content,
552
+ chunks.content_type,
553
+ chunks.timestamp,
554
+ sources.label,
555
+ bm25(chunks, 5.0, 1.0) AS rank,
556
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
557
+ chunks.session_id
558
+ FROM chunks
559
+ JOIN sources ON sources.id = chunks.source_id
560
+ WHERE chunks MATCH ? AND sources.label = ?
561
+ ORDER BY rank
562
+ LIMIT ?
563
+ `);
564
+ this.#stmtSearchTrigram = this.#db.prepare(`
565
+ SELECT
566
+ chunks_trigram.title,
567
+ chunks_trigram.content,
568
+ chunks_trigram.content_type,
569
+ chunks_trigram.timestamp,
570
+ sources.label,
571
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
572
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
573
+ chunks_trigram.session_id
574
+ FROM chunks_trigram
575
+ JOIN sources ON sources.id = chunks_trigram.source_id
576
+ WHERE chunks_trigram MATCH ?
577
+ ORDER BY rank
578
+ LIMIT ?
579
+ `);
580
+ this.#stmtSearchTrigramFiltered = this.#db.prepare(`
581
+ SELECT
582
+ chunks_trigram.title,
583
+ chunks_trigram.content,
584
+ chunks_trigram.content_type,
585
+ chunks_trigram.timestamp,
586
+ sources.label,
587
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
588
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
589
+ chunks_trigram.session_id
590
+ FROM chunks_trigram
591
+ JOIN sources ON sources.id = chunks_trigram.source_id
592
+ WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\'
593
+ ORDER BY rank
594
+ LIMIT ?
595
+ `);
596
+ this.#stmtSearchTrigramExact = this.#db.prepare(`
597
+ SELECT
598
+ chunks_trigram.title,
599
+ chunks_trigram.content,
600
+ chunks_trigram.content_type,
601
+ chunks_trigram.timestamp,
602
+ sources.label,
603
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
604
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
605
+ chunks_trigram.session_id
606
+ FROM chunks_trigram
607
+ JOIN sources ON sources.id = chunks_trigram.source_id
608
+ WHERE chunks_trigram MATCH ? AND sources.label = ?
609
+ ORDER BY rank
610
+ LIMIT ?
611
+ `);
612
+ // Content-type filtered variants
613
+ this.#stmtSearchPorterContentType = this.#db.prepare(`
614
+ SELECT
615
+ chunks.title,
616
+ chunks.content,
617
+ chunks.content_type,
618
+ chunks.timestamp,
619
+ sources.label,
620
+ bm25(chunks, 5.0, 1.0) AS rank,
621
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
622
+ chunks.session_id
623
+ FROM chunks
624
+ JOIN sources ON sources.id = chunks.source_id
625
+ WHERE chunks MATCH ? AND chunks.content_type = ?
626
+ ORDER BY rank
627
+ LIMIT ?
628
+ `);
629
+ this.#stmtSearchPorterFilteredContentType = this.#db.prepare(`
630
+ SELECT
631
+ chunks.title,
632
+ chunks.content,
633
+ chunks.content_type,
634
+ chunks.timestamp,
635
+ sources.label,
636
+ bm25(chunks, 5.0, 1.0) AS rank,
637
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
638
+ chunks.session_id
639
+ FROM chunks
640
+ JOIN sources ON sources.id = chunks.source_id
641
+ WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks.content_type = ?
642
+ ORDER BY rank
643
+ LIMIT ?
644
+ `);
645
+ this.#stmtSearchPorterExactContentType = this.#db.prepare(`
646
+ SELECT
647
+ chunks.title,
648
+ chunks.content,
649
+ chunks.content_type,
650
+ chunks.timestamp,
651
+ sources.label,
652
+ bm25(chunks, 5.0, 1.0) AS rank,
653
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
654
+ chunks.session_id
655
+ FROM chunks
656
+ JOIN sources ON sources.id = chunks.source_id
657
+ WHERE chunks MATCH ? AND sources.label = ? AND chunks.content_type = ?
658
+ ORDER BY rank
659
+ LIMIT ?
660
+ `);
661
+ this.#stmtSearchTrigramContentType = this.#db.prepare(`
662
+ SELECT
663
+ chunks_trigram.title,
664
+ chunks_trigram.content,
665
+ chunks_trigram.content_type,
666
+ chunks_trigram.timestamp,
667
+ sources.label,
668
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
669
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
670
+ chunks_trigram.session_id
671
+ FROM chunks_trigram
672
+ JOIN sources ON sources.id = chunks_trigram.source_id
673
+ WHERE chunks_trigram MATCH ? AND chunks_trigram.content_type = ?
674
+ ORDER BY rank
675
+ LIMIT ?
676
+ `);
677
+ this.#stmtSearchTrigramFilteredContentType = this.#db.prepare(`
678
+ SELECT
679
+ chunks_trigram.title,
680
+ chunks_trigram.content,
681
+ chunks_trigram.content_type,
682
+ chunks_trigram.timestamp,
683
+ sources.label,
684
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
685
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
686
+ chunks_trigram.session_id
687
+ FROM chunks_trigram
688
+ JOIN sources ON sources.id = chunks_trigram.source_id
689
+ WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks_trigram.content_type = ?
690
+ ORDER BY rank
691
+ LIMIT ?
692
+ `);
693
+ this.#stmtSearchTrigramExactContentType = this.#db.prepare(`
694
+ SELECT
695
+ chunks_trigram.title,
696
+ chunks_trigram.content,
697
+ chunks_trigram.content_type,
698
+ chunks_trigram.timestamp,
699
+ sources.label,
700
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
701
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
702
+ chunks_trigram.session_id
703
+ FROM chunks_trigram
704
+ JOIN sources ON sources.id = chunks_trigram.source_id
705
+ WHERE chunks_trigram MATCH ? AND sources.label = ? AND chunks_trigram.content_type = ?
706
+ ORDER BY rank
707
+ LIMIT ?
708
+ `);
709
+ // Fuzzy path
710
+ this.#stmtFuzzyVocab = this.#db.prepare("SELECT word FROM vocabulary WHERE length(word) BETWEEN ? AND ?");
711
+ // Read path
712
+ this.#stmtListSources = this.#db.prepare("SELECT label, chunk_count as chunkCount FROM sources ORDER BY id DESC");
713
+ this.#stmtChunksBySource = this.#db.prepare(`SELECT c.title, c.content, c.content_type, s.label
714
+ FROM chunks c
715
+ JOIN sources s ON s.id = c.source_id
716
+ WHERE c.source_id = ?
717
+ ORDER BY c.rowid`);
718
+ this.#stmtSourceChunkCount = this.#db.prepare("SELECT chunk_count FROM sources WHERE id = ?");
719
+ this.#stmtChunkContent = this.#db.prepare("SELECT content FROM chunks WHERE source_id = ?");
720
+ this.#stmtSourceMeta = this.#db.prepare("SELECT label, chunk_count, code_chunk_count, indexed_at, file_path, content_hash FROM sources WHERE label = ?");
721
+ this.#stmtStats = this.#db.prepare(`
722
+ SELECT
723
+ (SELECT COUNT(*) FROM sources) AS sources,
724
+ (SELECT COUNT(*) FROM chunks) AS chunks,
725
+ (SELECT COUNT(*) FROM chunks WHERE content_type = 'code') AS codeChunks
726
+ `);
727
+ // Cleanup path — cached to avoid recompiling SQL on each periodic call
728
+ this.#stmtCleanupChunks = this.#db.prepare("DELETE FROM chunks WHERE source_id IN (SELECT id FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days'))");
729
+ this.#stmtCleanupChunksTrigram = this.#db.prepare("DELETE FROM chunks_trigram WHERE source_id IN (SELECT id FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days'))");
730
+ this.#stmtCleanupSources = this.#db.prepare("DELETE FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days')");
731
+ }
732
+ // ── Deny Policy Hook ──
733
+ /**
734
+ * Register a deny-policy checker. When set, #refreshStaleSources
735
+ * calls it before re-reading any file_path during auto-refresh.
736
+ * Returning `true` causes the source to be skipped (kept in cache,
737
+ * not re-indexed). server.ts wires this to the Read deny patterns.
738
+ */
739
+ setDenyChecker(fn) {
740
+ this.#denyChecker = fn;
741
+ }
742
+ // ── Index ──
743
+ index(options) {
744
+ const { content, path, source, attribution } = options;
745
+ // Treat empty string as "no content" so an empty `content` paired with a
746
+ // valid `path` falls back to reading the file. Some MCP clients
747
+ // materialize optional string fields as `""` and the previous
748
+ // `content ?? readFileSync(path)` kept the empty string, indexing 0
749
+ // chunks. See issue #350.
750
+ const hasContent = typeof content === "string" && content.length > 0;
751
+ if (!hasContent && !path) {
752
+ throw new Error("Either content or path must be provided");
753
+ }
754
+ // Read file via fd to close the TOCTOU window between the security
755
+ // gate (security.ts evaluateFilePath calls realpathSync) and the read
756
+ // here. Lexical re-read by path string allowed an attacker to swap a
757
+ // symlink to a denied target (e.g. ~/.ssh/id_rsa) AFTER gate passed.
758
+ // openSync + fstat + readFileSync(fd) binds the read to the inode
759
+ // captured at gate-time. fstat also rejects non-regular files
760
+ // (directories, character devices) which would otherwise read as ""
761
+ // or throw inconsistently. See #442 round-3.
762
+ let text;
763
+ if (hasContent) {
764
+ text = content;
765
+ }
766
+ else {
767
+ const fd = openSync(path, "r");
768
+ try {
769
+ const st = fstatSync(fd);
770
+ if (!st.isFile()) {
771
+ throw new Error(`refusing to index ${path}: not a regular file`);
772
+ }
773
+ text = readFileSync(fd, "utf-8");
774
+ }
775
+ finally {
776
+ closeSync(fd);
777
+ }
778
+ }
779
+ const label = source ?? path ?? "untitled";
780
+ const chunks = this.#chunkMarkdown(text);
781
+ // Stale detection: store file_path + SHA-256 for file-backed sources
782
+ const filePath = path ?? undefined;
783
+ const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
784
+ return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
785
+ }
786
+ // ── Index Directory (#687) ──
787
+ /**
788
+ * Index every file under a directory by walking it with `walkDirectory` and
789
+ * delegating each discovered file to `this.index({ path })`. The per-file
790
+ * `openSync + fstatSync.isFile()` security gate at line ~845 stays active
791
+ * for every file — directory support never bypasses the TOCTOU defense
792
+ * from #442 round-3.
793
+ *
794
+ * Reported by @matiasduartee in #687.
795
+ */
796
+ indexDirectory(opts) {
797
+ const { path: rootPath, source, attribution, perFileDeny, ...walkOpts } = opts;
798
+ const walked = walkDirectoryDetailed(rootPath, walkOpts);
799
+ let filesIndexed = 0;
800
+ let totalChunks = 0;
801
+ let denied = 0;
802
+ let failed = 0;
803
+ for (const file of walked.files) {
804
+ if (perFileDeny && perFileDeny(file)) {
805
+ denied++;
806
+ continue;
807
+ }
808
+ try {
809
+ // Per-file source label so ctx_search(source: "<file>") still works.
810
+ const fileSource = source ? `${source}:${file}` : file;
811
+ const r = this.index({ path: file, source: fileSource, attribution });
812
+ filesIndexed++;
813
+ totalChunks += r.totalChunks;
814
+ }
815
+ catch {
816
+ // Per-file failure (e.g. fd-bound fstat rejection of a non-regular
817
+ // file that races between walk and read) — count + continue.
818
+ failed++;
819
+ }
820
+ }
821
+ return {
822
+ filesIndexed,
823
+ totalChunks,
824
+ capped: walked.capped,
825
+ totalSeen: walked.totalSeen,
826
+ denied,
827
+ failed,
828
+ label: source ?? rootPath,
829
+ };
830
+ }
831
+ // ── Index Plain Text ──
832
+ /**
833
+ * Index plain-text output (logs, build output, test results) by splitting
834
+ * into fixed-size line groups. Unlike markdown indexing, this does not
835
+ * look for headings — it chunks by line count with overlap.
836
+ */
837
+ indexPlainText(content, source, linesPerChunk = 20, attribution, maxChunkBytes = MAX_CHUNK_BYTES) {
838
+ if (!content || content.trim().length === 0) {
839
+ return this.#insertChunks([], source, "", undefined, undefined, attribution);
840
+ }
841
+ const chunks = this.#chunkPlainText(content, linesPerChunk, maxChunkBytes);
842
+ return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
843
+ }
844
+ // ── Index JSON ──
845
+ /**
846
+ * Index JSON content by walking the object tree and using key paths
847
+ * as chunk titles (analogous to heading hierarchy in markdown). Objects
848
+ * recurse by key; arrays batch items by size.
849
+ *
850
+ * Falls back to `indexPlainText` if the content is not valid JSON.
851
+ */
852
+ indexJSON(content, source, maxChunkBytes = MAX_CHUNK_BYTES, attribution) {
853
+ if (!content || content.trim().length === 0) {
854
+ return this.indexPlainText("", source, undefined, attribution, maxChunkBytes);
855
+ }
856
+ let parsed;
857
+ try {
858
+ parsed = JSON.parse(content);
859
+ }
860
+ catch {
861
+ return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
862
+ }
863
+ const chunks = [];
864
+ this.#walkJSON(parsed, [], chunks, maxChunkBytes);
865
+ if (chunks.length === 0) {
866
+ return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
867
+ }
868
+ return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
869
+ }
870
+ // ── Shared DB Insertion ──
871
+ /**
872
+ * Shared DB insertion logic for all index methods. Inserts chunks
873
+ * into both FTS5 tables within a transaction and extracts vocabulary.
874
+ * Uses cached prepared statements from #prepareStatements().
875
+ */
876
+ #insertChunks(chunks, label, text, filePath, contentHash, attribution) {
877
+ const codeChunks = chunks.filter((c) => c.hasCode).length;
878
+ // FK columns on chunks. Empty-string fallback preserves the FTS5-friendly
879
+ // "not-null but unattributed" sentinel used by legacy rows.
880
+ const sessionIdCol = attribution?.sessionId ?? "";
881
+ const eventIdCol = attribution?.eventId ?? "";
882
+ // Atomic dedup + insert: delete previous source with same label,
883
+ // then insert new content — all within a single transaction.
884
+ // Prevents stale results in iterative workflows. (See: GitHub issue #67)
885
+ const transaction = this.#db.transaction(() => {
886
+ this.#stmtDeleteChunksByLabel.run(label);
887
+ this.#stmtDeleteChunksTrigramByLabel.run(label);
888
+ this.#stmtDeleteSourcesByLabel.run(label);
889
+ if (chunks.length === 0) {
890
+ const info = this.#stmtInsertSourceEmpty.run(label, filePath ?? null, contentHash ?? null);
891
+ return Number(info.lastInsertRowid);
892
+ }
893
+ const info = this.#stmtInsertSource.run(label, chunks.length, codeChunks, filePath ?? null, contentHash ?? null);
894
+ const sourceId = Number(info.lastInsertRowid);
895
+ const now = new Date().toISOString();
896
+ for (const chunk of chunks) {
897
+ const ct = chunk.hasCode ? "code" : "prose";
898
+ this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
899
+ this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
900
+ }
901
+ return sourceId;
902
+ });
903
+ const sourceId = transaction();
904
+ if (text)
905
+ this.#extractAndStoreVocabulary(text);
906
+ // Periodically optimize FTS5 indexes to merge b-tree segments.
907
+ // Fragmentation accumulates over insert/delete cycles (dedup re-indexes
908
+ // every source on update). The 'optimize' command merges segments into
909
+ // a single b-tree, improving search latency for long-running sessions.
910
+ this.#insertCount++;
911
+ if (this.#insertCount % _a.OPTIMIZE_EVERY === 0) {
912
+ this.#optimizeFTS();
913
+ }
914
+ return {
915
+ sourceId,
916
+ label,
917
+ totalChunks: chunks.length,
918
+ codeChunks,
919
+ };
920
+ }
921
+ // ── Search ──
922
+ #mapSearchRows(rows) {
923
+ return rows.map((r) => ({
924
+ title: r.title,
925
+ content: r.content,
926
+ source: r.label,
927
+ rank: r.rank,
928
+ contentType: r.content_type,
929
+ highlighted: r.highlighted,
930
+ timestamp: r.timestamp ?? undefined,
931
+ sessionId: r.session_id ?? "",
932
+ }));
933
+ }
934
+ #sourceFilterParam(source, sourceMatchMode) {
935
+ if (sourceMatchMode === "exact")
936
+ return source;
937
+ // Escape SQLite LIKE metacharacters so user-supplied source labels
938
+ // containing `_`, `%`, or `\` are matched literally rather than as
939
+ // wildcards. Backslash must be replaced first (otherwise subsequent
940
+ // escapes would themselves be re-escaped). Paired with `ESCAPE '\'`
941
+ // in the four prepared LIKE statements (#stmtSearchPorter*,
942
+ // #stmtSearchTrigram*). Regression: #646.
943
+ const escaped = source
944
+ .replace(/\\/g, "\\\\")
945
+ .replace(/%/g, "\\%")
946
+ .replace(/_/g, "\\_");
947
+ return `%${escaped}%`;
948
+ }
949
+ search(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
950
+ const sanitized = sanitizeQuery(query, mode);
951
+ let stmt;
952
+ let params;
953
+ if (source && contentType) {
954
+ stmt = sourceMatchMode === "exact"
955
+ ? this.#stmtSearchPorterExactContentType
956
+ : this.#stmtSearchPorterFilteredContentType;
957
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
958
+ }
959
+ else if (source) {
960
+ stmt = sourceMatchMode === "exact"
961
+ ? this.#stmtSearchPorterExact
962
+ : this.#stmtSearchPorterFiltered;
963
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
964
+ }
965
+ else if (contentType) {
966
+ stmt = this.#stmtSearchPorterContentType;
967
+ params = [sanitized, contentType, limit];
968
+ }
969
+ else {
970
+ stmt = this.#stmtSearchPorter;
971
+ params = [sanitized, limit];
972
+ }
973
+ return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
974
+ }
975
+ // ── Trigram Search (Layer 2) ──
976
+ searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
977
+ const sanitized = sanitizeTrigramQuery(query, mode);
978
+ if (!sanitized)
979
+ return [];
980
+ let stmt;
981
+ let params;
982
+ if (source && contentType) {
983
+ stmt = sourceMatchMode === "exact"
984
+ ? this.#stmtSearchTrigramExactContentType
985
+ : this.#stmtSearchTrigramFilteredContentType;
986
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
987
+ }
988
+ else if (source) {
989
+ stmt = sourceMatchMode === "exact"
990
+ ? this.#stmtSearchTrigramExact
991
+ : this.#stmtSearchTrigramFiltered;
992
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
993
+ }
994
+ else if (contentType) {
995
+ stmt = this.#stmtSearchTrigramContentType;
996
+ params = [sanitized, contentType, limit];
997
+ }
998
+ else {
999
+ stmt = this.#stmtSearchTrigram;
1000
+ params = [sanitized, limit];
1001
+ }
1002
+ return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
1003
+ }
1004
+ // ── Fuzzy Correction (Layer 3) ──
1005
+ fuzzyCorrect(query) {
1006
+ const word = query.toLowerCase().trim();
1007
+ if (word.length < 3)
1008
+ return null;
1009
+ // Cache hit: promote to tail (Map preserves insertion order → LRU).
1010
+ if (this.#fuzzyCache.has(word)) {
1011
+ const cached = this.#fuzzyCache.get(word) ?? null;
1012
+ this.#fuzzyCache.delete(word);
1013
+ this.#fuzzyCache.set(word, cached);
1014
+ return cached;
1015
+ }
1016
+ const maxDist = maxEditDistance(word.length);
1017
+ const candidates = this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist);
1018
+ let bestWord = null;
1019
+ let bestDist = maxDist + 1;
1020
+ let exactMatch = false;
1021
+ for (const { word: candidate } of candidates) {
1022
+ if (candidate === word) {
1023
+ exactMatch = true;
1024
+ break;
1025
+ }
1026
+ const dist = levenshtein(word, candidate);
1027
+ if (dist < bestDist) {
1028
+ bestDist = dist;
1029
+ bestWord = candidate;
1030
+ }
1031
+ }
1032
+ const result = exactMatch ? null : bestDist <= maxDist ? bestWord : null;
1033
+ // Evict the oldest entry before insert if we hit the size cap.
1034
+ if (this.#fuzzyCache.size >= _a.FUZZY_CACHE_SIZE) {
1035
+ const oldestKey = this.#fuzzyCache.keys().next().value;
1036
+ if (oldestKey !== undefined)
1037
+ this.#fuzzyCache.delete(oldestKey);
1038
+ }
1039
+ this.#fuzzyCache.set(word, result);
1040
+ return result;
1041
+ }
1042
+ // ── Reciprocal Rank Fusion (Cormack et al. 2009) ──
1043
+ #rrfSearch(query, limit, source, contentType, sourceMatchMode = "like") {
1044
+ const K = 60; // Standard RRF constant
1045
+ const fetchLimit = Math.max(limit * 2, 10);
1046
+ const porterResults = this.search(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
1047
+ const trigramResults = this.searchTrigram(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
1048
+ const scoreMap = new Map();
1049
+ const key = (r) => `${r.source}::${r.title}`;
1050
+ for (const [i, r] of porterResults.entries()) {
1051
+ const k = key(r);
1052
+ const existing = scoreMap.get(k);
1053
+ if (existing) {
1054
+ existing.score += 1 / (K + i + 1);
1055
+ }
1056
+ else {
1057
+ scoreMap.set(k, { result: r, score: 1 / (K + i + 1) });
1058
+ }
1059
+ }
1060
+ for (const [i, r] of trigramResults.entries()) {
1061
+ const k = key(r);
1062
+ const existing = scoreMap.get(k);
1063
+ if (existing) {
1064
+ existing.score += 1 / (K + i + 1);
1065
+ }
1066
+ else {
1067
+ scoreMap.set(k, { result: r, score: 1 / (K + i + 1) });
1068
+ }
1069
+ }
1070
+ return Array.from(scoreMap.values())
1071
+ .sort((a, b) => b.score - a.score)
1072
+ .slice(0, limit)
1073
+ .map(({ result, score }) => ({ ...result, rank: -score }));
1074
+ }
1075
+ // ── Proximity Reranking ──
1076
+ #applyProximityReranking(results, query) {
1077
+ const allTerms = query
1078
+ .toLowerCase()
1079
+ .split(/\s+/)
1080
+ .filter((w) => w.length >= 2);
1081
+ // Exclude stopwords from proximity/title scoring — they match everywhere
1082
+ // and inflate boosts for irrelevant chunks. Keep all terms as fallback.
1083
+ const filtered = allTerms.filter((w) => !STOPWORDS.has(w));
1084
+ const terms = filtered.length > 0 ? filtered : allTerms;
1085
+ return results
1086
+ .map((r) => {
1087
+ // Title-match boost: query terms found in the chunk title get a boost.
1088
+ // Code chunks get a stronger title boost (function/class names are high
1089
+ // signal) while prose chunks get a moderate one (headings are useful but
1090
+ // body carries more weight).
1091
+ const titleLower = r.title.toLowerCase();
1092
+ const titleHits = terms.filter((t) => titleLower.includes(t)).length;
1093
+ const titleWeight = r.contentType === "code" ? 0.6 : 0.3;
1094
+ const titleBoost = titleHits > 0 ? titleWeight * (titleHits / terms.length) : 0;
1095
+ // Proximity boost for multi-term queries. minSpan picks the single
1096
+ // tightest window — frequency doesn't move it, so a long doc with one
1097
+ // tight occurrence outranks a short doc with several. Phrase-frequency
1098
+ // reward layers a saturating frequency signal on top: cap 0.5 (below
1099
+ // proximity max ≈1.0, in title-boost range), saturates at 4 hits.
1100
+ let proximityBoost = 0;
1101
+ let phraseBoost = 0;
1102
+ if (terms.length >= 2) {
1103
+ const content = r.content.toLowerCase();
1104
+ const positions = terms.map((t) => findAllPositions(content, t));
1105
+ if (!positions.some((p) => p.length === 0)) {
1106
+ const minSpan = findMinSpan(positions);
1107
+ proximityBoost = 1 / (1 + minSpan / Math.max(content.length, 1));
1108
+ const adjacentPairs = countAdjacentPairs(positions, terms);
1109
+ phraseBoost = 0.5 * Math.min(1, adjacentPairs / 4);
1110
+ }
1111
+ }
1112
+ return { result: r, boost: titleBoost + proximityBoost + phraseBoost };
1113
+ })
1114
+ .sort((a, b) => b.boost - a.boost || a.result.rank - b.result.rank)
1115
+ .map(({ result }) => result);
1116
+ }
1117
+ // ── Unified Fallback Search ──
1118
+ searchWithFallback(query, limit = 3, source, contentType, sourceMatchMode = "like", sessionIdAllowSet) {
1119
+ // Step 0: Auto-refresh stale file-backed sources before searching
1120
+ this.#refreshStaleSources();
1121
+ // When a session-id allow-set is in play (issue #737 project filter),
1122
+ // fetch a larger candidate pool from the FTS5 layers so the post-filter
1123
+ // can still deliver `limit` matches even if many candidates are excluded.
1124
+ // The cap is bounded — even at the largest installs the chunk count
1125
+ // dwarfs `limit * 8`, and the surplus is dropped on the post-filter.
1126
+ const fetchLimit = sessionIdAllowSet ? Math.max(limit * 8, 40) : limit;
1127
+ const sessionFilter = this.#makeSessionFilter(sessionIdAllowSet);
1128
+ // Step 1: RRF fusion (porter OR + trigram OR → merge)
1129
+ const rrfResults = this.#rrfSearch(query, fetchLimit, source, contentType, sourceMatchMode);
1130
+ const rrfFiltered = sessionFilter ? rrfResults.filter(sessionFilter) : rrfResults;
1131
+ if (rrfFiltered.length > 0) {
1132
+ const reranked = this.#applyProximityReranking(rrfFiltered.slice(0, limit), query);
1133
+ return reranked.map((r) => ({ ...r, matchLayer: "rrf" }));
1134
+ }
1135
+ // Step 2: Fuzzy correction → RRF re-run
1136
+ // Skip stopwords — they'll be filtered by sanitizeQuery anyway, and each
1137
+ // fuzzyCorrect call hits the vocab DB + runs levenshtein comparisons.
1138
+ const words = query
1139
+ .toLowerCase()
1140
+ .trim()
1141
+ .split(/\s+/)
1142
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w));
1143
+ const original = words.join(" ");
1144
+ const correctedWords = words.map((w) => this.fuzzyCorrect(w) ?? w);
1145
+ const correctedQuery = correctedWords.join(" ");
1146
+ if (correctedQuery !== original) {
1147
+ const fuzzyResults = this.#rrfSearch(correctedQuery, fetchLimit, source, contentType, sourceMatchMode);
1148
+ const fuzzyFiltered = sessionFilter ? fuzzyResults.filter(sessionFilter) : fuzzyResults;
1149
+ if (fuzzyFiltered.length > 0) {
1150
+ const reranked = this.#applyProximityReranking(fuzzyFiltered.slice(0, limit), correctedQuery);
1151
+ return reranked.map((r) => ({ ...r, matchLayer: "rrf-fuzzy" }));
1152
+ }
1153
+ }
1154
+ return [];
1155
+ }
1156
+ /**
1157
+ * Build the session-id post-filter for the FTS5 candidate pool. Legacy
1158
+ * chunks indexed before per-session attribution carry `session_id=''` and
1159
+ * stay visible across projects so user-indexed content remains reachable
1160
+ * after opting into the shared-DB mode (#737).
1161
+ */
1162
+ #makeSessionFilter(allowSet) {
1163
+ if (!allowSet)
1164
+ return null;
1165
+ return (r) => {
1166
+ const sid = r.sessionId ?? "";
1167
+ return sid === "" || allowSet.has(sid);
1168
+ };
1169
+ }
1170
+ /** Number of sources auto-refreshed in the last searchWithFallback call. */
1171
+ lastRefreshCount = 0;
1172
+ /**
1173
+ * Check all file-backed sources for staleness and auto re-index changed files.
1174
+ * Uses mtime as a fast gate — only computes SHA-256 when mtime has advanced
1175
+ * past indexed_at. Gracefully skips deleted files and non-file sources.
1176
+ */
1177
+ #refreshStaleSources() {
1178
+ this.lastRefreshCount = 0;
1179
+ const sources = this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all();
1180
+ for (const src of sources) {
1181
+ try {
1182
+ if (!existsSync(src.file_path))
1183
+ continue; // file deleted — keep cached results
1184
+ // Re-check deny policy before re-reading. The Read deny list may
1185
+ // have been edited after this source was originally indexed; a
1186
+ // file that was allowed then may now be denied. Without this
1187
+ // gate, refresh would happily re-read and re-expose it. #442 r3.
1188
+ if (this.#denyChecker && this.#denyChecker(src.file_path))
1189
+ continue;
1190
+ const mtime = statSync(src.file_path).mtime;
1191
+ const indexedAt = new Date(src.indexed_at + "Z");
1192
+ if (mtime <= indexedAt)
1193
+ continue; // file unchanged — fast path
1194
+ // mtime advanced — fd-bound read for hash + indexing in one go.
1195
+ // Open once, fstat, read from fd. Closes the swap-mid-flight
1196
+ // window between hash read and re-index. #442 round-3.
1197
+ const fd = openSync(src.file_path, "r");
1198
+ let newContent;
1199
+ try {
1200
+ const st = fstatSync(fd);
1201
+ if (!st.isFile())
1202
+ continue; // skip non-regular targets
1203
+ newContent = readFileSync(fd, "utf-8");
1204
+ }
1205
+ finally {
1206
+ closeSync(fd);
1207
+ }
1208
+ const newHash = createHash("sha256").update(newContent).digest("hex");
1209
+ if (newHash === src.content_hash)
1210
+ continue; // content identical — skip
1211
+ // File genuinely changed — re-index using already-read content
1212
+ // (avoids a second open/read race) but preserve file_path/hash
1213
+ // by going through index() which stores them. Since we pass
1214
+ // content, index() does NOT re-read; the bytes hashed above
1215
+ // are exactly the bytes indexed.
1216
+ this.index({ content: newContent, path: src.file_path, source: src.label });
1217
+ this.lastRefreshCount++;
1218
+ }
1219
+ catch {
1220
+ // Graceful degradation — never break search for stale detection
1221
+ }
1222
+ }
1223
+ }
1224
+ // ── Sources ──
1225
+ getSourceMeta(label) {
1226
+ const row = this.#stmtSourceMeta.get(label);
1227
+ if (!row)
1228
+ return null;
1229
+ return { label: row.label, chunkCount: row.chunk_count, codeChunkCount: row.code_chunk_count, indexedAt: row.indexed_at, filePath: row.file_path ?? null, contentHash: row.content_hash ?? null };
1230
+ }
1231
+ listSources() {
1232
+ return this.#stmtListSources.all();
1233
+ }
1234
+ /**
1235
+ * Aggregate snapshot of the persistent content store. Returns total
1236
+ * chunk count, source count, and the most recent indexed_at timestamp.
1237
+ * Used by ctx_stats so callers can see observability state in the same
1238
+ * round trip instead of inferring it from snapshot diffs.
1239
+ */
1240
+ getIndexState() {
1241
+ const row = this.#db
1242
+ .prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
1243
+ .get();
1244
+ return {
1245
+ totalChunks: row.total_chunks ?? 0,
1246
+ totalSources: row.total_sources ?? 0,
1247
+ lastIndexedAt: row.last_indexed_at ?? undefined,
1248
+ };
1249
+ }
1250
+ /**
1251
+ * Get all chunks for a given source by ID — bypasses FTS5 MATCH entirely.
1252
+ * Use this for inventory/listing where you need all sections, not search.
1253
+ */
1254
+ getChunksBySource(sourceId) {
1255
+ const rows = this.#stmtChunksBySource.all(sourceId);
1256
+ return rows.map((r) => ({
1257
+ title: r.title,
1258
+ content: r.content,
1259
+ source: r.label,
1260
+ rank: 0,
1261
+ contentType: r.content_type,
1262
+ }));
1263
+ }
1264
+ // ── Vocabulary ──
1265
+ getDistinctiveTerms(sourceId, maxTerms = 40) {
1266
+ const stats = this.#stmtSourceChunkCount.get(sourceId);
1267
+ if (!stats || stats.chunk_count < 3)
1268
+ return [];
1269
+ const totalChunks = stats.chunk_count;
1270
+ const minAppearances = 2;
1271
+ const maxAppearances = Math.max(3, Math.ceil(totalChunks * 0.4));
1272
+ // Stream chunks one at a time to avoid loading all content into memory
1273
+ // Count document frequency (how many sections contain each word)
1274
+ const docFreq = new Map();
1275
+ for (const row of this.#stmtChunkContent.iterate(sourceId)) {
1276
+ const words = new Set(row.content
1277
+ .toLowerCase()
1278
+ .split(/[^\p{L}\p{N}_-]+/u)
1279
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w)));
1280
+ for (const word of words) {
1281
+ docFreq.set(word, (docFreq.get(word) ?? 0) + 1);
1282
+ }
1283
+ }
1284
+ const filtered = Array.from(docFreq.entries())
1285
+ .filter(([, count]) => count >= minAppearances && count <= maxAppearances);
1286
+ // Score: IDF (rarity) + length bonus + identifier bonus (underscore/camelCase)
1287
+ const scored = filtered.map(([word, count]) => {
1288
+ const idf = Math.log(totalChunks / count);
1289
+ const lenBonus = Math.min(word.length / 20, 0.5);
1290
+ const hasSpecialChars = /[_]/.test(word);
1291
+ const isCamelOrLong = word.length >= 12;
1292
+ const identifierBonus = hasSpecialChars ? 1.5 : isCamelOrLong ? 0.8 : 0;
1293
+ return { word, score: idf + lenBonus + identifierBonus };
1294
+ });
1295
+ return scored
1296
+ .sort((a, b) => b.score - a.score)
1297
+ .slice(0, maxTerms)
1298
+ .map((s) => s.word);
1299
+ }
1300
+ // ── Stats ──
1301
+ getStats() {
1302
+ const row = this.#stmtStats.get();
1303
+ return {
1304
+ sources: row?.sources ?? 0,
1305
+ chunks: row?.chunks ?? 0,
1306
+ codeChunks: row?.codeChunks ?? 0,
1307
+ };
1308
+ }
1309
+ // ── Cleanup ──
1310
+ /**
1311
+ * Delete sources (and their chunks) older than maxAgeDays.
1312
+ * Returns count of deleted sources.
1313
+ */
1314
+ cleanupStaleSources(maxAgeDays) {
1315
+ const cleanup = this.#db.transaction((days) => {
1316
+ this.#stmtCleanupChunks.run(days);
1317
+ this.#stmtCleanupChunksTrigram.run(days);
1318
+ return this.#stmtCleanupSources.run(days);
1319
+ });
1320
+ const info = cleanup(maxAgeDays);
1321
+ return info.changes;
1322
+ }
1323
+ /** Get DB file size in bytes. */
1324
+ getDBSizeBytes() {
1325
+ try {
1326
+ return statSync(this.#dbPath).size;
1327
+ }
1328
+ catch {
1329
+ return 0;
1330
+ }
1331
+ }
1332
+ /** Merge FTS5 b-tree segments for both porter and trigram indexes. */
1333
+ #optimizeFTS() {
1334
+ try {
1335
+ this.#db.exec("INSERT INTO chunks(chunks) VALUES('optimize')");
1336
+ this.#db.exec("INSERT INTO chunks_trigram(chunks_trigram) VALUES('optimize')");
1337
+ }
1338
+ catch { /* best effort — don't block indexing */ }
1339
+ }
1340
+ close() {
1341
+ this.#optimizeFTS(); // defragment before close
1342
+ closeDB(this.#db); // safe close — no close-time checkpoint (cross-process mutation, see db-base.ts closeDB)
1343
+ }
1344
+ // ── Vocabulary Extraction ──
1345
+ #extractAndStoreVocabulary(content) {
1346
+ const words = content
1347
+ .toLowerCase()
1348
+ .split(/[^\p{L}\p{N}_-]+/u)
1349
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w));
1350
+ const unique = [...new Set(words)];
1351
+ let inserted = 0;
1352
+ this.#db.transaction(() => {
1353
+ for (const word of unique) {
1354
+ const info = this.#stmtInsertVocab.run(word);
1355
+ inserted += info.changes;
1356
+ }
1357
+ })();
1358
+ // Invalidate fuzzy cache when new vocab words actually land. INSERT OR
1359
+ // IGNORE reports changes=0 for duplicates, so re-indexing identical
1360
+ // content does not thrash the cache during iterative workflows.
1361
+ if (inserted > 0)
1362
+ this.#fuzzyCache.clear();
1363
+ }
1364
+ // ── Chunking ──
1365
+ #chunkMarkdown(text, maxChunkBytes = MAX_CHUNK_BYTES) {
1366
+ const chunks = [];
1367
+ const lines = text.split("\n");
1368
+ const headingStack = [];
1369
+ let currentContent = [];
1370
+ let currentHeading = "";
1371
+ const flush = () => {
1372
+ const joined = currentContent.join("\n").trim();
1373
+ if (joined.length === 0)
1374
+ return;
1375
+ const title = this.#buildTitle(headingStack, currentHeading);
1376
+ const hasCode = currentContent.some((l) => /^`{3,}/.test(l));
1377
+ // If under the cap, emit as-is (fast path — most chunks hit this)
1378
+ if (Buffer.byteLength(joined) <= maxChunkBytes) {
1379
+ chunks.push({ title, content: joined, hasCode });
1380
+ currentContent = [];
1381
+ return;
1382
+ }
1383
+ // Split oversized chunk at paragraph boundaries (double newlines)
1384
+ const paragraphs = joined.split(/\n\n+/);
1385
+ let accumulator = [];
1386
+ let partIndex = 1;
1387
+ const flushAccumulator = () => {
1388
+ if (accumulator.length === 0)
1389
+ return;
1390
+ const part = accumulator.join("\n\n").trim();
1391
+ if (part.length === 0)
1392
+ return;
1393
+ const partTitle = paragraphs.length > 1 ? `${title} (${partIndex})` : title;
1394
+ partIndex++;
1395
+ chunks.push({
1396
+ title: partTitle,
1397
+ content: part,
1398
+ hasCode: part.includes("```"),
1399
+ });
1400
+ accumulator = [];
1401
+ };
1402
+ for (const para of paragraphs) {
1403
+ accumulator.push(para);
1404
+ const candidate = accumulator.join("\n\n");
1405
+ if (Buffer.byteLength(candidate) > maxChunkBytes && accumulator.length > 1) {
1406
+ accumulator.pop();
1407
+ flushAccumulator();
1408
+ accumulator = [para];
1409
+ }
1410
+ }
1411
+ flushAccumulator();
1412
+ currentContent = [];
1413
+ };
1414
+ let i = 0;
1415
+ while (i < lines.length) {
1416
+ const line = lines[i];
1417
+ // Horizontal rule separator (Context7 uses long dashes)
1418
+ if (/^[-_*]{3,}\s*$/.test(line)) {
1419
+ flush();
1420
+ i++;
1421
+ continue;
1422
+ }
1423
+ // Heading (H1-H4)
1424
+ const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
1425
+ if (headingMatch) {
1426
+ flush();
1427
+ const level = headingMatch[1].length;
1428
+ const heading = headingMatch[2].trim();
1429
+ // Pop deeper levels from stack
1430
+ while (headingStack.length > 0 &&
1431
+ headingStack[headingStack.length - 1].level >= level) {
1432
+ headingStack.pop();
1433
+ }
1434
+ headingStack.push({ level, text: heading });
1435
+ currentHeading = heading;
1436
+ currentContent.push(line);
1437
+ i++;
1438
+ continue;
1439
+ }
1440
+ // Code block — collect entire block as a unit
1441
+ const codeMatch = line.match(/^(`{3,})(.*)?$/);
1442
+ if (codeMatch) {
1443
+ const fence = codeMatch[1];
1444
+ const codeLines = [line];
1445
+ i++;
1446
+ while (i < lines.length) {
1447
+ codeLines.push(lines[i]);
1448
+ if (lines[i].startsWith(fence) && lines[i].trim() === fence) {
1449
+ i++;
1450
+ break;
1451
+ }
1452
+ i++;
1453
+ }
1454
+ currentContent.push(...codeLines);
1455
+ continue;
1456
+ }
1457
+ // Regular line
1458
+ currentContent.push(line);
1459
+ i++;
1460
+ }
1461
+ // Flush remaining content
1462
+ flush();
1463
+ return chunks;
1464
+ }
1465
+ /**
1466
+ * Return the largest prefix of `str` whose UTF-8 byte length does not exceed
1467
+ * `maxBytes`, walking by Unicode code point so multibyte sequences (CJK) and
1468
+ * surrogate pairs (emoji) are never cut mid-character. Guarantees forward
1469
+ * progress: if even the first code point exceeds `maxBytes`, it is still
1470
+ * returned whole (a 1-4 byte overshoot beats an infinite loop).
1471
+ */
1472
+ #byteCappedPrefix(str, maxBytes) {
1473
+ if (Buffer.byteLength(str) <= maxBytes)
1474
+ return str;
1475
+ let prefix = "";
1476
+ let bytes = 0;
1477
+ for (const char of str) {
1478
+ const charBytes = Buffer.byteLength(char);
1479
+ if (bytes + charBytes > maxBytes)
1480
+ break;
1481
+ prefix += char;
1482
+ bytes += charBytes;
1483
+ }
1484
+ // Defensive: a single code point wider than the cap (only possible with a
1485
+ // pathologically small maxBytes) still advances by one character.
1486
+ if (prefix.length === 0)
1487
+ return [...str][0] ?? "";
1488
+ return prefix;
1489
+ }
1490
+ /**
1491
+ * Split a single oversized plain-text chunk into byte-capped sub-chunks
1492
+ * by accumulating lines until the byte count would exceed maxChunkBytes.
1493
+ * Falls back to byte-accurate splitting for extremely long single lines.
1494
+ */
1495
+ #splitOversizedPlainChunk(lines, titlePrefix, maxChunkBytes) {
1496
+ const subChunks = [];
1497
+ let accumulator = [];
1498
+ let partIndex = 1;
1499
+ const flushAccumulator = () => {
1500
+ if (accumulator.length === 0)
1501
+ return;
1502
+ const content = accumulator.join("\n");
1503
+ const partTitle = partIndex === 1 ? titlePrefix : `${titlePrefix} (${partIndex})`;
1504
+ subChunks.push({ title: partTitle, content });
1505
+ partIndex++;
1506
+ accumulator = [];
1507
+ };
1508
+ for (const line of lines) {
1509
+ // If a single line itself exceeds the cap (even as first line),
1510
+ // split it by character before accumulating
1511
+ if (Buffer.byteLength(line) > maxChunkBytes) {
1512
+ flushAccumulator();
1513
+ // Split the long line into byte-capped pieces
1514
+ let remaining = line;
1515
+ let linePart = 1;
1516
+ while (remaining.length > 0) {
1517
+ // Byte-accurate slice: never exceeds the cap, never cuts a multibyte
1518
+ // character (CJK) or surrogate pair (emoji) in half.
1519
+ let slice = this.#byteCappedPrefix(remaining, maxChunkBytes);
1520
+ // Try to break at a whitespace boundary near the end for readability,
1521
+ // but only when text remains after this slice.
1522
+ if (slice.length < remaining.length) {
1523
+ const lastSpace = slice.lastIndexOf(" ");
1524
+ const lastNewline = slice.lastIndexOf("\n");
1525
+ const breakPoint = Math.max(lastSpace, lastNewline);
1526
+ if (breakPoint > slice.length * WHITESPACE_BREAK_RATIO) {
1527
+ slice = slice.slice(0, breakPoint);
1528
+ }
1529
+ }
1530
+ const linePartTitle = partIndex === 1 && linePart === 1
1531
+ ? titlePrefix
1532
+ : `${titlePrefix} (${partIndex}.${linePart})`;
1533
+ subChunks.push({ title: linePartTitle, content: slice });
1534
+ remaining = remaining.slice(slice.length);
1535
+ linePart++;
1536
+ partIndex++;
1537
+ }
1538
+ continue;
1539
+ }
1540
+ const candidate = accumulator.length > 0
1541
+ ? accumulator.join("\n") + "\n" + line
1542
+ : line;
1543
+ // If adding this line would exceed the cap, flush accumulator first
1544
+ if (Buffer.byteLength(candidate) > maxChunkBytes && accumulator.length > 0) {
1545
+ flushAccumulator();
1546
+ }
1547
+ accumulator.push(line);
1548
+ }
1549
+ flushAccumulator();
1550
+ return subChunks;
1551
+ }
1552
+ #chunkPlainText(text, linesPerChunk, maxChunkBytes = MAX_CHUNK_BYTES) {
1553
+ // Try blank-line splitting first for naturally-sectioned output
1554
+ const sections = text.split(/\n\s*\n/);
1555
+ if (sections.length >= MIN_BLANK_LINE_SECTIONS &&
1556
+ sections.length <= MAX_BLANK_LINE_SECTIONS &&
1557
+ sections.every((s) => Buffer.byteLength(s) < BLANK_SECTION_STRATEGY_MAX_BYTES)) {
1558
+ return sections.flatMap((section, i) => {
1559
+ const trimmed = section.trim();
1560
+ if (trimmed.length === 0)
1561
+ return [];
1562
+ const title = trimmed.split("\n")[0].slice(0, CHUNK_TITLE_MAX_CHARS) || `Section ${i + 1}`;
1563
+ // A section may pass the strategy guard yet still exceed the byte cap
1564
+ // (4097–4999B band): sub-split it so no stored chunk breaks the cap.
1565
+ if (Buffer.byteLength(trimmed) <= maxChunkBytes) {
1566
+ return [{ title, content: trimmed }];
1567
+ }
1568
+ return this.#splitOversizedPlainChunk(trimmed.split("\n"), title, maxChunkBytes);
1569
+ });
1570
+ }
1571
+ const lines = text.split("\n");
1572
+ // Small enough for a single chunk — but still enforce byte cap
1573
+ if (lines.length <= linesPerChunk) {
1574
+ if (Buffer.byteLength(text) <= maxChunkBytes) {
1575
+ return [{ title: "Output", content: text }];
1576
+ }
1577
+ return this.#splitOversizedPlainChunk(lines, "Output", maxChunkBytes);
1578
+ }
1579
+ // Fixed-size line groups with 2-line overlap
1580
+ const chunks = [];
1581
+ const overlap = 2;
1582
+ const step = Math.max(linesPerChunk - overlap, 1);
1583
+ for (let i = 0; i < lines.length; i += step) {
1584
+ const slice = lines.slice(i, i + linesPerChunk);
1585
+ if (slice.length === 0)
1586
+ break;
1587
+ const startLine = i + 1;
1588
+ const endLine = Math.min(i + slice.length, lines.length);
1589
+ const firstLine = slice[0]?.trim().slice(0, CHUNK_TITLE_MAX_CHARS);
1590
+ const joined = slice.join("\n");
1591
+ // Enforce byte cap: sub-split oversized line-group chunks
1592
+ if (Buffer.byteLength(joined) <= maxChunkBytes) {
1593
+ chunks.push({
1594
+ title: firstLine || `Lines ${startLine}-${endLine}`,
1595
+ content: joined,
1596
+ });
1597
+ }
1598
+ else {
1599
+ const subChunks = this.#splitOversizedPlainChunk(slice, firstLine || `Lines ${startLine}-${endLine}`, maxChunkBytes);
1600
+ chunks.push(...subChunks);
1601
+ }
1602
+ }
1603
+ return chunks;
1604
+ }
1605
+ #walkJSON(value, path, chunks, maxChunkBytes) {
1606
+ const title = path.length > 0 ? path.join(" > ") : "(root)";
1607
+ const serialized = JSON.stringify(value, null, 2);
1608
+ // Small enough — emit as a single chunk
1609
+ if (Buffer.byteLength(serialized) <= maxChunkBytes) {
1610
+ // Exception: objects with nested structure (object/array values) always
1611
+ // recurse so that key paths become chunk titles for searchability —
1612
+ // even when the subtree fits in one chunk. Flat objects (all primitive
1613
+ // values) stay as a single chunk since there's no hierarchy to expose.
1614
+ const shouldRecurse = typeof value === "object" &&
1615
+ value !== null &&
1616
+ !Array.isArray(value) &&
1617
+ Object.values(value).some((v) => typeof v === "object" && v !== null);
1618
+ if (!shouldRecurse) {
1619
+ chunks.push({ title, content: serialized, hasCode: true });
1620
+ return;
1621
+ }
1622
+ }
1623
+ // Object — recurse into each key
1624
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1625
+ const entries = Object.entries(value);
1626
+ if (entries.length > 0) {
1627
+ for (const [key, val] of entries) {
1628
+ this.#walkJSON(val, [...path, key], chunks, maxChunkBytes);
1629
+ }
1630
+ return;
1631
+ }
1632
+ // Empty object — emit as-is
1633
+ chunks.push({ title, content: serialized, hasCode: true });
1634
+ return;
1635
+ }
1636
+ // Array — batch by size with identity-field-aware titles
1637
+ if (Array.isArray(value)) {
1638
+ this.#chunkJSONArray(value, path, chunks, maxChunkBytes);
1639
+ return;
1640
+ }
1641
+ // Primitive that exceeds maxChunkBytes (e.g., very long string)
1642
+ chunks.push({ title, content: serialized, hasCode: false });
1643
+ }
1644
+ /**
1645
+ * Scan the first element of an array of objects for a recognizable
1646
+ * identity field. Returns the field name or null.
1647
+ */
1648
+ #findIdentityField(arr) {
1649
+ if (arr.length === 0)
1650
+ return null;
1651
+ const first = arr[0];
1652
+ if (typeof first !== "object" || first === null || Array.isArray(first))
1653
+ return null;
1654
+ const candidates = ["id", "name", "title", "path", "slug", "key", "label"];
1655
+ const obj = first;
1656
+ for (const field of candidates) {
1657
+ if (field in obj && (typeof obj[field] === "string" || typeof obj[field] === "number")) {
1658
+ return field;
1659
+ }
1660
+ }
1661
+ return null;
1662
+ }
1663
+ #jsonBatchTitle(prefix, startIdx, endIdx, batch, identityField) {
1664
+ const sep = prefix ? `${prefix} > ` : "";
1665
+ if (!identityField) {
1666
+ return startIdx === endIdx
1667
+ ? `${sep}[${startIdx}]`
1668
+ : `${sep}[${startIdx}-${endIdx}]`;
1669
+ }
1670
+ const getId = (item) => String(item[identityField]);
1671
+ if (batch.length === 1) {
1672
+ return `${sep}${getId(batch[0])}`;
1673
+ }
1674
+ if (batch.length <= 3) {
1675
+ return sep + batch.map(getId).join(", ");
1676
+ }
1677
+ return `${sep}${getId(batch[0])}\u2026${getId(batch[batch.length - 1])}`;
1678
+ }
1679
+ #chunkJSONArray(arr, path, chunks, maxChunkBytes) {
1680
+ const prefix = path.length > 0 ? path.join(" > ") : "(root)";
1681
+ const identityField = this.#findIdentityField(arr);
1682
+ let batch = [];
1683
+ let batchStart = 0;
1684
+ const flushBatch = (batchEnd) => {
1685
+ if (batch.length === 0)
1686
+ return;
1687
+ const title = this.#jsonBatchTitle(prefix, batchStart, batchEnd, batch, identityField);
1688
+ chunks.push({
1689
+ title,
1690
+ content: JSON.stringify(batch, null, 2),
1691
+ hasCode: true,
1692
+ });
1693
+ };
1694
+ for (let i = 0; i < arr.length; i++) {
1695
+ batch.push(arr[i]);
1696
+ const candidate = JSON.stringify(batch, null, 2);
1697
+ if (Buffer.byteLength(candidate) > maxChunkBytes && batch.length > 1) {
1698
+ batch.pop();
1699
+ flushBatch(i - 1);
1700
+ batch = [arr[i]];
1701
+ batchStart = i;
1702
+ }
1703
+ }
1704
+ // Flush remaining
1705
+ flushBatch(batchStart + batch.length - 1);
1706
+ }
1707
+ #buildTitle(headingStack, currentHeading) {
1708
+ if (headingStack.length === 0) {
1709
+ return currentHeading || "Untitled";
1710
+ }
1711
+ return headingStack.map((h) => h.text).join(" > ");
1712
+ }
1713
+ }
1714
+ _a = ContentStore;