gather-engine 2.1.0__tar.gz → 2.3.0__tar.gz

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 (207) hide show
  1. {gather_engine-2.1.0 → gather_engine-2.3.0}/PKG-INFO +7 -5
  2. {gather_engine-2.1.0 → gather_engine-2.3.0}/README.md +6 -4
  3. {gather_engine-2.1.0 → gather_engine-2.3.0}/pyproject.toml +1 -1
  4. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/__init__.py +1 -1
  5. gather_engine-2.3.0/src/gather/citecheck.py +119 -0
  6. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/cli.py +4 -18
  7. gather_engine-2.3.0/src/gather/cli_ext.py +19 -0
  8. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/client_mcp.py +6 -7
  9. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/commands.py +20 -11
  10. gather_engine-2.3.0/src/gather/docs_tool.py +93 -0
  11. gather_engine-2.3.0/src/gather/filter_ledger.py +111 -0
  12. gather_engine-2.3.0/src/gather/grant_checks.py +52 -0
  13. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/grants.py +3 -36
  14. gather_engine-2.3.0/src/gather/http_call.py +56 -0
  15. gather_engine-2.3.0/src/gather/ledger_cmd.py +39 -0
  16. gather_engine-2.3.0/src/gather/ledger_verify.py +86 -0
  17. gather_engine-2.3.0/src/gather/local_model.py +83 -0
  18. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/mcp.py +17 -45
  19. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/method.py +2 -0
  20. gather_engine-2.3.0/src/gather/reddit.py +203 -0
  21. gather_engine-2.3.0/src/gather/reddit_cmd.py +34 -0
  22. gather_engine-2.3.0/src/gather/reddit_items.py +64 -0
  23. gather_engine-2.3.0/src/gather/report.py +118 -0
  24. gather_engine-2.3.0/src/gather/report_cmd.py +94 -0
  25. gather_engine-2.3.0/src/gather/route.py +78 -0
  26. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/run_config.py +36 -4
  27. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/source.py +16 -9
  28. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/video_cmd.py +3 -3
  29. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/video_source.py +5 -0
  30. gather_engine-2.3.0/src/gather/youtube_api.py +129 -0
  31. gather_engine-2.3.0/src/gather/youtube_cmd.py +90 -0
  32. gather_engine-2.3.0/src/gather/youtube_probe.py +92 -0
  33. gather_engine-2.3.0/src/gather/youtube_route.py +166 -0
  34. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather_engine.egg-info/PKG-INFO +7 -5
  35. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather_engine.egg-info/SOURCES.txt +34 -0
  36. gather_engine-2.3.0/tests/test_citecheck.py +72 -0
  37. gather_engine-2.3.0/tests/test_client_disclosure.py +80 -0
  38. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_client_package.py +41 -1
  39. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_client_setup.py +17 -0
  40. gather_engine-2.3.0/tests/test_client_vendored.py +144 -0
  41. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_docs.py +1 -1
  42. gather_engine-2.3.0/tests/test_filter_ledger.py +117 -0
  43. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_grants.py +1 -1
  44. gather_engine-2.3.0/tests/test_http_call.py +59 -0
  45. gather_engine-2.3.0/tests/test_mcp_annotations.py +38 -0
  46. gather_engine-2.3.0/tests/test_reddit.py +180 -0
  47. gather_engine-2.3.0/tests/test_reddit_grants.py +29 -0
  48. gather_engine-2.3.0/tests/test_report.py +99 -0
  49. gather_engine-2.3.0/tests/test_report_quotes.py +119 -0
  50. gather_engine-2.3.0/tests/test_route.py +40 -0
  51. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_video_source.py +3 -2
  52. gather_engine-2.3.0/tests/test_youtube_api.py +63 -0
  53. gather_engine-2.3.0/tests/test_youtube_grants.py +30 -0
  54. gather_engine-2.3.0/tests/test_youtube_probe.py +88 -0
  55. gather_engine-2.3.0/tests/test_youtube_route.py +188 -0
  56. {gather_engine-2.1.0 → gather_engine-2.3.0}/LICENSE +0 -0
  57. {gather_engine-2.1.0 → gather_engine-2.3.0}/setup.cfg +0 -0
  58. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/__main__.py +0 -0
  59. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/_vendor/__init__.py +0 -0
  60. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/_vendor/safe_spawn.py +0 -0
  61. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/api.py +0 -0
  62. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/arxiv.py +0 -0
  63. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/availability.py +0 -0
  64. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/backends.py +0 -0
  65. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/backends_browser.py +0 -0
  66. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/benchmark.py +0 -0
  67. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/browser.py +0 -0
  68. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/browser_evidence.py +0 -0
  69. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/cache.py +0 -0
  70. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/captions.py +0 -0
  71. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/channel.py +0 -0
  72. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/channel_ledger.py +0 -0
  73. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/client_network.py +0 -0
  74. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/context.py +0 -0
  75. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/corpus_cmd.py +0 -0
  76. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/crawl.py +0 -0
  77. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/credentials.py +0 -0
  78. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/derive.py +0 -0
  79. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/digest.py +0 -0
  80. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/docs.py +0 -0
  81. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/dom.py +0 -0
  82. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/export.py +0 -0
  83. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/extract.py +0 -0
  84. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/fastparse.py +0 -0
  85. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/federation.py +0 -0
  86. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/federation_cmd.py +0 -0
  87. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/federation_policy.py +0 -0
  88. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/federation_receipt.py +0 -0
  89. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/feed.py +0 -0
  90. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/fetch.py +0 -0
  91. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/flagship.py +0 -0
  92. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/interop.py +0 -0
  93. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/item.py +0 -0
  94. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/localpath.py +0 -0
  95. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/model.py +0 -0
  96. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/monitor.py +0 -0
  97. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/net.py +0 -0
  98. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/ocr.py +0 -0
  99. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pacing.py +0 -0
  100. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/payloads.py +0 -0
  101. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pdf.py +0 -0
  102. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pilot.py +0 -0
  103. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pilot_bundle.py +0 -0
  104. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pilot_commands.py +0 -0
  105. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pilot_manifest.py +0 -0
  106. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pilot_report.py +0 -0
  107. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/pilot_sources.py +0 -0
  108. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/provenance.py +0 -0
  109. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/recall.py +0 -0
  110. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/run.py +0 -0
  111. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/schema_extract.py +0 -0
  112. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/scholar.py +0 -0
  113. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/scope.py +0 -0
  114. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/search.py +0 -0
  115. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/spawn.py +0 -0
  116. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/store.py +0 -0
  117. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/stream.py +0 -0
  118. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/track.py +0 -0
  119. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/transcribe.py +0 -0
  120. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/video.py +0 -0
  121. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/web.py +0 -0
  122. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/web_commands.py +0 -0
  123. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather/ytdlp.py +0 -0
  124. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather_engine.egg-info/dependency_links.txt +0 -0
  125. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather_engine.egg-info/entry_points.txt +0 -0
  126. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather_engine.egg-info/requires.txt +0 -0
  127. {gather_engine-2.1.0 → gather_engine-2.3.0}/src/gather_engine.egg-info/top_level.txt +0 -0
  128. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_api.py +0 -0
  129. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_arxiv.py +0 -0
  130. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_availability.py +0 -0
  131. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_backends.py +0 -0
  132. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_backends_browser.py +0 -0
  133. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_backends_card.py +0 -0
  134. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_benchmark.py +0 -0
  135. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_browser_evidence.py +0 -0
  136. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_cache.py +0 -0
  137. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_captions.py +0 -0
  138. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_channel.py +0 -0
  139. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_channel_ledger.py +0 -0
  140. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_cli.py +0 -0
  141. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_cli_web.py +0 -0
  142. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_client_network.py +0 -0
  143. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_client_profile.py +0 -0
  144. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_context.py +0 -0
  145. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_context_mountinfo.py +0 -0
  146. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_crawl.py +0 -0
  147. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_credentials.py +0 -0
  148. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_demo.py +0 -0
  149. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_derive.py +0 -0
  150. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_device_names.py +0 -0
  151. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_digest.py +0 -0
  152. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_dom.py +0 -0
  153. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_export.py +0 -0
  154. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_extract.py +0 -0
  155. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_fastparse.py +0 -0
  156. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_federation.py +0 -0
  157. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_federation_cli.py +0 -0
  158. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_federation_receipt.py +0 -0
  159. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_feed.py +0 -0
  160. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_fetch.py +0 -0
  161. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_flagship_cli.py +0 -0
  162. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_hard_sources.py +0 -0
  163. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_interop.py +0 -0
  164. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_item.py +0 -0
  165. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_localpath.py +0 -0
  166. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_mcp.py +0 -0
  167. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_method.py +0 -0
  168. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_model.py +0 -0
  169. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_monitor.py +0 -0
  170. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_net.py +0 -0
  171. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_nonlocal_paths.py +0 -0
  172. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pacing.py +0 -0
  173. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_package.py +0 -0
  174. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pdf.py +0 -0
  175. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot.py +0 -0
  176. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_bundle.py +0 -0
  177. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_cli.py +0 -0
  178. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_manifest.py +0 -0
  179. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_refresh.py +0 -0
  180. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_report.py +0 -0
  181. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_sample.py +0 -0
  182. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_showcase.py +0 -0
  183. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_pilot_sources.py +0 -0
  184. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_provenance.py +0 -0
  185. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_recall.py +0 -0
  186. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_release_workflow.py +0 -0
  187. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_repo_art.py +0 -0
  188. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_repo_card.py +0 -0
  189. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_run.py +0 -0
  190. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_schema_extract.py +0 -0
  191. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_scholar.py +0 -0
  192. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_scope.py +0 -0
  193. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_search.py +0 -0
  194. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_spawn_children.py +0 -0
  195. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_spawn_working_folder.py +0 -0
  196. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_store.py +0 -0
  197. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_stream.py +0 -0
  198. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_track.py +0 -0
  199. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_vendored.py +0 -0
  200. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_verify_pilot_cli.py +0 -0
  201. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_video.py +0 -0
  202. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_video_spawn.py +0 -0
  203. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_web.py +0 -0
  204. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_web_refine.py +0 -0
  205. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_xml_safety.py +0 -0
  206. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_ytdlp.py +0 -0
  207. {gather_engine-2.1.0 → gather_engine-2.3.0}/tests/test_ytdlp_real.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gather-engine
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Accountable research intake for difficult sources, with provenance receipts and witnessed digests.
5
5
  Author: Zain Dana Harper
6
6
  License-Expression: LicenseRef-FSL-1.1-MIT
@@ -34,7 +34,7 @@ Dynamic: license-file
34
34
  [![downloads](https://img.shields.io/pypi/dm/gather-engine?label=downloads&style=flat-square&labelColor=14041b)](https://pypi.org/project/gather-engine/)
35
35
  ![python: 3.11+](https://img.shields.io/badge/python-3.11%2B-blue?style=flat-square&labelColor=14041b)
36
36
  ![deps: none (core)](https://img.shields.io/badge/core%20deps-none-success?style=flat-square&labelColor=14041b)
37
- ![version: 2.1.0](https://img.shields.io/badge/version-2.1.0-26dfe8?style=flat-square&labelColor=14041b)
37
+ ![version: 2.3.0](https://img.shields.io/badge/version-2.3.0-26dfe8?style=flat-square&labelColor=14041b)
38
38
 
39
39
  gather pulls research out of the places most tools break on: arXiv papers, authenticated JSON APIs, JavaScript-rendered pages via a real headless browser, scanned images through OCR, and audio through transcription, alongside video, web, feeds, and local docs. The core runs with zero third-party runtime dependencies, and the same engine is reachable from the CLI, MCP tools, and plain Python. Every run writes a receipt you can re-check.
40
40
 
@@ -42,7 +42,7 @@ gather pulls research out of the places most tools break on: arXiv papers, authe
42
42
 
43
43
  ## Current status
44
44
 
45
- `gather-engine 2.1.0` is the current source version. Core intake,
45
+ `gather-engine 2.3.0` is the current source version. Core intake,
46
46
  content-addressed corpus storage, exact UTF-8 source-byte receipts, readable
47
47
  context selection, descriptor handoff, pilot monitoring, CLI, Python API, and MCP
48
48
  surfaces are present in this checkout. Browser rendering, OCR, audio, and fast
@@ -82,7 +82,9 @@ fabricated field cannot reach the corpus by being plausible.
82
82
  - **Track elements across redesigns.** Fingerprint a scraped element once, then relocate it in any later version of the page and get a typed MATCH, RELOCATED, DRIFT, or GONE verdict (`gather.track`, Python API).
83
83
  - **Structured extraction with a hallucination check.** `gather.schema_extract` binds schema fields to source nodes, and `verify_record` rejects any LLM-proposed field value not grounded in the fetched content.
84
84
  - **Streaming extraction.** `gather.stream` parses an HTML stream chunk by chunk and emits partial-update commits as blocks complete, each folded into a hash chain, so a streamed extraction is replayable.
85
- - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp`), static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
85
+ - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
86
+ - **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify. An answer that cites an excerpt without quoting it is sent back for a rewrite, up to twice.
87
+ - **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
86
88
  - **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
87
89
  - **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
88
90
  - **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
@@ -250,7 +252,7 @@ Keep the README, package metadata, and examples aligned with current behavior be
250
252
 
251
253
  ---
252
254
 
253
- **[Zentropy Labs](https://github.com/ZentropyLabs-ai)** · order out of entropy. An independent lab building evidence-first tools that leave a re-checkable artifact behind. Built by Zain Dana Harper in Seattle. The full workbench is at [Project Telos](https://harperz9.github.io).
255
+ Built by **[Zain Dana Harper](https://harperz9.github.io)** in Seattle: evidence-first tools that leave a re-checkable artifact behind. The full workbench is at [Project Telos](https://harperz9.github.io).
254
256
 
255
257
  ## Local client packages
256
258
 
@@ -8,7 +8,7 @@
8
8
  [![downloads](https://img.shields.io/pypi/dm/gather-engine?label=downloads&style=flat-square&labelColor=14041b)](https://pypi.org/project/gather-engine/)
9
9
  ![python: 3.11+](https://img.shields.io/badge/python-3.11%2B-blue?style=flat-square&labelColor=14041b)
10
10
  ![deps: none (core)](https://img.shields.io/badge/core%20deps-none-success?style=flat-square&labelColor=14041b)
11
- ![version: 2.1.0](https://img.shields.io/badge/version-2.1.0-26dfe8?style=flat-square&labelColor=14041b)
11
+ ![version: 2.3.0](https://img.shields.io/badge/version-2.3.0-26dfe8?style=flat-square&labelColor=14041b)
12
12
 
13
13
  gather pulls research out of the places most tools break on: arXiv papers, authenticated JSON APIs, JavaScript-rendered pages via a real headless browser, scanned images through OCR, and audio through transcription, alongside video, web, feeds, and local docs. The core runs with zero third-party runtime dependencies, and the same engine is reachable from the CLI, MCP tools, and plain Python. Every run writes a receipt you can re-check.
14
14
 
@@ -16,7 +16,7 @@ gather pulls research out of the places most tools break on: arXiv papers, authe
16
16
 
17
17
  ## Current status
18
18
 
19
- `gather-engine 2.1.0` is the current source version. Core intake,
19
+ `gather-engine 2.3.0` is the current source version. Core intake,
20
20
  content-addressed corpus storage, exact UTF-8 source-byte receipts, readable
21
21
  context selection, descriptor handoff, pilot monitoring, CLI, Python API, and MCP
22
22
  surfaces are present in this checkout. Browser rendering, OCR, audio, and fast
@@ -56,7 +56,9 @@ fabricated field cannot reach the corpus by being plausible.
56
56
  - **Track elements across redesigns.** Fingerprint a scraped element once, then relocate it in any later version of the page and get a typed MATCH, RELOCATED, DRIFT, or GONE verdict (`gather.track`, Python API).
57
57
  - **Structured extraction with a hallucination check.** `gather.schema_extract` binds schema fields to source nodes, and `verify_record` rejects any LLM-proposed field value not grounded in the fetched content.
58
58
  - **Streaming extraction.** `gather.stream` parses an HTML stream chunk by chunk and emits partial-update commits as blocks complete, each folded into a hash chain, so a streamed extraction is replayable.
59
- - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp`), static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
59
+ - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
60
+ - **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify. An answer that cites an excerpt without quoting it is sent back for a rewrite, up to twice.
61
+ - **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
60
62
  - **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
61
63
  - **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
62
64
  - **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
@@ -224,7 +226,7 @@ Keep the README, package metadata, and examples aligned with current behavior be
224
226
 
225
227
  ---
226
228
 
227
- **[Zentropy Labs](https://github.com/ZentropyLabs-ai)** · order out of entropy. An independent lab building evidence-first tools that leave a re-checkable artifact behind. Built by Zain Dana Harper in Seattle. The full workbench is at [Project Telos](https://harperz9.github.io).
229
+ Built by **[Zain Dana Harper](https://harperz9.github.io)** in Seattle: evidence-first tools that leave a re-checkable artifact behind. The full workbench is at [Project Telos](https://harperz9.github.io).
228
230
 
229
231
  ## Local client packages
230
232
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gather-engine"
7
- version = "2.1.0"
7
+ version = "2.3.0"
8
8
  description = "Accountable research intake for difficult sources, with provenance receipts and witnessed digests."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -28,7 +28,7 @@ from gather.scope import filter_scope, in_scope
28
28
  from gather.source import Catalog, Source
29
29
  from gather.store import Corpus
30
30
 
31
- __version__ = "2.1.0"
31
+ __version__ = "2.3.0"
32
32
 
33
33
  __all__ = [
34
34
  "Catalog", "Corpus", "CorpusRootDescriptor", "CorpusRootIdentity", "Digest", "Item", "NullProvenanceProvider", "NullSynthesizer",
@@ -0,0 +1,119 @@
1
+ """A code check on every citation in a report. Pure: no network, no model.
2
+
3
+ A report cites an excerpt by quoting it and naming its number: ``"the exact words" [2]``. For
4
+ each citation the check decides, by string comparison alone:
5
+
6
+ - ``verified``: the quoted span appears in excerpt 2 (after Unicode, quote-mark, dash and
7
+ whitespace normalisation, and case folding);
8
+ - ``not-in-source``: excerpt 2 exists and does not contain the span;
9
+ - ``unknown-source``: there is no excerpt 2;
10
+ - ``too-short``: the quote has fewer than ``MIN_QUOTE_WORDS`` words, so a match would prove
11
+ little; it is not counted as verified;
12
+ - ``unchecked``: a bracketed number with no quote before it, so there is nothing to compare.
13
+
14
+ Sentences with no citation at all are listed as uncited. Nothing is hidden: every citation and
15
+ every uncited sentence is in the result. Precision is verified citations over all citations.
16
+
17
+ What this does not check: whether the sentence around a verified quote says what the excerpt
18
+ means. A quote can be exact and the claim around it can still stretch it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import unicodedata
25
+ from dataclasses import dataclass
26
+
27
+ CHECK_SCHEMA = "gather.citation-check/1"
28
+ MIN_QUOTE_WORDS = 4
29
+ STATUSES = ("verified", "not-in-source", "unknown-source", "too-short", "unchecked")
30
+
31
+ _MARKS = str.maketrans({"“": '"', "”": '"', "„": '"', "«": '"', "»": '"',
32
+ "‘": "'", "’": "'", "–": "-", "—": "-", "−": "-",
33
+ " ": " "})
34
+ # a double-quoted span, optional space, then [n]
35
+ _QUOTE_CITE = re.compile(r'"([^"]{1,2000})"\s*\[(\d{1,4})\]')
36
+ _BARE_CITE = re.compile(r"\[(\d{1,4})\]")
37
+ _LIST_MARK = re.compile(r"^(?:[-*+]|\d{1,3}[.)])\s+")
38
+ _SENTENCE = re.compile(r'(?<=[.!?])["\')\]]*\s+(?=["(\[A-Z0-9])')
39
+
40
+
41
+ def normalize(text: str) -> str:
42
+ """NFKC, straight quote marks and hyphens, collapsed whitespace, case folded."""
43
+ text = unicodedata.normalize("NFKC", text).translate(_MARKS)
44
+ return re.sub(r"\s+", " ", text).strip().casefold()
45
+
46
+
47
+ def _strip_edges(quote: str) -> str:
48
+ return normalize(quote).strip(" .,;:!?'")
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class Citation:
53
+ sentence: int
54
+ source: int
55
+ quote: str | None
56
+ status: str
57
+
58
+ def to_dict(self) -> dict:
59
+ return {"sentence": self.sentence, "source": self.source, "quote": self.quote, "status": self.status}
60
+
61
+
62
+ def _split_line(line: str) -> list[str]:
63
+ """Split one line at sentence ends that fall outside a double-quoted span."""
64
+ parts, start = [], 0
65
+ for m in _SENTENCE.finditer(line):
66
+ if line[:m.start()].count('"') % 2 == 0:
67
+ parts.append(line[start:m.start() + len(m.group(0).rstrip())].strip())
68
+ start = m.end()
69
+ parts.append(line[start:].strip())
70
+ return [p for p in parts if p]
71
+
72
+
73
+ def sentences(report: str) -> list[str]:
74
+ """The report's sentences: lines split at sentence ends outside quotes, list markers removed,
75
+ headings and blank lines dropped."""
76
+ out: list[str] = []
77
+ for raw in report.splitlines():
78
+ line = raw.strip()
79
+ if not line or line.startswith("#"):
80
+ continue
81
+ out.extend(_split_line(_LIST_MARK.sub("", line.translate(_MARKS))))
82
+ return out
83
+
84
+
85
+ def _status(quote: str, source: int, excerpts: list[str]) -> str:
86
+ if not 1 <= source <= len(excerpts):
87
+ return "unknown-source"
88
+ span = _strip_edges(quote)
89
+ if len(span.split()) < MIN_QUOTE_WORDS:
90
+ return "too-short"
91
+ return "verified" if span in normalize(excerpts[source - 1]) else "not-in-source"
92
+
93
+
94
+ def _sentence_citations(n: int, sentence: str, excerpts: list[str]) -> list[Citation]:
95
+ found = [Citation(n, int(m.group(2)), m.group(1), _status(m.group(1), int(m.group(2)), excerpts))
96
+ for m in _QUOTE_CITE.finditer(sentence.translate(_MARKS))]
97
+ bare_text = _QUOTE_CITE.sub("", sentence.translate(_MARKS))
98
+ found += [Citation(n, int(m.group(1)), None, "unchecked") for m in _BARE_CITE.finditer(bare_text)]
99
+ return found
100
+
101
+
102
+ def check_report(report: str, excerpts: list[str]) -> dict:
103
+ """Check every citation in ``report`` against ``excerpts`` (excerpt 1 is ``excerpts[0]``)."""
104
+ cites: list[Citation] = []
105
+ uncited: list[str] = []
106
+ for n, sentence in enumerate(sentences(report)):
107
+ found = _sentence_citations(n, sentence, excerpts)
108
+ cites.extend(found)
109
+ if not found:
110
+ uncited.append(sentence)
111
+ counts = {s: sum(1 for c in cites if c.status == s) for s in STATUSES}
112
+ total = len(cites)
113
+ return {
114
+ "schema": CHECK_SCHEMA, "excerpts": len(excerpts), "citations": [c.to_dict() for c in cites],
115
+ "counts": counts, "total": total,
116
+ "precision": round(counts["verified"] / total, 4) if total else None,
117
+ "uncited_sentences": uncited,
118
+ "does_not_prove": "that the claim around a verified quote matches the excerpt's meaning",
119
+ }
@@ -4,6 +4,7 @@ import argparse
4
4
  import sys
5
5
 
6
6
  from gather import __version__
7
+ from gather.cli_ext import add_extension_parsers
7
8
  from gather.commands import (
8
9
  cmd_api,
9
10
  cmd_arxiv,
@@ -11,12 +12,10 @@ from gather.commands import (
11
12
  cmd_docs,
12
13
  cmd_feed,
13
14
  cmd_ocr,
14
- cmd_parse,
15
15
  cmd_pdf,
16
16
  cmd_run,
17
17
  cmd_scholar,
18
18
  cmd_transcribe,
19
- cmd_video,
20
19
  cmd_web,
21
20
  )
22
21
  from gather.corpus_cmd import cmd_corpus
@@ -29,7 +28,6 @@ from gather.pilot_commands import (
29
28
  cmd_pilot_run,
30
29
  cmd_pilot_verify,
31
30
  )
32
- from gather.video_cmd import add_channel_parser, add_ytdlp_options
33
31
  from gather.web_commands import (
34
32
  cmd_caps,
35
33
  cmd_crawl,
@@ -50,6 +48,8 @@ def _add_common(p: argparse.ArgumentParser) -> None:
50
48
  p.add_argument("--scope", default=None, help="comma-separated scope terms; keep items mentioning any")
51
49
  p.add_argument("--json", action="store_true", help="emit the catalog and digest as JSON")
52
50
  p.add_argument("--store", default=None, metavar="DIR", help="persist gathered items into a corpus at DIR")
51
+ p.add_argument("--ledger", default=None, metavar="DIR",
52
+ help="write a filter ledger (ledger.json, input.jsonl) of what --scope dropped into DIR")
53
53
 
54
54
 
55
55
  def _add_flagship_commands(sub) -> None:
@@ -124,21 +124,7 @@ def build_parser() -> argparse.ArgumentParser:
124
124
  sub = parser.add_subparsers(dest="command")
125
125
  _add_flagship_commands(sub)
126
126
 
127
- parse = sub.add_parser("parse", help="parse a saved yt-dlp info.json (+ optional .vtt), offline, no network")
128
- parse.add_argument("info", help="path to a yt-dlp info.json")
129
- parse.add_argument("--vtt", default=None, help="path to a .vtt captions file")
130
- parse.add_argument("--auto-captions", action="store_true",
131
- help="captions are machine-generated: collapse rolling-window growth, stamp auto-caption")
132
- _add_common(parse)
133
- parse.set_defaults(func=cmd_parse)
134
-
135
- video = sub.add_parser("video", help="fetch a video via yt-dlp (needs yt-dlp on PATH and network)")
136
- video.add_argument("url")
137
- video.add_argument("--comments", action="store_true", help="also gather comments")
138
- add_ytdlp_options(video)
139
- _add_common(video)
140
- video.set_defaults(func=cmd_video)
141
- add_channel_parser(sub)
127
+ add_extension_parsers(sub, _add_common)
142
128
 
143
129
  web = sub.add_parser("web", help="fetch a static web page via http(s) and extract readable text")
144
130
  web.add_argument("url")
@@ -0,0 +1,19 @@
1
+ """The subcommands that live in their own modules, registered in one place for ``gather.cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+
8
+ def add_extension_parsers(sub, add_common: Callable) -> None:
9
+ from gather.ledger_cmd import add_ledger_parser
10
+ from gather.reddit_cmd import add_reddit_parser
11
+ from gather.report_cmd import add_report_parsers
12
+ from gather.video_cmd import add_channel_parser
13
+ from gather.youtube_cmd import add_video_parsers
14
+
15
+ add_video_parsers(sub, add_common)
16
+ add_channel_parser(sub)
17
+ add_reddit_parser(sub, add_common)
18
+ add_report_parsers(sub, add_common)
19
+ add_ledger_parser(sub)
@@ -156,23 +156,22 @@ def main(argv=None):
156
156
  return 0
157
157
 
158
158
  def definitions(network=None):
159
- from gather.mcp import _tool_defs
160
- tools = [d for d in _tool_defs() if d["name"] in {"gather.docs"}]
159
+ from gather.docs_tool import annotate, docs_tool
160
+ tools = [annotate(docs_tool())]
161
161
  if network is not None and network.origins:
162
- tools.append({"name": "gather.fetch", "description": "Read an allowed origin; return untrusted source text and a byte receipt. No redirects or credentials.",
163
- "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"], "additionalProperties": False}})
162
+ tools.append(annotate({"name": "gather.fetch", "description": "Read an allowed origin; return untrusted source text and a byte receipt. No redirects or credentials.",
163
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"], "additionalProperties": False}}))
164
164
  return tools
165
165
 
166
166
 
167
167
  def invoke(name, args, root):
168
- from gather.grants import NONE
169
- from gather.mcp import call_tool
168
+ from gather.docs_tool import call_docs
170
169
  # Single files avoid a directory walk following a file link outside the grant.
171
170
  path = confined(root, args["path"])
172
171
  if not path.is_file():
173
172
  raise ClientRefusal("local client intake requires one text file per call")
174
173
  args["path"] = str(path)
175
- return call_tool(name, args, grants=NONE)
174
+ return call_docs(args)
176
175
 
177
176
 
178
177
  if __name__ == "__main__":
@@ -13,12 +13,22 @@ def _scope(args) -> list[str]:
13
13
  return _split(args.scope)
14
14
 
15
15
 
16
- def _emit(items, scope, as_json, store=None) -> int:
16
+ def _scoped(items, scope, ledger=None):
17
+ """The scope filter; with ``ledger``, every drop is also written to a ledger beside its input."""
18
+ if not ledger:
19
+ from gather.scope import filter_scope
20
+ return filter_scope(items, scope)
21
+ from gather.filter_ledger import scope_with_ledger, write_ledger
22
+ kept, record = scope_with_ledger(items, scope)
23
+ write_ledger(ledger, record, items)
24
+ return kept, record["dropped"]
25
+
26
+
27
+ def _emit(items, scope, as_json, store=None, ledger=None) -> int:
17
28
  from gather.digest import digest, verify_digest
18
29
  from gather.payloads import catalog_digest_payload
19
- from gather.scope import filter_scope
20
30
 
21
- kept, dropped = filter_scope(items, scope)
31
+ kept, dropped = _scoped(items, scope, ledger)
22
32
  d = digest(kept)
23
33
  stored = None
24
34
  if store:
@@ -48,7 +58,7 @@ def _fetch_and_emit(fetch, args, fail: str = "fetch failed") -> int:
48
58
  except Exception as exc:
49
59
  print(f"{fail}: {exc}", file=sys.stderr)
50
60
  return 1
51
- return _emit(items, _scope(args), args.json, store=args.store)
61
+ return _emit(items, _scope(args), args.json, store=args.store, ledger=getattr(args, "ledger", None))
52
62
 
53
63
 
54
64
  def cmd_parse(args) -> int:
@@ -61,13 +71,13 @@ def cmd_parse(args) -> int:
61
71
  with open(args.vtt, encoding="utf-8") as f:
62
72
  vtt = f.read()
63
73
  items = parse_video(info_json, vtt, fetched_at=time.time(), auto_captions=args.auto_captions)
64
- return _emit(items, _scope(args), args.json, store=args.store)
74
+ return _emit(items, _scope(args), args.json, store=args.store, ledger=args.ledger)
65
75
 
66
76
 
67
77
  def cmd_video(args) -> int:
68
- from gather.video_cmd import video_source_from_args
78
+ from gather.youtube_cmd import route_from_args
69
79
  try:
70
- src = video_source_from_args(args, with_comments=args.comments)
80
+ src = route_from_args(args, with_comments=args.comments)
71
81
  except ValueError as exc:
72
82
  print(f"fetch failed: {exc}", file=sys.stderr)
73
83
  return 2
@@ -115,17 +125,16 @@ def cmd_scholar(args) -> int:
115
125
  except Exception as exc:
116
126
  print(f"scholar failed: {exc}", file=sys.stderr)
117
127
  return 1
118
- return _emit_with_edges(items, edges, _scope(args), args.json, store=args.store)
128
+ return _emit_with_edges(items, edges, _scope(args), args.json, store=args.store, ledger=args.ledger)
119
129
 
120
130
 
121
- def _emit_with_edges(items, edges, scope, as_json, store=None) -> int:
131
+ def _emit_with_edges(items, edges, scope, as_json, store=None, ledger=None) -> int:
122
132
  """Emit scholar items plus citation-edge receipts, folding the edges into the same witnessed
123
133
  digest as the papers, so the seal covers the graph (nodes and links), not just the nodes."""
124
134
  from gather.digest import digest_of_receipts, verify_digest
125
- from gather.scope import filter_scope
126
135
  from gather.store import Corpus
127
136
 
128
- kept, dropped = filter_scope(items, scope)
137
+ kept, dropped = _scoped(items, scope, ledger)
129
138
  item_receipts = [
130
139
  {"kind": i.kind, "id": i.id, "title": i.title, "source": i.provenance.source,
131
140
  "ref": i.provenance.ref, "method": i.provenance.method, "sha256": i.provenance.sha256,
@@ -0,0 +1,93 @@
1
+ """The gather.docs tool and the MCP tool annotations.
2
+
3
+ Shared by the full MCP server and the local client profile, so the client can
4
+ serve gather.docs without importing the full tool surface.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ from gather.localpath import require_local
11
+ from gather.payloads import catalog_digest_payload
12
+ from gather.scope import filter_scope
13
+
14
+
15
+ def scope_terms(raw: object) -> list[str]:
16
+ if raw is None:
17
+ return []
18
+ if isinstance(raw, str):
19
+ return [term.strip() for term in raw.split(",") if term.strip()]
20
+ if isinstance(raw, list) and all(isinstance(term, str) for term in raw):
21
+ return [term.strip() for term in raw if term.strip()]
22
+ raise ValueError("scope must be a comma-separated string or a list of strings")
23
+
24
+
25
+ def payload_from_items(items, scope: list[str]) -> dict:
26
+ kept, dropped = filter_scope(items, scope)
27
+ return catalog_digest_payload(kept, dropped=dropped)
28
+
29
+
30
+ def _hints(title: str, *, read_only: bool = True, idempotent: bool = True,
31
+ open_world: bool = False) -> dict:
32
+ return {"title": title, "readOnlyHint": read_only, "destructiveHint": False,
33
+ "idempotentHint": idempotent, "openWorldHint": open_world}
34
+
35
+
36
+ # MCP tool annotations. A hint describes the tool to the client and grants
37
+ # nothing; launch grants still refuse network sources, commands and credentials.
38
+ TOOL_ANNOTATIONS = {
39
+ "gather.status": _hints("Gather status"),
40
+ "gather.doctor": _hints("Gather readiness check"),
41
+ "gather.docs": _hints("Read local documents with receipts"),
42
+ "gather.arxiv": _hints("Fetch arXiv metadata", idempotent=False, open_world=True),
43
+ "gather.federation": _hints("Validate or plan a source registry"),
44
+ "gather.run": _hints("Run a gather config", read_only=False, idempotent=False,
45
+ open_world=True),
46
+ "gather.context": _hints("Select verified corpus context"),
47
+ "gather.pilot": _hints("Run or verify a gather pilot", read_only=False, idempotent=False,
48
+ open_world=True),
49
+ "gather.fetch": _hints("Read an allowed web origin", idempotent=False, open_world=True),
50
+ }
51
+
52
+
53
+ def annotate(tool: dict) -> dict:
54
+ notes = dict(TOOL_ANNOTATIONS[tool["name"]])
55
+ return {**tool, "title": notes["title"], "annotations": notes}
56
+
57
+
58
+ def docs_tool() -> dict:
59
+ """The raw gather.docs definition, before annotation."""
60
+ return {
61
+ "name": "gather.docs",
62
+ "description": "Read a local text file or directory and return catalog rows plus digest receipts.",
63
+ "inputSchema": {
64
+ "type": "object",
65
+ "properties": {
66
+ "path": {"type": "string", "description": "local file or directory to read; a network "
67
+ "or device path returns NON_LOCAL_PATH"},
68
+ "scope": {
69
+ "description": (
70
+ "optional post-fetch content filter: keep items whose title or body "
71
+ "contains any term as a case-insensitive substring; omit for unfiltered capture"
72
+ ),
73
+ "oneOf": [
74
+ {"type": "string"},
75
+ {"type": "array", "items": {"type": "string"}},
76
+ ],
77
+ },
78
+ },
79
+ "required": ["path"],
80
+ },
81
+ }
82
+
83
+
84
+ def call_docs(args: dict) -> str:
85
+ """Run gather.docs: read one local path and return catalog rows plus digest receipts."""
86
+ from gather.docs import DocsSource
87
+
88
+ path = args.get("path")
89
+ if not isinstance(path, str) or not path:
90
+ raise ValueError("gather.docs requires a non-empty path")
91
+ require_local(path, label="path")
92
+ payload = payload_from_items(DocsSource().fetch(path), scope_terms(args.get("scope")))
93
+ return json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True)
@@ -0,0 +1,111 @@
1
+ """Filter ledgers: what a filter dropped, item by item, with one reason code each.
2
+
3
+ A filter that keeps some items and drops others makes a judgment a reader cannot see in the kept
4
+ set alone. ``apply_filters`` runs named filters in order; the first filter that rejects an item
5
+ gives its reason code, so every dropped item has exactly one. The ledger records the input
6
+ (count and a digest over the items' content hashes, in order), one row per dropped item (index,
7
+ id, content hash, reason), counts by reason, and the parameters of each filter.
8
+
9
+ ``write_ledger`` saves the ledger and the input items beside it (``ledger.json`` and
10
+ ``input.jsonl``), so a second person can recompute every count. ``verify_ledger`` does that
11
+ recomputation: it rebuilds the input digest, checks every row against the input, checks that
12
+ kept plus dropped equals the input and that the counts by reason match the rows, and, for the
13
+ built-in scope filter, runs the filter again and compares.
14
+
15
+ A ledger makes the filtering inspectable. It does not show that the filtering was fair, and a
16
+ reason code can still carry a judgment inside its label.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import os
24
+ from collections.abc import Callable, Sequence
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from gather.item import Item, make_item
29
+ from gather.scope import in_scope
30
+
31
+ LEDGER_SCHEMA = "gather.filter-ledger/1"
32
+ SCOPE_REASON = "out-of-scope"
33
+ DOES_NOT_PROVE = "that the filtering was fair; it makes the filtering inspectable"
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Filter:
38
+ """One named filter: ``keep(item)`` returns True to keep; a drop is recorded as ``reason``."""
39
+
40
+ reason: str
41
+ keep: Callable[[Item], bool]
42
+ params: dict[str, Any] = field(default_factory=dict)
43
+
44
+
45
+ def scope_filter(terms: Sequence[str]) -> Filter:
46
+ term_list = [t for t in terms if t]
47
+ return Filter(SCOPE_REASON, lambda it: in_scope(it, term_list), {"filter": "scope", "terms": term_list})
48
+
49
+
50
+ def input_digest(hashes: Sequence[str]) -> str:
51
+ return hashlib.sha256("\n".join(hashes).encode("utf-8")).hexdigest()
52
+
53
+
54
+ def apply_filters(items: Sequence[Item], filters: Sequence[Filter]) -> tuple[list[Item], dict]:
55
+ """The kept items, in order, and the ledger of the dropped."""
56
+ kept: list[Item] = []
57
+ rows: list[dict] = []
58
+ for index, it in enumerate(items):
59
+ reason = next((f.reason for f in filters if not f.keep(it)), None)
60
+ if reason is None:
61
+ kept.append(it)
62
+ else:
63
+ rows.append({"index": index, "id": it.id, "sha256": it.provenance.sha256, "reason": reason})
64
+ by_reason = {f.reason: sum(1 for r in rows if r["reason"] == f.reason) for f in filters}
65
+ ledger = {
66
+ "schema": LEDGER_SCHEMA,
67
+ "filters": [{"reason": f.reason, **f.params} for f in filters],
68
+ "input": {"count": len(items), "digest": input_digest([i.provenance.sha256 for i in items])},
69
+ "kept": len(kept), "dropped": len(rows), "by_reason": by_reason, "rows": rows,
70
+ "does_not_prove": DOES_NOT_PROVE,
71
+ }
72
+ return kept, ledger
73
+
74
+
75
+ def scope_with_ledger(items: Sequence[Item], terms: Sequence[str]) -> tuple[list[Item], dict]:
76
+ """The scope filter with its ledger. No terms keeps everything and drops nothing."""
77
+ return apply_filters(items, [scope_filter(terms)] if [t for t in terms if t] else [])
78
+
79
+
80
+ def _item_row(it: Item) -> dict:
81
+ p = it.provenance
82
+ return {"kind": it.kind, "id": it.id, "title": it.title, "text": it.text, "source": p.source,
83
+ "ref": p.ref, "method": p.method, "fetched_at": p.fetched_at, "sha256": p.sha256}
84
+
85
+
86
+ def write_ledger(directory: str, ledger: dict, items: Sequence[Item]) -> dict[str, str]:
87
+ """Write ``ledger.json`` and ``input.jsonl`` into ``directory`` (created if absent)."""
88
+ os.makedirs(directory, exist_ok=True)
89
+ paths = {"ledger": os.path.join(directory, "ledger.json"), "input": os.path.join(directory, "input.jsonl")}
90
+ with open(paths["input"], "w", encoding="utf-8") as f:
91
+ for it in items:
92
+ f.write(json.dumps(_item_row(it), ensure_ascii=False, sort_keys=True) + "\n")
93
+ with open(paths["ledger"], "w", encoding="utf-8") as f:
94
+ json.dump(ledger, f, indent=2, ensure_ascii=False)
95
+ return paths
96
+
97
+
98
+ def read_input(path: str) -> list[Item]:
99
+ """Items from an ``input.jsonl``. A row whose text no longer matches its hash is an error."""
100
+ items = []
101
+ with open(path, encoding="utf-8") as f:
102
+ for n, line in enumerate(f, 1):
103
+ if not line.strip():
104
+ continue
105
+ r = json.loads(line)
106
+ it = make_item(kind=r["kind"], id=r["id"], title=r["title"], text=r["text"], source=r["source"],
107
+ ref=r["ref"], method=r["method"], fetched_at=r["fetched_at"])
108
+ if it.provenance.sha256 != r["sha256"]:
109
+ raise ValueError(f"input line {n}: text does not match its sha256")
110
+ items.append(it)
111
+ return items