utim-cli 2.3.14__tar.gz → 2.3.15__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 (163) hide show
  1. {utim_cli-2.3.14 → utim_cli-2.3.15}/CHANGELOG.md +5 -0
  2. {utim_cli-2.3.14/utim_cli.egg-info → utim_cli-2.3.15}/PKG-INFO +1 -1
  3. {utim_cli-2.3.14 → utim_cli-2.3.15}/pyproject.toml +1 -1
  4. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim/__init__.py +1 -1
  5. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/_version.py +1 -1
  7. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/agent.py +7 -0
  8. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/crawler/markdown_distiller.py +258 -251
  9. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/orchestrator_llm_transport.py +43 -2
  10. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/orchestrator_turn_history.py +40 -0
  11. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/plugins/manager.py +135 -43
  12. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/cli_auth.py +6 -1
  13. utim_cli-2.3.15/utim_cli/server/email_utils.py +911 -0
  14. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/key_rotator.py +1055 -710
  15. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tools.py +58 -4
  16. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/feedback_dialog.py +87 -14
  17. utim_cli-2.3.15/utim_cli/tui/plugins_dialog.py +756 -0
  18. utim_cli-2.3.15/utim_cli/update_checker.py +545 -0
  19. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/utim.py +106 -3
  20. {utim_cli-2.3.14 → utim_cli-2.3.15/utim_cli.egg-info}/PKG-INFO +1 -1
  21. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli.egg-info/SOURCES.txt +1 -0
  22. utim_cli-2.3.14/utim_cli/server/email_utils.py +0 -691
  23. utim_cli-2.3.14/utim_cli/tui/plugins_dialog.py +0 -351
  24. {utim_cli-2.3.14 → utim_cli-2.3.15}/LICENSE +0 -0
  25. {utim_cli-2.3.14 → utim_cli-2.3.15}/MANIFEST.in +0 -0
  26. {utim_cli-2.3.14 → utim_cli-2.3.15}/README.md +0 -0
  27. {utim_cli-2.3.14 → utim_cli-2.3.15}/setup.cfg +0 -0
  28. {utim_cli-2.3.14 → utim_cli-2.3.15}/setup.py +0 -0
  29. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/_verify_repro.py +0 -0
  30. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/_verify_syntax.py +0 -0
  31. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/ask_helper.py +0 -0
  32. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/auth.py +0 -0
  33. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/backup.py +0 -0
  34. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/billing.py +0 -0
  35. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/blender_agent.py +0 -0
  36. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/bootstrap.py +0 -0
  37. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/brain.py +0 -0
  38. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/client_utils.py +0 -0
  39. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/config.py +0 -0
  40. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/constants.py +0 -0
  41. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/context_pruner.py +0 -0
  42. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/crawler/__init__.py +0 -0
  43. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/crawler/engine.py +0 -0
  44. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/dependency_manager.py +0 -0
  45. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/doctor.py +0 -0
  46. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/evidence_registry.py +0 -0
  47. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/harbor.py +0 -0
  48. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/knowledge_graph.py +0 -0
  49. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/local_db.py +0 -0
  50. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/logger.py +0 -0
  51. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/mcp_clean_wrapper.py +0 -0
  52. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/mcp_client.py +0 -0
  53. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/mcp_registry.json +0 -0
  54. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/models.txt +0 -0
  55. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/orchestrator.py +0 -0
  56. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/orchestrator_helpers.py +0 -0
  57. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/orchestrator_tool_exec.py +0 -0
  58. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/personalities.py +0 -0
  59. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/platform_installer.py +0 -0
  60. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/plugins/__init__.py +0 -0
  61. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/plugins/hooks.py +0 -0
  62. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/plugins/registry.py +0 -0
  63. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/plugins/schema.py +0 -0
  64. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/plugins/tool_loader.py +0 -0
  65. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/reflection.py +0 -0
  66. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/report.py +0 -0
  67. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/scrapy_search.py +0 -0
  68. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/sdk/__init__.py +0 -0
  69. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/sdk/config.py +0 -0
  70. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/sdk/context.py +0 -0
  71. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/sdk/events.py +0 -0
  72. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/sdk/handlers.py +0 -0
  73. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/sdk/session.py +0 -0
  74. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/sdk/sidecar.py +0 -0
  75. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/__init__.py +0 -0
  76. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/admin_auth.py +0 -0
  77. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/attribution.py +0 -0
  78. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/audit_log.py +0 -0
  79. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/auth.py +0 -0
  80. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/batch_processor.py +0 -0
  81. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/captcha.py +0 -0
  82. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/concurrency.py +0 -0
  83. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/db.py +0 -0
  84. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  85. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/about.md +0 -0
  86. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/changelog.md +0 -0
  87. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/docs.md +0 -0
  88. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/features.md +0 -0
  89. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/license.md +0 -0
  90. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/pricing.md +0 -0
  91. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/privacy.md +0 -0
  92. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/refund.md +0 -0
  93. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/support.md +0 -0
  94. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/docs_md/terms.md +0 -0
  95. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/exchange_rate.py +0 -0
  96. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/firebase.py +0 -0
  97. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/fix_duplicate_users.py +0 -0
  98. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/history.py +0 -0
  99. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/local_llm.py +0 -0
  100. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/logging_config.py +0 -0
  101. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/micro_batcher.py +0 -0
  102. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/model_agent.py +0 -0
  103. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/models.py +0 -0
  104. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/pricing_updater.py +0 -0
  105. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/provision_build.py +0 -0
  106. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/rate_limit.py +0 -0
  107. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/rewards_engine.py +0 -0
  108. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/router.py +0 -0
  109. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/__init__.py +0 -0
  110. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/admin_db_routes.py +0 -0
  111. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/auth_routes.py +0 -0
  112. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/billing_routes.py +0 -0
  113. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/completion_routes.py +0 -0
  114. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/credit_routes.py +0 -0
  115. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/feedback_routes.py +0 -0
  116. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/marketplace_routes.py +0 -0
  117. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/quota_routes.py +0 -0
  118. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/quota_share_routes.py +0 -0
  119. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/referral_routes.py +0 -0
  120. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/rewards_routes.py +0 -0
  121. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/security_routes.py +0 -0
  122. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/session_routes.py +0 -0
  123. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/routes/share_routes.py +0 -0
  124. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/server.py +0 -0
  125. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/server/storage_nodes.py +0 -0
  126. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/share.py +0 -0
  127. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/share_tui.py +0 -0
  128. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/situational_scoring.py +0 -0
  129. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/state.py +0 -0
  130. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/subagent_manager.py +0 -0
  131. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/task_dispatcher.py +0 -0
  132. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/task_logger.py +0 -0
  133. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tools_grep.py +0 -0
  134. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/__init__.py +0 -0
  135. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/history_dialog.py +0 -0
  136. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/marketplace_app_state.py +0 -0
  137. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/marketplace_dialog.py +0 -0
  138. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  139. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/mcp_dialog.py +0 -0
  140. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/miniagents_dialog.py +0 -0
  141. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/model_dialog.py +0 -0
  142. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/personality_dialog.py +0 -0
  143. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/publish_dialog.py +0 -0
  144. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/quota_dialog.py +0 -0
  145. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  146. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/quota_share_dialog.py +0 -0
  147. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/resume_dialog.py +0 -0
  148. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/rewards_tui.py +0 -0
  149. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/skills_dialog.py +0 -0
  150. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/subagents_dialog.py +0 -0
  151. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/tasks_dialog.py +0 -0
  152. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/thinking_display.py +0 -0
  153. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/tools_dialog.py +0 -0
  154. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/tui/update_dialog.py +0 -0
  155. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/utilities.py +0 -0
  156. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/utimmodel.txt +0 -0
  157. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/vector_memory.py +0 -0
  158. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/wheel.py +0 -0
  159. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli/workspace.py +0 -0
  160. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli.egg-info/dependency_links.txt +0 -0
  161. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli.egg-info/entry_points.txt +0 -0
  162. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli.egg-info/requires.txt +0 -0
  163. {utim_cli-2.3.14 → utim_cli-2.3.15}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.15] - 2026-08-29
4
+
5
+ ### 🛠️ Minor Enhancements and Tool Call Bug Fixes
6
+ - Minor enhancements and fixed tool call bugs
7
+
3
8
  ## [2.3.14] - 2026-08-28
4
9
 
5
10
  ### ⚡ Request Queue & Scaled Free Capacity
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.3.14
3
+ Version: 2.3.15
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: Emend AI Proprietary EULA
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.3.14"
7
+ version = "2.3.15"
8
8
 
9
9
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
10
10
  readme = "README.md"
@@ -14,7 +14,7 @@ Quickstart:
14
14
  try:
15
15
  from utim_cli._version import VERSION as __version__
16
16
  except ImportError:
17
- __version__ = "2.3.14"
17
+ __version__ = "2.3.15"
18
18
 
19
19
  # Developer-friendly core classes (similar to `import genai` / `import openai`)
20
20
  from utim_cli.sdk.session import UtimSession as Session
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.3.14"
8
+ __version__ = "2.3.15"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,6 +11,6 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.3.14"
14
+ VERSION = "2.3.15"
15
15
 
16
16
 
@@ -168,6 +168,13 @@ class ReActAgent:
168
168
  arguments[key] = val
169
169
  tool_name = actual_name
170
170
 
171
+ # Collapse duplicated tool names (e.g. 'read_fileread_file', 'read_file read_file',
172
+ # 'run_commandrun_command') produced by buggy model/proxy tool-call serialization.
173
+ import re as _re_dup
174
+ _dup_match = _re_dup.match(r'^([a-zA-Z_]\w*)[\s_]*\1$', tool_name)
175
+ if _dup_match:
176
+ tool_name = _dup_match.group(1)
177
+
171
178
  # Map common alias tool names to actual UTIM CLI tool names
172
179
  _TOOL_NAME_ALIASES = {
173
180
  "shell": "run_command",
@@ -1,251 +1,258 @@
1
- """Semantic Markdown Distiller for UTIM HyperCrawler.
2
-
3
- Transforms raw HTML documents into dense, structured, noise-free Markdown.
4
- Preserves:
5
- - Tables (converted to standard GitHub-Flavored Markdown tables)
6
- - Code blocks (with syntax language identifiers)
7
- - Headings (#, ##, ###, etc.)
8
- - Lists (ordered and unordered)
9
- - Emphasis (bold, italics, inline code)
10
- - Main article content (stripping navbars, footers, cookie banners, ads, SVGs)
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import re
16
- import html as html_lib
17
- from typing import Optional, Tuple, List
18
- from bs4 import BeautifulSoup, Comment, NavigableString, Tag
19
-
20
-
21
- # Tags to remove entirely (including their contents)
22
- _NOISE_TAGS = {
23
- "script", "style", "nav", "footer", "header", "aside", "noscript",
24
- "iframe", "svg", "canvas", "form", "button", "dialog", "menu",
25
- }
26
-
27
- # Common noise classes / IDs in modern web design
28
- _NOISE_CLASS_PATTERNS = re.compile(
29
- r"(ad[s\-_]|banner|cookie|consent|popup|modal|sidebar|newsletter|social|share|widget|tracking|promo)",
30
- re.IGNORECASE,
31
- )
32
-
33
-
34
- class SemanticMarkdownDistiller:
35
- """Converts HTML into token-efficient, highly-structured Markdown for LLMs."""
36
-
37
- def __init__(self, max_length: int = 40000):
38
- self.max_length = max_length
39
-
40
- def distill(self, html_content: str, base_url: str = "") -> Tuple[str, str]:
41
- """Distills raw HTML into (clean_markdown, page_title)."""
42
- if not html_content or not html_content.strip():
43
- return "", ""
44
-
45
- try:
46
- soup = BeautifulSoup(html_content, "html.parser")
47
- except Exception:
48
- # Fallback simple regex extraction if parser fails
49
- text = re.sub(r"<[^>]+>", " ", html_content)
50
- return html_lib.unescape(text).strip()[:self.max_length], ""
51
-
52
- # 1. Extract Page Title
53
- title = ""
54
- title_el = soup.find("title") or soup.find("h1")
55
- if title_el:
56
- title = title_el.get_text(strip=True)
57
-
58
- # 2. Strip comments and unwanted noise elements
59
- for comment in soup.find_all(string=lambda s: isinstance(s, Comment)):
60
- comment.extract()
61
-
62
- for tag_name in _NOISE_TAGS:
63
- for el in soup.find_all(tag_name):
64
- el.decompose()
65
-
66
- # 3. Strip obvious ad and cookie banner containers
67
- for el in soup.find_all(["div", "section", "aside"]):
68
- classes = " ".join(el.get("class", [])) if el.get("class") else ""
69
- el_id = el.get("id", "")
70
- if _NOISE_CLASS_PATTERNS.search(classes) or _NOISE_CLASS_PATTERNS.search(el_id):
71
- # Only decompose if it doesn't contain main article/content
72
- if not any(k in (classes + el_id).lower() for k in ("article", "content", "main", "post", "entry")):
73
- el.decompose()
74
-
75
- # 4. Target the main content container if present
76
- main_content = (
77
- soup.find("main")
78
- or soup.find("article")
79
- or soup.find("div", {"id": re.compile(r"(content|main|article|docs|post)", re.I)})
80
- or soup.find("div", {"class": re.compile(r"(content|main|article|docs|post-body)", re.I)})
81
- or soup.body
82
- or soup
83
- )
84
-
85
- # 5. Recursively convert DOM tree to Markdown
86
- md_text = self._convert_element(main_content).strip()
87
-
88
- # 6. Normalize whitespace and blank lines
89
- md_text = re.sub(r"\n{3,}", "\n\n", md_text)
90
-
91
- if len(md_text) > self.max_length:
92
- md_text = md_text[:self.max_length] + "\n\n... [Content truncated for length] ..."
93
-
94
- return md_text, title
95
-
96
- def _convert_element(self, element) -> str:
97
- """Recursively converts a BeautifulSoup Tag into Markdown."""
98
- if element is None:
99
- return ""
100
-
101
- if isinstance(element, NavigableString):
102
- text = str(element)
103
- # Normalize whitespace within inline text
104
- return re.sub(r"[ \t\r\f\v]+", " ", text)
105
-
106
- tag = element.name.lower() if element.name else ""
107
-
108
- # Headings
109
- if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
110
- level = int(tag[1])
111
- inner = self._get_inner_text(element).strip()
112
- if inner:
113
- return f"\n\n{'#' * level} {inner}\n\n"
114
- return ""
115
-
116
- # Paragraphs & Blocks
117
- if tag in ("p", "div", "section", "article"):
118
- inner = "".join(self._convert_element(c) for c in element.children).strip()
119
- if inner:
120
- return f"\n\n{inner}\n\n"
121
- return ""
122
-
123
- # Code Blocks (<pre><code> or <pre>)
124
- if tag == "pre":
125
- code_el = element.find("code")
126
- lang = ""
127
- if code_el and code_el.get("class"):
128
- for c in code_el["class"]:
129
- if c.startswith("language-") or c.startswith("lang-"):
130
- lang = c.split("-", 1)[1]
131
- break
132
- code_text = (code_el or element).get_text()
133
- return f"\n\n```{lang}\n{code_text.rstrip()}\n```\n\n"
134
-
135
- # Inline Code (<code>)
136
- if tag == "code":
137
- code_text = element.get_text()
138
- if "\n" in code_text:
139
- return f"\n\n```\n{code_text.rstrip()}\n```\n\n"
140
- return f"`{code_text.strip()}`"
141
-
142
- # Blockquote
143
- if tag == "blockquote":
144
- inner = "".join(self._convert_element(c) for c in element.children).strip()
145
- lines = [f"> {l}" for l in inner.splitlines()]
146
- return "\n\n" + "\n".join(lines) + "\n\n"
147
-
148
- # Lists (<ul>, <ol>)
149
- if tag in ("ul", "ol"):
150
- items = []
151
- is_ordered = (tag == "ol")
152
- for idx, li in enumerate(element.find_all("li", recursive=False)):
153
- prefix = f"{idx + 1}. " if is_ordered else "- "
154
- li_text = "".join(self._convert_element(c) for c in li.children).strip()
155
- if li_text:
156
- items.append(f"{prefix}{li_text}")
157
- if items:
158
- return "\n\n" + "\n".join(items) + "\n\n"
159
- return ""
160
-
161
- # List item
162
- if tag == "li":
163
- return "".join(self._convert_element(c) for c in element.children).strip()
164
-
165
- # Tables (<table>) -> Markdown Table
166
- if tag == "table":
167
- return self._convert_table(element)
168
-
169
- # Emphasis & Formatting
170
- if tag in ("strong", "b"):
171
- inner = "".join(self._convert_element(c) for c in element.children).strip()
172
- return f"**{inner}**" if inner else ""
173
-
174
- if tag in ("em", "i"):
175
- inner = "".join(self._convert_element(c) for c in element.children).strip()
176
- return f"*{inner}*" if inner else ""
177
-
178
- # Links (<a>)
179
- if tag == "a":
180
- href = element.get("href", "").strip()
181
- inner = "".join(self._convert_element(c) for c in element.children).strip()
182
- if href and inner and not href.startswith("javascript:") and not href.startswith("#"):
183
- return f"[{inner}]({href})"
184
- return inner
185
-
186
- # Images (<img>)
187
- if tag == "img":
188
- alt = element.get("alt", "").strip()
189
- src = element.get("src", "").strip()
190
- if src and not src.startswith("data:"):
191
- return f"![{alt}]({src})"
192
- return f"[{alt}]" if alt else ""
193
-
194
- # Line Break (<br>, <hr>)
195
- if tag == "br":
196
- return "\n"
197
- if tag == "hr":
198
- return "\n\n---\n\n"
199
-
200
- # Default: traverse children
201
- return "".join(self._convert_element(c) for c in element.children)
202
-
203
- def _get_inner_text(self, tag: Tag) -> str:
204
- """Extracts clean text without extra formatting."""
205
- return tag.get_text(separator=" ", strip=True)
206
-
207
- def _convert_table(self, table_tag: Tag) -> str:
208
- """Converts HTML table into a clean GitHub Flavored Markdown table."""
209
- rows = table_tag.find_all("tr")
210
- if not rows:
211
- return ""
212
-
213
- table_matrix: List[List[str]] = []
214
- for r in rows:
215
- cols = r.find_all(["th", "td"])
216
- col_texts = []
217
- for c in cols:
218
- # Get text and clean pipe characters to prevent markdown table breakage
219
- c_text = "".join(self._convert_element(child) for child in c.children).strip()
220
- c_text = c_text.replace("\n", " ").replace("|", "\\|")
221
- col_texts.append(c_text)
222
- if any(col_texts):
223
- table_matrix.append(col_texts)
224
-
225
- if not table_matrix:
226
- return ""
227
-
228
- # Normalize column widths
229
- max_cols = max(len(r) for r in table_matrix)
230
- for r in table_matrix:
231
- while len(r) < max_cols:
232
- r.append("")
233
-
234
- header_row = table_matrix[0]
235
- separator_row = ["---"] * max_cols
236
- body_rows = table_matrix[1:] if len(table_matrix) > 1 else []
237
-
238
- lines = [
239
- "| " + " | ".join(header_row) + " |",
240
- "| " + " | ".join(separator_row) + " |",
241
- ]
242
- for r in body_rows:
243
- lines.append("| " + " | ".join(r) + " |")
244
-
245
- return "\n\n" + "\n".join(lines) + "\n\n"
246
-
247
-
248
- def html_to_markdown(html_content: str, base_url: str = "", max_length: int = 40000) -> Tuple[str, str]:
249
- """Convenience function to distill HTML into clean Markdown."""
250
- distiller = SemanticMarkdownDistiller(max_length=max_length)
251
- return distiller.distill(html_content, base_url=base_url)
1
+ """Semantic Markdown Distiller for UTIM HyperCrawler.
2
+
3
+ Transforms raw HTML documents into dense, structured, noise-free Markdown.
4
+ Preserves:
5
+ - Tables (converted to standard GitHub-Flavored Markdown tables)
6
+ - Code blocks (with syntax language identifiers)
7
+ - Headings (#, ##, ###, etc.)
8
+ - Lists (ordered and unordered)
9
+ - Emphasis (bold, italics, inline code)
10
+ - Main article content (stripping navbars, footers, cookie banners, ads, SVGs)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import html as html_lib
17
+ from typing import Optional, Tuple, List
18
+ from bs4 import BeautifulSoup, Comment, NavigableString, Tag
19
+
20
+
21
+ # Tags to remove entirely (including their contents)
22
+ _NOISE_TAGS = {
23
+ "script", "style", "nav", "footer", "header", "aside", "noscript",
24
+ "iframe", "svg", "canvas", "form", "button", "dialog", "menu",
25
+ }
26
+
27
+ # Common noise classes / IDs in modern web design
28
+ _NOISE_CLASS_PATTERNS = re.compile(
29
+ r"(ad[s\-_]|banner|cookie|consent|popup|modal|sidebar|newsletter|social|share|widget|tracking|promo)",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+
34
+ class SemanticMarkdownDistiller:
35
+ """Converts HTML into token-efficient, highly-structured Markdown for LLMs."""
36
+
37
+ def __init__(self, max_length: int = 40000):
38
+ self.max_length = max_length
39
+
40
+ def distill(self, html_content: str, base_url: str = "") -> Tuple[str, str]:
41
+ """Distills raw HTML into (clean_markdown, page_title)."""
42
+ if not html_content or not html_content.strip():
43
+ return "", ""
44
+
45
+ try:
46
+ soup = BeautifulSoup(html_content, "html.parser")
47
+ except Exception:
48
+ # Fallback simple regex extraction if parser fails
49
+ text = re.sub(r"<[^>]+>", " ", html_content)
50
+ return html_lib.unescape(text).strip()[:self.max_length], ""
51
+
52
+ # 1. Extract Page Title
53
+ title = ""
54
+ title_el = soup.find("title") or soup.find("h1")
55
+ if title_el:
56
+ title = title_el.get_text(strip=True)
57
+
58
+ # 2. Strip comments and unwanted noise elements
59
+ for comment in soup.find_all(string=lambda s: isinstance(s, Comment)):
60
+ comment.extract()
61
+
62
+ for tag_name in _NOISE_TAGS:
63
+ for el in soup.find_all(tag_name):
64
+ el.decompose()
65
+
66
+ # 3. Strip obvious ad and cookie banner containers
67
+ for el in soup.find_all(["div", "section", "aside"]):
68
+ # Defensive: BeautifulSoup can yield tags with attrs=None for
69
+ # malformed/void elements. Guard before calling .get() so a single
70
+ # bad tag (common in GitHub/React-rendered HTML) doesn't kill the
71
+ # entire distillation pass with AttributeError: 'NoneType' has no
72
+ # attribute 'get'.
73
+ if not el.attrs:
74
+ continue
75
+ classes = " ".join(el.get("class", [])) if el.get("class") else ""
76
+ el_id = el.get("id", "")
77
+ if _NOISE_CLASS_PATTERNS.search(classes) or _NOISE_CLASS_PATTERNS.search(el_id):
78
+ # Only decompose if it doesn't contain main article/content
79
+ if not any(k in (classes + el_id).lower() for k in ("article", "content", "main", "post", "entry")):
80
+ el.decompose()
81
+
82
+ # 4. Target the main content container if present
83
+ main_content = (
84
+ soup.find("main")
85
+ or soup.find("article")
86
+ or soup.find("div", {"id": re.compile(r"(content|main|article|docs|post)", re.I)})
87
+ or soup.find("div", {"class": re.compile(r"(content|main|article|docs|post-body)", re.I)})
88
+ or soup.body
89
+ or soup
90
+ )
91
+
92
+ # 5. Recursively convert DOM tree to Markdown
93
+ md_text = self._convert_element(main_content).strip()
94
+
95
+ # 6. Normalize whitespace and blank lines
96
+ md_text = re.sub(r"\n{3,}", "\n\n", md_text)
97
+
98
+ if len(md_text) > self.max_length:
99
+ md_text = md_text[:self.max_length] + "\n\n... [Content truncated for length] ..."
100
+
101
+ return md_text, title
102
+
103
+ def _convert_element(self, element) -> str:
104
+ """Recursively converts a BeautifulSoup Tag into Markdown."""
105
+ if element is None:
106
+ return ""
107
+
108
+ if isinstance(element, NavigableString):
109
+ text = str(element)
110
+ # Normalize whitespace within inline text
111
+ return re.sub(r"[ \t\r\f\v]+", " ", text)
112
+
113
+ tag = element.name.lower() if element.name else ""
114
+
115
+ # Headings
116
+ if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
117
+ level = int(tag[1])
118
+ inner = self._get_inner_text(element).strip()
119
+ if inner:
120
+ return f"\n\n{'#' * level} {inner}\n\n"
121
+ return ""
122
+
123
+ # Paragraphs & Blocks
124
+ if tag in ("p", "div", "section", "article"):
125
+ inner = "".join(self._convert_element(c) for c in element.children).strip()
126
+ if inner:
127
+ return f"\n\n{inner}\n\n"
128
+ return ""
129
+
130
+ # Code Blocks (<pre><code> or <pre>)
131
+ if tag == "pre":
132
+ code_el = element.find("code")
133
+ lang = ""
134
+ if code_el and code_el.get("class"):
135
+ for c in code_el["class"]:
136
+ if c.startswith("language-") or c.startswith("lang-"):
137
+ lang = c.split("-", 1)[1]
138
+ break
139
+ code_text = (code_el or element).get_text()
140
+ return f"\n\n```{lang}\n{code_text.rstrip()}\n```\n\n"
141
+
142
+ # Inline Code (<code>)
143
+ if tag == "code":
144
+ code_text = element.get_text()
145
+ if "\n" in code_text:
146
+ return f"\n\n```\n{code_text.rstrip()}\n```\n\n"
147
+ return f"`{code_text.strip()}`"
148
+
149
+ # Blockquote
150
+ if tag == "blockquote":
151
+ inner = "".join(self._convert_element(c) for c in element.children).strip()
152
+ lines = [f"> {l}" for l in inner.splitlines()]
153
+ return "\n\n" + "\n".join(lines) + "\n\n"
154
+
155
+ # Lists (<ul>, <ol>)
156
+ if tag in ("ul", "ol"):
157
+ items = []
158
+ is_ordered = (tag == "ol")
159
+ for idx, li in enumerate(element.find_all("li", recursive=False)):
160
+ prefix = f"{idx + 1}. " if is_ordered else "- "
161
+ li_text = "".join(self._convert_element(c) for c in li.children).strip()
162
+ if li_text:
163
+ items.append(f"{prefix}{li_text}")
164
+ if items:
165
+ return "\n\n" + "\n".join(items) + "\n\n"
166
+ return ""
167
+
168
+ # List item
169
+ if tag == "li":
170
+ return "".join(self._convert_element(c) for c in element.children).strip()
171
+
172
+ # Tables (<table>) -> Markdown Table
173
+ if tag == "table":
174
+ return self._convert_table(element)
175
+
176
+ # Emphasis & Formatting
177
+ if tag in ("strong", "b"):
178
+ inner = "".join(self._convert_element(c) for c in element.children).strip()
179
+ return f"**{inner}**" if inner else ""
180
+
181
+ if tag in ("em", "i"):
182
+ inner = "".join(self._convert_element(c) for c in element.children).strip()
183
+ return f"*{inner}*" if inner else ""
184
+
185
+ # Links (<a>)
186
+ if tag == "a":
187
+ href = element.get("href", "").strip()
188
+ inner = "".join(self._convert_element(c) for c in element.children).strip()
189
+ if href and inner and not href.startswith("javascript:") and not href.startswith("#"):
190
+ return f"[{inner}]({href})"
191
+ return inner
192
+
193
+ # Images (<img>)
194
+ if tag == "img":
195
+ alt = element.get("alt", "").strip()
196
+ src = element.get("src", "").strip()
197
+ if src and not src.startswith("data:"):
198
+ return f"![{alt}]({src})"
199
+ return f"[{alt}]" if alt else ""
200
+
201
+ # Line Break (<br>, <hr>)
202
+ if tag == "br":
203
+ return "\n"
204
+ if tag == "hr":
205
+ return "\n\n---\n\n"
206
+
207
+ # Default: traverse children
208
+ return "".join(self._convert_element(c) for c in element.children)
209
+
210
+ def _get_inner_text(self, tag: Tag) -> str:
211
+ """Extracts clean text without extra formatting."""
212
+ return tag.get_text(separator=" ", strip=True)
213
+
214
+ def _convert_table(self, table_tag: Tag) -> str:
215
+ """Converts HTML table into a clean GitHub Flavored Markdown table."""
216
+ rows = table_tag.find_all("tr")
217
+ if not rows:
218
+ return ""
219
+
220
+ table_matrix: List[List[str]] = []
221
+ for r in rows:
222
+ cols = r.find_all(["th", "td"])
223
+ col_texts = []
224
+ for c in cols:
225
+ # Get text and clean pipe characters to prevent markdown table breakage
226
+ c_text = "".join(self._convert_element(child) for child in c.children).strip()
227
+ c_text = c_text.replace("\n", " ").replace("|", "\\|")
228
+ col_texts.append(c_text)
229
+ if any(col_texts):
230
+ table_matrix.append(col_texts)
231
+
232
+ if not table_matrix:
233
+ return ""
234
+
235
+ # Normalize column widths
236
+ max_cols = max(len(r) for r in table_matrix)
237
+ for r in table_matrix:
238
+ while len(r) < max_cols:
239
+ r.append("")
240
+
241
+ header_row = table_matrix[0]
242
+ separator_row = ["---"] * max_cols
243
+ body_rows = table_matrix[1:] if len(table_matrix) > 1 else []
244
+
245
+ lines = [
246
+ "| " + " | ".join(header_row) + " |",
247
+ "| " + " | ".join(separator_row) + " |",
248
+ ]
249
+ for r in body_rows:
250
+ lines.append("| " + " | ".join(r) + " |")
251
+
252
+ return "\n\n" + "\n".join(lines) + "\n\n"
253
+
254
+
255
+ def html_to_markdown(html_content: str, base_url: str = "", max_length: int = 40000) -> Tuple[str, str]:
256
+ """Convenience function to distill HTML into clean Markdown."""
257
+ distiller = SemanticMarkdownDistiller(max_length=max_length)
258
+ return distiller.distill(html_content, base_url=base_url)