@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
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Classify non-zero exit codes for ctx_execute / ctx_execute_file.
3
+ *
4
+ * Shell commands like `grep` exit 1 for "no matches" — not a real error.
5
+ * We treat exit code 1 as a soft failure when:
6
+ * - language is "shell"
7
+ * - exit code is exactly 1
8
+ * - stdout has non-whitespace content
9
+ */
10
+ export interface ExitClassification {
11
+ isError: boolean;
12
+ output: string;
13
+ }
14
+ export declare function classifyNonZeroExit(params: {
15
+ language: string;
16
+ exitCode: number;
17
+ stdout: string;
18
+ stderr: string;
19
+ }): ExitClassification;
@@ -0,0 +1,12 @@
1
+ export function classifyNonZeroExit(params) {
2
+ const { language, exitCode, stdout, stderr } = params;
3
+ const isSoftFail = language === "shell" &&
4
+ exitCode === 1 &&
5
+ stdout.trim().length > 0;
6
+ return {
7
+ isError: !isSoftFail,
8
+ output: isSoftFail
9
+ ? stdout
10
+ : `Exit code: ${exitCode}\n\nstdout:\n${stdout}\n\nstderr:\n${stderr}`,
11
+ };
12
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * blocks — block-level template/content extraction for fetched documents.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ───────────────
6
+ * A format converter answers "what format". It can never answer "which part of
7
+ * the page". Turndown (and Cloudflare's `toMarkdown`, and every other
8
+ * converter) transliterates the whole document, chrome included: nav lists,
9
+ * sidebars, footers, cookie banners. Bolting a link-density or byte threshold
10
+ * onto the converter is a symptom detector — it guesses from the shape of a
11
+ * single page, and the right threshold differs per host. Measured 2026-08-12 on
12
+ * the shipped converter: link-only lines are 28.3% of the Stripe API reference
13
+ * and 0.3% of the Resend send-email page. No single number separates those.
14
+ *
15
+ * The signal that does work needs no threshold at all:
16
+ *
17
+ * CHROME IS WHAT REPEATS ACROSS PAGES OF THE SAME HOST.
18
+ * CONTENT IS WHAT DOES NOT.
19
+ *
20
+ * Every page of docs.stripe.com carries the same nav; only the article differs.
21
+ * So a block is classified `template` only when that exact block was already
22
+ * seen on a DIFFERENT page of the same host. The rule never guesses from the
23
+ * shape of one page, which makes it lossless by construction.
24
+ *
25
+ * NO DATA LOSS MEANS LABEL, NEVER DROP
26
+ * ────────────────────────────────────
27
+ * This module only ever LABELS. It does not delete, trim, summarise or
28
+ * truncate. `splitBlocks` is exactly reversible — see `reassemble`, and the
29
+ * invariant test that asserts `reassemble(splitBlocks(x)) === x` byte for byte.
30
+ * The caller stores every block; only `content` blocks reach the search index.
31
+ *
32
+ * No regular expressions anywhere (repo-wide ban): all scanning is explicit
33
+ * character and prefix work.
34
+ */
35
+ export interface Block {
36
+ /** 0-based position in the document. */
37
+ ordinal: number;
38
+ /**
39
+ * The block's source bytes VERBATIM, including the newlines and blank-line
40
+ * separators that followed it. Concatenating `raw` over all blocks in
41
+ * ordinal order reproduces the input document exactly.
42
+ */
43
+ raw: string;
44
+ /** The block's visible text: `raw` with leading/trailing whitespace removed. */
45
+ text: string;
46
+ /** sha256 (full digest, never shortened) of the normalised text. */
47
+ hash: string;
48
+ }
49
+ export type BlockKind = "content" | "template";
50
+ export interface ClassifiedBlock extends Block {
51
+ kind: BlockKind;
52
+ }
53
+ export interface ClassifyResult {
54
+ blocks: ClassifiedBlock[];
55
+ /**
56
+ * True when this host had no other recorded page at classification time, so
57
+ * every block was admitted as `content` on no evidence. The caller must
58
+ * re-run classification for this page once a second page of the host lands.
59
+ */
60
+ provisional: boolean;
61
+ /**
62
+ * True when every block of this page was already seen on other pages of the
63
+ * host — the fetch returned the site shell, not this page. The caller must
64
+ * refuse rather than index it.
65
+ */
66
+ allTemplate: boolean;
67
+ contentBytes: number;
68
+ templateBytes: number;
69
+ }
70
+ /**
71
+ * Split converted markdown into blocks.
72
+ *
73
+ * A block boundary is a blank line, or a heading line, outside a fenced code
74
+ * block. Blank lines stay attached to the block they follow, so every input
75
+ * byte belongs to exactly one block and `reassemble` is exact.
76
+ *
77
+ * Fenced code is never split: a nav list and a JSON example must each be a
78
+ * single unit, or a code sample that happens to share one line with another
79
+ * page would be classified independently of its surroundings.
80
+ */
81
+ export declare function splitBlocks(markdown: string): Block[];
82
+ /**
83
+ * Exact inverse of `splitBlocks`. `reassemble(splitBlocks(x)) === x` for every
84
+ * input — this is the formal statement of "nothing was lost".
85
+ */
86
+ export declare function reassemble(blocks: Array<{
87
+ raw: string;
88
+ }>): string;
89
+ /**
90
+ * Normalise a block for cross-page comparison: lower-cased, with every run of
91
+ * whitespace collapsed to a single space. Two renderings of the same nav that
92
+ * differ only in indentation or line wrapping must hash identically, or the
93
+ * repeat is missed and chrome leaks into the index.
94
+ *
95
+ * Deliberately conservative: no punctuation stripping, no token dropping. A
96
+ * looser normaliser risks collapsing two DIFFERENT blocks to one hash, which
97
+ * would demote real content. Under-matching leaks chrome (recoverable, and
98
+ * visible); over-matching hides content (a silent loss, which is forbidden).
99
+ */
100
+ export declare function normalizeBlockText(text: string): string;
101
+ /** Full sha256 hex digest of the normalised block. Never shortened. */
102
+ export declare function hashBlockText(text: string): string;
103
+ export interface ClassifyOptions {
104
+ blocks: Block[];
105
+ /**
106
+ * Number of DISTINCT other pages of this host on which the given block hash
107
+ * has been seen. Must exclude the page being classified, or a page compared
108
+ * against itself marks its own article as template.
109
+ */
110
+ otherPageCount: (hash: string) => number;
111
+ /** Distinct pages already recorded for this host, excluding the current one. */
112
+ hostPageCount: number;
113
+ /**
114
+ * Set when the document came straight from the site's own machine-readable
115
+ * endpoint. Such a document was authored for machines and carries no chrome
116
+ * to classify, so every block is content and the result is never provisional.
117
+ */
118
+ authored?: boolean;
119
+ }
120
+ /**
121
+ * Classify every block of one page.
122
+ *
123
+ * COLD START — the honest hard case. The first page of a host has no
124
+ * comparison set, so there is no evidence on which to call anything template.
125
+ * The decision here is to admit EVERY block as `content`, mark the page
126
+ * `provisional`, and re-run the moment a second page of that host arrives.
127
+ *
128
+ * The alternative — guessing per-block from link density, position or
129
+ * container on page one — is rejected on the measurements above: the correct
130
+ * threshold is 28.3% on Stripe and 0.3% on Resend, so any fixed value demotes
131
+ * real content on one of them. Over-indexing on page one is fully recoverable
132
+ * (the re-run removes the chrome, and the page's own bytes were never
133
+ * discarded); under-indexing is a silent loss with nothing left to detect it.
134
+ * Never demote on evidence we do not have.
135
+ */
136
+ export declare function classifyBlocks(opts: ClassifyOptions): ClassifyResult;
137
+ /**
138
+ * The text that goes to the search index: the `content` blocks, in document
139
+ * order, joined by their own separators. Template blocks are NOT deleted —
140
+ * the caller has already stored every block verbatim; they are simply left
141
+ * out of the index.
142
+ */
143
+ export declare function contentText(blocks: ClassifiedBlock[]): string;
144
+ /** The template stream, in document order. Stored and retrievable, never indexed. */
145
+ export declare function templateText(blocks: ClassifiedBlock[]): string;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * blocks — block-level template/content extraction for fetched documents.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ───────────────
6
+ * A format converter answers "what format". It can never answer "which part of
7
+ * the page". Turndown (and Cloudflare's `toMarkdown`, and every other
8
+ * converter) transliterates the whole document, chrome included: nav lists,
9
+ * sidebars, footers, cookie banners. Bolting a link-density or byte threshold
10
+ * onto the converter is a symptom detector — it guesses from the shape of a
11
+ * single page, and the right threshold differs per host. Measured 2026-08-12 on
12
+ * the shipped converter: link-only lines are 28.3% of the Stripe API reference
13
+ * and 0.3% of the Resend send-email page. No single number separates those.
14
+ *
15
+ * The signal that does work needs no threshold at all:
16
+ *
17
+ * CHROME IS WHAT REPEATS ACROSS PAGES OF THE SAME HOST.
18
+ * CONTENT IS WHAT DOES NOT.
19
+ *
20
+ * Every page of docs.stripe.com carries the same nav; only the article differs.
21
+ * So a block is classified `template` only when that exact block was already
22
+ * seen on a DIFFERENT page of the same host. The rule never guesses from the
23
+ * shape of one page, which makes it lossless by construction.
24
+ *
25
+ * NO DATA LOSS MEANS LABEL, NEVER DROP
26
+ * ────────────────────────────────────
27
+ * This module only ever LABELS. It does not delete, trim, summarise or
28
+ * truncate. `splitBlocks` is exactly reversible — see `reassemble`, and the
29
+ * invariant test that asserts `reassemble(splitBlocks(x)) === x` byte for byte.
30
+ * The caller stores every block; only `content` blocks reach the search index.
31
+ *
32
+ * No regular expressions anywhere (repo-wide ban): all scanning is explicit
33
+ * character and prefix work.
34
+ */
35
+ import { createHash } from "node:crypto";
36
+ // ─────────────────────────────────────────────────────────
37
+ // Splitting
38
+ // ─────────────────────────────────────────────────────────
39
+ /** True when `s` starts with `prefix`. Explicit so no regex is involved. */
40
+ function startsWith(s, prefix) {
41
+ return s.lastIndexOf(prefix, 0) === 0;
42
+ }
43
+ function isSpace(ch) {
44
+ return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
45
+ }
46
+ /** Trim ASCII whitespace from both ends without a regex. */
47
+ function trimEdges(s) {
48
+ let start = 0;
49
+ let end = s.length;
50
+ while (start < end && isSpace(s.charAt(start)))
51
+ start++;
52
+ while (end > start && isSpace(s.charAt(end - 1)))
53
+ end--;
54
+ return start === 0 && end === s.length ? s : s.substring(start, end);
55
+ }
56
+ /**
57
+ * Split a document into lines, KEEPING each line's terminator on the line.
58
+ * Concatenating the result reproduces the input exactly — the property the
59
+ * whole lossless story rests on.
60
+ */
61
+ function splitLinesKeepEnds(text) {
62
+ const lines = [];
63
+ let lineStart = 0;
64
+ for (let i = 0; i < text.length; i++) {
65
+ if (text.charAt(i) === "\n") {
66
+ lines.push(text.substring(lineStart, i + 1));
67
+ lineStart = i + 1;
68
+ }
69
+ }
70
+ if (lineStart < text.length)
71
+ lines.push(text.substring(lineStart));
72
+ return lines;
73
+ }
74
+ /**
75
+ * Split converted markdown into blocks.
76
+ *
77
+ * A block boundary is a blank line, or a heading line, outside a fenced code
78
+ * block. Blank lines stay attached to the block they follow, so every input
79
+ * byte belongs to exactly one block and `reassemble` is exact.
80
+ *
81
+ * Fenced code is never split: a nav list and a JSON example must each be a
82
+ * single unit, or a code sample that happens to share one line with another
83
+ * page would be classified independently of its surroundings.
84
+ */
85
+ export function splitBlocks(markdown) {
86
+ const blocks = [];
87
+ if (markdown.length === 0)
88
+ return blocks;
89
+ const lines = splitLinesKeepEnds(markdown);
90
+ let rawParts = [];
91
+ let hasText = false;
92
+ let inFence = false;
93
+ let pendingBreak = false;
94
+ const flush = () => {
95
+ if (rawParts.length === 0)
96
+ return;
97
+ const raw = rawParts.join("");
98
+ const text = trimEdges(raw);
99
+ blocks.push({ ordinal: blocks.length, raw, text, hash: hashBlockText(text) });
100
+ rawParts = [];
101
+ hasText = false;
102
+ };
103
+ for (const line of lines) {
104
+ const trimmed = trimEdges(line);
105
+ const isFenceMarker = startsWith(trimmed, "```") || startsWith(trimmed, "~~~");
106
+ const blank = !inFence && trimmed.length === 0;
107
+ const heading = !inFence && !isFenceMarker && startsWith(trimmed, "#");
108
+ // A new block starts at the first non-blank line after a blank run, or at
109
+ // a heading — but only if the block being built already carries text, so
110
+ // leading separators never produce an empty block.
111
+ if (!blank && (pendingBreak || heading) && hasText) {
112
+ flush();
113
+ pendingBreak = false;
114
+ }
115
+ if (isFenceMarker)
116
+ inFence = !inFence;
117
+ rawParts.push(line);
118
+ if (!blank)
119
+ hasText = true;
120
+ if (blank)
121
+ pendingBreak = true;
122
+ }
123
+ flush();
124
+ return blocks;
125
+ }
126
+ /**
127
+ * Exact inverse of `splitBlocks`. `reassemble(splitBlocks(x)) === x` for every
128
+ * input — this is the formal statement of "nothing was lost".
129
+ */
130
+ export function reassemble(blocks) {
131
+ let out = "";
132
+ for (const b of blocks)
133
+ out += b.raw;
134
+ return out;
135
+ }
136
+ // ─────────────────────────────────────────────────────────
137
+ // Hashing
138
+ // ─────────────────────────────────────────────────────────
139
+ /**
140
+ * Normalise a block for cross-page comparison: lower-cased, with every run of
141
+ * whitespace collapsed to a single space. Two renderings of the same nav that
142
+ * differ only in indentation or line wrapping must hash identically, or the
143
+ * repeat is missed and chrome leaks into the index.
144
+ *
145
+ * Deliberately conservative: no punctuation stripping, no token dropping. A
146
+ * looser normaliser risks collapsing two DIFFERENT blocks to one hash, which
147
+ * would demote real content. Under-matching leaks chrome (recoverable, and
148
+ * visible); over-matching hides content (a silent loss, which is forbidden).
149
+ */
150
+ export function normalizeBlockText(text) {
151
+ let out = "";
152
+ let inRun = false;
153
+ for (let i = 0; i < text.length; i++) {
154
+ const ch = text.charAt(i);
155
+ if (isSpace(ch)) {
156
+ inRun = true;
157
+ continue;
158
+ }
159
+ if (inRun && out.length > 0)
160
+ out += " ";
161
+ inRun = false;
162
+ out += ch;
163
+ }
164
+ return out.toLowerCase();
165
+ }
166
+ /** Full sha256 hex digest of the normalised block. Never shortened. */
167
+ export function hashBlockText(text) {
168
+ return createHash("sha256").update(normalizeBlockText(text), "utf-8").digest("hex");
169
+ }
170
+ /**
171
+ * Classify every block of one page.
172
+ *
173
+ * COLD START — the honest hard case. The first page of a host has no
174
+ * comparison set, so there is no evidence on which to call anything template.
175
+ * The decision here is to admit EVERY block as `content`, mark the page
176
+ * `provisional`, and re-run the moment a second page of that host arrives.
177
+ *
178
+ * The alternative — guessing per-block from link density, position or
179
+ * container on page one — is rejected on the measurements above: the correct
180
+ * threshold is 28.3% on Stripe and 0.3% on Resend, so any fixed value demotes
181
+ * real content on one of them. Over-indexing on page one is fully recoverable
182
+ * (the re-run removes the chrome, and the page's own bytes were never
183
+ * discarded); under-indexing is a silent loss with nothing left to detect it.
184
+ * Never demote on evidence we do not have.
185
+ */
186
+ export function classifyBlocks(opts) {
187
+ const { blocks, otherPageCount, hostPageCount, authored } = opts;
188
+ const coldStart = !authored && hostPageCount < 1;
189
+ const classified = [];
190
+ let contentBytes = 0;
191
+ let templateBytes = 0;
192
+ let sawContent = false;
193
+ let sawTextBlock = false;
194
+ for (const b of blocks) {
195
+ // A whitespace-only separator carries no information either way; keep it
196
+ // as content so reassembly of the content stream stays readable.
197
+ const textual = b.text.length > 0;
198
+ if (textual)
199
+ sawTextBlock = true;
200
+ const repeated = textual && !authored && !coldStart && otherPageCount(b.hash) >= 1;
201
+ const kind = repeated ? "template" : "content";
202
+ if (kind === "content") {
203
+ if (textual)
204
+ sawContent = true;
205
+ contentBytes += Buffer.byteLength(b.text, "utf-8");
206
+ }
207
+ else {
208
+ templateBytes += Buffer.byteLength(b.text, "utf-8");
209
+ }
210
+ classified.push({ ...b, kind });
211
+ }
212
+ return {
213
+ blocks: classified,
214
+ provisional: coldStart,
215
+ // Every textual block of this page already exists on other pages of this
216
+ // host: the response carried no page-specific content at all.
217
+ allTemplate: sawTextBlock && !sawContent,
218
+ contentBytes,
219
+ templateBytes,
220
+ };
221
+ }
222
+ /**
223
+ * The text that goes to the search index: the `content` blocks, in document
224
+ * order, joined by their own separators. Template blocks are NOT deleted —
225
+ * the caller has already stored every block verbatim; they are simply left
226
+ * out of the index.
227
+ */
228
+ export function contentText(blocks) {
229
+ let out = "";
230
+ for (const b of blocks) {
231
+ if (b.kind === "content")
232
+ out += b.raw;
233
+ }
234
+ return trimEdges(out);
235
+ }
236
+ /** The template stream, in document order. Stored and retrievable, never indexed. */
237
+ export function templateText(blocks) {
238
+ let out = "";
239
+ for (const b of blocks) {
240
+ if (b.kind === "template")
241
+ out += b.raw;
242
+ }
243
+ return trimEdges(out);
244
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * extract — orchestrates the lossless template/content pass for one fetch.
3
+ *
4
+ * Order of operations, cheapest correct answer first:
5
+ *
6
+ * 1. If the site publishes a machine-readable version of the page, take it.
7
+ * The fetch subprocess now sends `Accept: text/markdown` on the SAME
8
+ * request it was already making, so this costs zero extra round trips.
9
+ * Measured 2026-08-12: Stripe 1,846,885 B of HTML → 11,744 B of pure
10
+ * article; GitBook, Mintlify, Resend and Polygon likewise. Such a
11
+ * document was authored for machines and carries no chrome, so it skips
12
+ * extraction entirely (`authored`).
13
+ * 2. Otherwise convert the HTML, then classify each block against the other
14
+ * pages already seen from that host.
15
+ * 3. Index only `content`. Store everything.
16
+ * 4. If the page turns out to carry no page-specific content at all, refuse
17
+ * and say what to try instead.
18
+ *
19
+ * No regular expressions (repo-wide ban). Nothing here truncates.
20
+ */
21
+ import { PageStore } from "./page-store.js";
22
+ /** How the document reached us. `markdown` = the site served it; `html` = we converted it. */
23
+ export type FetchRoute = "markdown" | "html" | "json" | "text";
24
+ export interface ExtractInput {
25
+ url: string;
26
+ /** The storage label the caller will index under. */
27
+ sourceLabel: string;
28
+ /** The COMPLETE converted document. Stored verbatim; never trimmed here. */
29
+ document: string;
30
+ route: FetchRoute;
31
+ store: PageStore;
32
+ }
33
+ export interface Relabelled {
34
+ sourceLabel: string;
35
+ url: string;
36
+ /** Text to re-index for that page under the same label (replaces the old rows). */
37
+ indexText: string;
38
+ templateBytes: number;
39
+ }
40
+ export type ExtractOutcome = {
41
+ kind: "index";
42
+ /** The text that should reach the FTS index — `content` blocks only. */
43
+ indexText: string;
44
+ /** Complete document as stored. Retained for the caller's byte accounting. */
45
+ storedBytes: number;
46
+ contentBytes: number;
47
+ templateBytes: number;
48
+ templateBlocks: number;
49
+ totalBlocks: number;
50
+ provisional: boolean;
51
+ route: FetchRoute;
52
+ /** Cold-start pages of this host that a second page has now resolved. */
53
+ relabelled: Relabelled[];
54
+ } | {
55
+ kind: "refuse";
56
+ reason: string;
57
+ storedBytes: number;
58
+ route: FetchRoute;
59
+ };
60
+ /**
61
+ * JSON and plain-text responses are not web pages and have no chrome; they are
62
+ * passed through untouched so the existing JSON/text indexing strategies keep
63
+ * their exact behaviour.
64
+ */
65
+ export declare function routeSkipsExtraction(route: FetchRoute): boolean;
66
+ export declare function extractAndStore(input: ExtractInput): ExtractOutcome;
67
+ /** The chrome that was labelled out of a stored page, for retrieval. */
68
+ export declare function storedTemplateText(store: PageStore, url: string): string;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * extract — orchestrates the lossless template/content pass for one fetch.
3
+ *
4
+ * Order of operations, cheapest correct answer first:
5
+ *
6
+ * 1. If the site publishes a machine-readable version of the page, take it.
7
+ * The fetch subprocess now sends `Accept: text/markdown` on the SAME
8
+ * request it was already making, so this costs zero extra round trips.
9
+ * Measured 2026-08-12: Stripe 1,846,885 B of HTML → 11,744 B of pure
10
+ * article; GitBook, Mintlify, Resend and Polygon likewise. Such a
11
+ * document was authored for machines and carries no chrome, so it skips
12
+ * extraction entirely (`authored`).
13
+ * 2. Otherwise convert the HTML, then classify each block against the other
14
+ * pages already seen from that host.
15
+ * 3. Index only `content`. Store everything.
16
+ * 4. If the page turns out to carry no page-specific content at all, refuse
17
+ * and say what to try instead.
18
+ *
19
+ * No regular expressions (repo-wide ban). Nothing here truncates.
20
+ */
21
+ import { splitBlocks, classifyBlocks, contentText, templateText, } from "./blocks.js";
22
+ import { pageKeyFor, hostFor } from "./page-store.js";
23
+ /**
24
+ * JSON and plain-text responses are not web pages and have no chrome; they are
25
+ * passed through untouched so the existing JSON/text indexing strategies keep
26
+ * their exact behaviour.
27
+ */
28
+ export function routeSkipsExtraction(route) {
29
+ return route === "json" || route === "text";
30
+ }
31
+ export function extractAndStore(input) {
32
+ const { url, sourceLabel, document, route, store } = input;
33
+ const pageKey = pageKeyFor(url);
34
+ const host = hostFor(url);
35
+ const storedBytes = Buffer.byteLength(document, "utf-8");
36
+ const blocks = splitBlocks(document);
37
+ const authored = route === "markdown";
38
+ const hostPageCount = store.hostPageCount(host, pageKey);
39
+ const counts = authored
40
+ ? new Map()
41
+ : store.otherPageCounts(host, pageKey, blocks.map((b) => b.hash));
42
+ const result = classifyBlocks({
43
+ blocks,
44
+ otherPageCount: (h) => counts.get(h) ?? 0,
45
+ hostPageCount,
46
+ authored,
47
+ });
48
+ // Store the page WHOLE regardless of the verdict — including when we are
49
+ // about to refuse. Refusing to index is not a licence to discard bytes.
50
+ store.recordPage({
51
+ pageKey, host, url, sourceLabel, route,
52
+ provisional: result.provisional,
53
+ fullText: document,
54
+ }, result.blocks);
55
+ if (result.allTemplate) {
56
+ return {
57
+ kind: "refuse",
58
+ storedBytes,
59
+ route,
60
+ reason: `every block of this page is byte-identical to blocks already seen on other pages of ${host}, ` +
61
+ `so the response carried the site shell rather than this page — its content is rendered ` +
62
+ `client-side by JavaScript and an HTTP fetch cannot see it. Nothing was indexed (the response ` +
63
+ `is stored whole and unaltered). Retrying this URL returns the same shell; look for this site's ` +
64
+ `llms.txt, a raw .md source, an OpenAPI spec, or a repository README instead.`,
65
+ };
66
+ }
67
+ // A second page of this host resolves every cold-start page that came
68
+ // before it. Re-run those now — a first page that is wrongly labelled and
69
+ // never revisited is exactly the silent loss this design forbids.
70
+ const relabelled = [];
71
+ if (!result.provisional) {
72
+ for (const prev of store.provisionalPages(host, pageKey)) {
73
+ const prevBlocks = store.blocksOf(prev.pageKey);
74
+ const prevCounts = store.otherPageCounts(host, prev.pageKey, prevBlocks.map((b) => b.hash));
75
+ const rerun = classifyBlocks({
76
+ blocks: prevBlocks.map((b) => ({
77
+ ordinal: b.ordinal, raw: b.raw, text: b.text, hash: b.hash,
78
+ })),
79
+ otherPageCount: (h) => prevCounts.get(h) ?? 0,
80
+ hostPageCount: store.hostPageCount(prev.host, prev.pageKey),
81
+ authored: prev.route === "markdown",
82
+ });
83
+ // An all-template re-run means the earlier page was a shell too. Leave
84
+ // its stored bytes alone and leave its index rows alone rather than
85
+ // emptying a source the user may already be searching.
86
+ if (rerun.allTemplate)
87
+ continue;
88
+ store.relabelPage(prev.pageKey, rerun.blocks, rerun.provisional);
89
+ if (rerun.templateBytes > 0) {
90
+ relabelled.push({
91
+ sourceLabel: prev.sourceLabel,
92
+ url: prev.url,
93
+ indexText: contentText(rerun.blocks),
94
+ templateBytes: rerun.templateBytes,
95
+ });
96
+ }
97
+ }
98
+ }
99
+ return {
100
+ kind: "index",
101
+ indexText: contentText(result.blocks),
102
+ storedBytes,
103
+ contentBytes: result.contentBytes,
104
+ templateBytes: result.templateBytes,
105
+ templateBlocks: countTemplate(result.blocks),
106
+ totalBlocks: result.blocks.length,
107
+ provisional: result.provisional,
108
+ route,
109
+ relabelled,
110
+ };
111
+ }
112
+ function countTemplate(blocks) {
113
+ let n = 0;
114
+ for (const b of blocks)
115
+ if (b.kind === "template")
116
+ n++;
117
+ return n;
118
+ }
119
+ /** The chrome that was labelled out of a stored page, for retrieval. */
120
+ export function storedTemplateText(store, url) {
121
+ const blocks = store.blocksOf(pageKeyFor(url));
122
+ return templateText(blocks.map((b) => ({ ordinal: b.ordinal, raw: b.raw, text: b.text, hash: b.hash, kind: b.kind })));
123
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * page-store — the lossless side of fetch extraction.
3
+ *
4
+ * Holds, per project, the COMPLETE converted document for every page ever
5
+ * fetched, plus every block of it with its `content` / `template` label. The
6
+ * FTS index receives only `content` blocks; this store is what makes that
7
+ * safe, because nothing has been thrown away — the whole document and every
8
+ * template block remain here, verbatim and retrievable.
9
+ *
10
+ * It is also the comparison set. Classification asks one question — "was this
11
+ * exact block already seen on a DIFFERENT page of this host?" — and that
12
+ * question is answered by `page_blocks` joined to `pages`.
13
+ *
14
+ * Nothing in this file truncates. Full documents are stored whole; there is no
15
+ * size cap, no prefix, no summary. (The fetch path upstream already refuses
16
+ * responses above 50 MB before conversion.)
17
+ *
18
+ * No regular expressions (repo-wide ban).
19
+ */
20
+ import type { BlockKind, ClassifiedBlock } from "./blocks.js";
21
+ export interface StoredPage {
22
+ pageKey: string;
23
+ host: string;
24
+ url: string;
25
+ sourceLabel: string;
26
+ route: string;
27
+ provisional: boolean;
28
+ fullText: string;
29
+ }
30
+ export interface StoredBlock {
31
+ ordinal: number;
32
+ hash: string;
33
+ kind: BlockKind;
34
+ raw: string;
35
+ text: string;
36
+ }
37
+ /**
38
+ * Canonical identity of a fetched page. The URL minus its fragment: two
39
+ * fetches of the same page must be the same row, or a page compared against
40
+ * an older copy of itself marks its own article as template.
41
+ */
42
+ export declare function pageKeyFor(url: string): string;
43
+ /** Host of a URL, lower-cased. Empty string when the URL will not parse. */
44
+ export declare function hostFor(url: string): string;
45
+ export declare class PageStore {
46
+ #private;
47
+ constructor(dbPath: string);
48
+ /** Distinct pages already recorded for a host, excluding `exceptPageKey`. */
49
+ hostPageCount(host: string, exceptPageKey: string): number;
50
+ /**
51
+ * How many DISTINCT other pages of this host carry this exact block. The
52
+ * `page_key <> ?` clause is what keeps a page from classifying itself.
53
+ */
54
+ otherPageCounts(host: string, exceptPageKey: string, hashes: string[]): Map<string, number>;
55
+ /** Store one page whole: the complete document plus every labelled block. */
56
+ recordPage(page: StoredPage, blocks: ClassifiedBlock[]): void;
57
+ /** Update the stored labels after a re-run, and clear the provisional flag. */
58
+ relabelPage(pageKey: string, blocks: ClassifiedBlock[], provisional: boolean): void;
59
+ /** Every page of a host still carrying a cold-start (provisional) labelling. */
60
+ provisionalPages(host: string, exceptPageKey: string): StoredPage[];
61
+ /** Every stored block of a page, in document order — content and template alike. */
62
+ blocksOf(pageKey: string): StoredBlock[];
63
+ /** The complete converted document as it was stored, or null. */
64
+ fullTextOf(pageKey: string): string | null;
65
+ close(): void;
66
+ }