multacd 2.0.0b3__tar.gz → 2.0.0b4__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 (196) hide show
  1. {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/workflows/release.yml +1 -1
  2. {multacd-2.0.0b3 → multacd-2.0.0b4}/CHANGELOG.md +11 -0
  3. {multacd-2.0.0b3 → multacd-2.0.0b4}/PKG-INFO +4 -4
  4. multacd-2.0.0b4/core/config.py +605 -0
  5. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/version.py +1 -1
  6. multacd-2.0.0b4/docs/install.md +49 -0
  7. {multacd-2.0.0b3 → multacd-2.0.0b4}/pyproject.toml +9 -7
  8. multacd-2.0.0b4/requirements.txt +12 -0
  9. {multacd-2.0.0b3 → multacd-2.0.0b4}/scripts/install.sh +1 -1
  10. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/__main__.py +3 -5
  11. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/tavily.py +2 -2
  12. multacd-2.0.0b3/core/config.py +0 -340
  13. multacd-2.0.0b3/docs/install.md +0 -35
  14. multacd-2.0.0b3/requirements.txt +0 -13
  15. {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/pull_request_template.md +0 -0
  18. {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/workflows/test.yml +0 -0
  19. {multacd-2.0.0b3 → multacd-2.0.0b4}/.gitignore +0 -0
  20. {multacd-2.0.0b3 → multacd-2.0.0b4}/CONTRIBUTING.md +0 -0
  21. {multacd-2.0.0b3 → multacd-2.0.0b4}/DESIGN.md +0 -0
  22. {multacd-2.0.0b3 → multacd-2.0.0b4}/LICENSE +0 -0
  23. {multacd-2.0.0b3 → multacd-2.0.0b4}/README.md +0 -0
  24. {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/__init__.py +0 -0
  25. {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/generator.py +0 -0
  26. {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/privacy.py +0 -0
  27. {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/__init__.py +0 -0
  28. {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/git_source.py +0 -0
  29. {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/news_source.py +0 -0
  30. {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/todo_source.py +0 -0
  31. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/__init__.py +0 -0
  32. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/agent_loop.py +0 -0
  33. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/clipboard.py +0 -0
  34. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/codebase.py +0 -0
  35. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/llm_client.py +0 -0
  36. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/mode_manager.py +0 -0
  37. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/permissions.py +0 -0
  38. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/plugin_loader.py +0 -0
  39. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/prompt_composer.py +0 -0
  40. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/__init__.py +0 -0
  41. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/__main__.py +0 -0
  42. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/anthropic.py +0 -0
  43. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/openai_compat.py +0 -0
  44. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/__init__.py +0 -0
  45. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/bus.py +0 -0
  46. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/orchestrator.py +0 -0
  47. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/query_generator.py +0 -0
  48. {multacd-2.0.0b3 → multacd-2.0.0b4}/core/updater.py +0 -0
  49. {multacd-2.0.0b3 → multacd-2.0.0b4}/daemon/__init__.py +0 -0
  50. {multacd-2.0.0b3 → multacd-2.0.0b4}/daemon/ipc.py +0 -0
  51. {multacd-2.0.0b3 → multacd-2.0.0b4}/daemon/process.py +0 -0
  52. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/configuration.md +0 -0
  53. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/index.md +0 -0
  54. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/mkdocs.yml +0 -0
  55. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/modes/coding.md +0 -0
  56. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/modes/personal.md +0 -0
  57. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/modes/research.md +0 -0
  58. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/plugins.md +0 -0
  59. {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/quickstart.md +0 -0
  60. {multacd-2.0.0b3 → multacd-2.0.0b4}/main.py +0 -0
  61. {multacd-2.0.0b3 → multacd-2.0.0b4}/memory/__init__.py +0 -0
  62. {multacd-2.0.0b3 → multacd-2.0.0b4}/memory/context.py +0 -0
  63. {multacd-2.0.0b3 → multacd-2.0.0b4}/memory/store.py +0 -0
  64. {multacd-2.0.0b3 → multacd-2.0.0b4}/multacd.spec +0 -0
  65. {multacd-2.0.0b3 → multacd-2.0.0b4}/plan/PLAN-phase1.md +0 -0
  66. {multacd-2.0.0b3 → multacd-2.0.0b4}/plan/PLAN-phase2.md +0 -0
  67. {multacd-2.0.0b3 → multacd-2.0.0b4}/plugins/README.md +0 -0
  68. {multacd-2.0.0b3 → multacd-2.0.0b4}/plugins/example_plugin.py +0 -0
  69. {multacd-2.0.0b3 → multacd-2.0.0b4}/roadmap/ROADMAP.md +0 -0
  70. {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/__init__.py +0 -0
  71. {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/engine.py +0 -0
  72. {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/jobs/__init__.py +0 -0
  73. {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/jobs/briefing_job.py +0 -0
  74. {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/jobs/research_job.py +0 -0
  75. {multacd-2.0.0b3 → multacd-2.0.0b4}/scripts/install.ps1 +0 -0
  76. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/__init__.py +0 -0
  77. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/base.py +0 -0
  78. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/brave.py +0 -0
  79. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/duckduckgo.py +0 -0
  80. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/exa.py +0 -0
  81. {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/serpapi.py +0 -0
  82. {multacd-2.0.0b3 → multacd-2.0.0b4}/soul.md +0 -0
  83. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/__init__.py +0 -0
  84. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/conftest.py +0 -0
  85. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/integration/__init__.py +0 -0
  86. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/integration/test_agent_loop.py +0 -0
  87. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/test_selftests.py +0 -0
  88. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/__init__.py +0 -0
  89. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_binary.py +0 -0
  90. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_config.py +0 -0
  91. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_docs.py +0 -0
  92. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_entrypoint.py +0 -0
  93. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_github.py +0 -0
  94. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_installer.py +0 -0
  95. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_permissions.py +0 -0
  96. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_plugin_loader.py +0 -0
  97. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_privacy_filter.py +0 -0
  98. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_search_providers/__init__.py +0 -0
  99. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_search_providers/test_tavily.py +0 -0
  100. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/__init__.py +0 -0
  101. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_bash.py +0 -0
  102. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_read_file.py +0 -0
  103. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_web_scrape.py +0 -0
  104. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_write_file.py +0 -0
  105. {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_updater.py +0 -0
  106. {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/__init__.py +0 -0
  107. {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/access_control.py +0 -0
  108. {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/agent.py +0 -0
  109. {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/bot.py +0 -0
  110. {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/file_handler.py +0 -0
  111. {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/formatter.py +0 -0
  112. {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/handlers.py +0 -0
  113. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/__init__.py +0 -0
  114. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/__init__.py +0 -0
  115. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/ask.py +0 -0
  116. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/skill.py +0 -0
  117. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/task.py +0 -0
  118. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/todo_write.py +0 -0
  119. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/__init__.py +0 -0
  120. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/lint_python.py +0 -0
  121. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/run_python.py +0 -0
  122. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/run_tests.py +0 -0
  123. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/codebase/__init__.py +0 -0
  124. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/codebase/scan_codebase.py +0 -0
  125. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/common.py +0 -0
  126. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/__init__.py +0 -0
  127. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/apply_patch.py +0 -0
  128. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/delete_file.py +0 -0
  129. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/edit_file.py +0 -0
  130. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/glob.py +0 -0
  131. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/grep.py +0 -0
  132. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/list_dir.py +0 -0
  133. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/move_file.py +0 -0
  134. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/multi_edit.py +0 -0
  135. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/read_file.py +0 -0
  136. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/read_many_files.py +0 -0
  137. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/write_file.py +0 -0
  138. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/__init__.py +0 -0
  139. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/_helper.py +0 -0
  140. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_add.py +0 -0
  141. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_branch.py +0 -0
  142. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_checkout.py +0 -0
  143. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_commit.py +0 -0
  144. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_diff.py +0 -0
  145. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_log.py +0 -0
  146. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_merge.py +0 -0
  147. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_pull.py +0 -0
  148. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_push.py +0 -0
  149. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_status.py +0 -0
  150. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/__init__.py +0 -0
  151. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/forget.py +0 -0
  152. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/recall.py +0 -0
  153. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/remember.py +0 -0
  154. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/__init__.py +0 -0
  155. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/cancel_job.py +0 -0
  156. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/daemon_status.py +0 -0
  157. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/generate_briefing.py +0 -0
  158. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/get_jobs.py +0 -0
  159. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/schedule_job.py +0 -0
  160. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/send_telegram.py +0 -0
  161. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/user_manager.py +0 -0
  162. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/registry.py +0 -0
  163. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/__init__.py +0 -0
  164. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/deep_research.py +0 -0
  165. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/export_research.py +0 -0
  166. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/quick_research.py +0 -0
  167. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/web_scrape.py +0 -0
  168. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/web_search.py +0 -0
  169. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/shell/__init__.py +0 -0
  170. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/shell/bash.py +0 -0
  171. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/streaming.py +0 -0
  172. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/web/__init__.py +0 -0
  173. {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/web/web_fetch.py +0 -0
  174. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/__init__.py +0 -0
  175. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/app.py +0 -0
  176. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/icons.py +0 -0
  177. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/screens/__init__.py +0 -0
  178. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/screens/main_screen.py +0 -0
  179. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/screens/setup_wizard.py +0 -0
  180. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/themes/__init__.py +0 -0
  181. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/themes/__main__.py +0 -0
  182. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/tokens.py +0 -0
  183. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/__init__.py +0 -0
  184. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/chat_panel.py +0 -0
  185. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/confirm_dialog.py +0 -0
  186. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/diff_viewer.py +0 -0
  187. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/file_tree.py +0 -0
  188. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/info_panel.py +0 -0
  189. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/input_bar.py +0 -0
  190. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/model_selector.py +0 -0
  191. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/permission_popup.py +0 -0
  192. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/slash_palette.py +0 -0
  193. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/sources_panel.py +0 -0
  194. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/status_bar.py +0 -0
  195. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/thinking_bar.py +0 -0
  196. {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/tool_activity.py +0 -0
@@ -101,7 +101,7 @@ jobs:
101
101
 
102
102
  ```bash
103
103
  pkg install -y python git
104
- pip install "multacd==2.0.0b3"
104
+ pip install "multacd==2.0.0b4"
105
105
  ```
106
106
 
107
107
  Windows (PowerShell):
@@ -2,6 +2,17 @@
2
2
 
3
3
  Format: Keep a Changelog. Versi: Semantic Versioning.
4
4
 
5
+ ## [2.0.0b4] - 2026-09-12
6
+
7
+ Beta v2 keempat — Termux harusnya tembus sekarang (#24): `pydantic`
8
+ dibuang total (pydantic-core Rust, 0 wheel Android dari 159 rilis —
9
+ config rewrite ke dataclass + ConfigError, API konstruktor identik),
10
+ `tavily-python` jadi extra `[tavily]` (bawa tiktoken Rust),
11
+ `APScheduler<4` (kunci alpha rewrite), binary susut ke 33MB.
12
+
13
+ Install: `pip install "multacd==2.0.0b4"` (tanpa --pre, tanpa rust,
14
+ tanpa compiler — pyyaml fallback pure-python).
15
+
5
16
  ## [2.0.0b3] - 2026-09-12
6
17
 
7
18
  Beta v2 ketiga — benerin install Termux (#24): `exa-py` jadi extra
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: multacd
3
- Version: 2.0.0b3
3
+ Version: 2.0.0b4
4
4
  Summary: Agentic TUI for coding, research, and personal automation
5
5
  Project-URL: Homepage, https://github.com/prototypeall850-creator/multacd
6
6
  Project-URL: Issues, https://github.com/prototypeall850-creator/multacd/issues
@@ -16,14 +16,12 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
17
  Classifier: Topic :: Terminals
18
18
  Requires-Python: >=3.10
19
- Requires-Dist: apscheduler>=3.10.0
19
+ Requires-Dist: apscheduler<4,>=3.10.0
20
20
  Requires-Dist: beautifulsoup4>=4.12.0
21
21
  Requires-Dist: httpx>=0.27.0
22
22
  Requires-Dist: markdownify>=0.12.0
23
- Requires-Dist: pydantic>=2.0
24
23
  Requires-Dist: python-telegram-bot>=21.0
25
24
  Requires-Dist: pyyaml>=6.0
26
- Requires-Dist: tavily-python>=0.3.0
27
25
  Requires-Dist: textual>=0.80.0
28
26
  Provides-Extra: dev
29
27
  Requires-Dist: pyinstaller>=6.0; extra == 'dev'
@@ -33,6 +31,8 @@ Requires-Dist: pytest>=8.0.0; extra == 'dev'
33
31
  Requires-Dist: ruff>=0.4.0; extra == 'dev'
34
32
  Provides-Extra: exa
35
33
  Requires-Dist: exa-py>=1.0.0; extra == 'exa'
34
+ Provides-Extra: tavily
35
+ Requires-Dist: tavily-python>=0.3.0; extra == 'tavily'
36
36
  Description-Content-Type: text/markdown
37
37
 
38
38
  # ⚡ multacd
@@ -0,0 +1,605 @@
1
+ """Config system (BYOK) — baca & validasi ~/.multacd/config.yaml.
2
+
3
+ Jalankan langsung untuk test cepat:
4
+ python -m core.config
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from dataclasses import dataclass, field, fields
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ # NOTE: tanpa pydantic — pydantic-core (Rust) tidak punya wheel Android
17
+ # (0 dari 159 rilis), jadi pydantic v2 mustahil diinstall di Termux.
18
+ # Validasi ditulis manual (dataclass + ConfigError), pesan tetap ramah.
19
+
20
+ # Hormati MULTACD_HOME (isolation test) — konsisten dengan memory/store.py
21
+ # dan tools/agent/skill.py. Dievaluasi saat import; test set env sebelum subprocess.
22
+ CONFIG_DIR = Path(os.environ.get("MULTACD_HOME", str(Path.home()))) / ".multacd"
23
+ DEFAULT_CONFIG_PATH = CONFIG_DIR / "config.yaml"
24
+ ENV_CONFIG_OVERRIDE = "MULTACD_CONFIG"
25
+
26
+ EXAMPLE_CONFIG = """\
27
+ # ~/.multacd/config.yaml — contoh config lengkap multacd (BYOK)
28
+
29
+ # ── Model (wajib diisi) ──────────────────────────────
30
+ model: "anthropic/claude-sonnet-4-6"
31
+ api_key: "sk-ant-xxxx"
32
+
33
+ # Contoh provider lain (uncomment salah satu):
34
+ #
35
+ # OpenAI
36
+ # model: "openai/gpt-4o"
37
+ # api_key: "sk-xxxx"
38
+ #
39
+ # Google Gemini
40
+ # model: "gemini/gemini-2.0-flash"
41
+ # api_key: "AIzaxxxx"
42
+ #
43
+ # Groq (cepat & murah)
44
+ # model: "groq/llama-3.3-70b-versatile"
45
+ # api_key: "gsk_xxxx"
46
+ #
47
+ # Ollama (lokal, gratis)
48
+ # model: "ollama/llama3.2"
49
+ # api_base: "http://localhost:11434"
50
+ # api_key: "none"
51
+ #
52
+ # Custom OpenAI-compatible endpoint
53
+ # model: "openai/nama-model"
54
+ # api_base: "https://endpoint-kamu.com/v1"
55
+ # api_key: "key-kamu"
56
+
57
+ # ── Agent Settings ───────────────────────────────────
58
+ max_tokens: 8096
59
+ temperature: 0.3
60
+ max_tool_iterations: 20 # batas loop per task, hindari infinite loop
61
+
62
+ # ── Permission Settings ──────────────────────────────
63
+ auto_approve_reads: true # semua tool READ → langsung jalan
64
+ ask_before_write: true # semua tool WRITE → konfirmasi dulu
65
+ ask_before_bash: true # bash → konfirmasi dulu
66
+ ask_before_web: true # web_fetch → konfirmasi dulu
67
+
68
+ # ── Display Settings ─────────────────────────────────
69
+ theme: "dark" # dark | light (= multacd-dark/light)
70
+ # + multacd-min (Termux hemat) | catppuccin-* (kompat v1)
71
+ show_tool_calls: true # tampilkan nama tool yang dijalankan
72
+ show_thinking: false # tampilkan reasoning LLM (verbose mode)
73
+ icon_style: "auto" # auto | nerdfonts | unicode | ascii
74
+
75
+ # ── Search Provider (BYOK, Phase 3) ────────────────────
76
+ search_provider: "tavily" # tavily | exa | brave | serpapi | duckduckgo
77
+ search_api_key: "tvly-xxxx" # tidak perlu untuk duckduckgo (gratis, tidak resmi)
78
+
79
+ # ── Research Settings (Phase 3) ────────────────────────
80
+ search_results_per_query: 5 # hasil per query saat search
81
+ research_quick_queries: 3 # jumlah query untuk quick research
82
+ research_quick_max_sources: 5 # max sumber yang dibaca quick research
83
+ research_deep_rounds: 5 # max round untuk deep research
84
+ research_deep_queries_per_round: 4 # query per round di deep research
85
+ research_deep_max_sources: 20 # max total sumber deep research
86
+ research_scrape_timeout: 15 # timeout scraping per URL (detik)
87
+ research_snippet_fallback: true # pakai snippet kalau scraping gagal
88
+
89
+ # ── Telegram Bot (Phase 4, opsional) ───────────────────
90
+ # Diisi via setup wizard atau manual. Kosong = bot nonaktif.
91
+ # telegram:
92
+ # bot_token: "123456:AAF..." # dari @BotFather
93
+ # admin_id: 123456789 # Telegram user ID kamu
94
+ # admin_username: "usernamekamu"
95
+ # allowed_users: [987654321]
96
+
97
+ # ── Scheduler (Phase 4, opsional) ──────────────────────
98
+ # schedules:
99
+ # - name: "daily_briefing"
100
+ # cron: "0 7 * * *"
101
+ # action: "briefing"
102
+ # channel: "telegram"
103
+
104
+ # ── Daily Briefing (Phase 4, opsional) ─────────────────
105
+ # briefing:
106
+ # todo: true
107
+ # git_status: true
108
+ # news: true
109
+ # news_topics: ["artificial intelligence"]
110
+ # news_sources: 3
111
+ """
112
+
113
+
114
+ class ConfigError(Exception):
115
+ """Error config yang friendly — pesannya bisa langsung ditampilkan ke user."""
116
+
117
+ def __init__(self, message: str, field_errors: list[str] | None = None) -> None:
118
+ super().__init__(message)
119
+ self.field_errors = field_errors or []
120
+
121
+
122
+ THEMES = ("dark", "light", "multacd-dark", "multacd-light", "multacd-min",
123
+ "catppuccin-mocha", "catppuccin-latte", "catppuccin-frappe",
124
+ "catppuccin-macchiato")
125
+ ICON_STYLES = ("auto", "nerdfonts", "unicode", "ascii")
126
+ SEARCH_PROVIDERS = ("tavily", "exa", "brave", "serpapi", "duckduckgo")
127
+ SCHEDULE_ACTIONS = ("briefing", "research")
128
+
129
+
130
+ def _str(loc: str, v: object, errs: list[str], min_len: int = 0) -> str:
131
+ if isinstance(v, bool) or not isinstance(v, (str, int, float)):
132
+ errs.append(f"{loc}: harus teks, dapat {type(v).__name__}")
133
+ return "" if isinstance(v, str) else str(v) if v is not None else ""
134
+ s = v if isinstance(v, str) else str(v)
135
+ if len(s) < min_len:
136
+ errs.append(f"{loc}: wajib diisi (minimal {min_len} karakter)")
137
+ return s
138
+
139
+
140
+ def _int(loc: str, v: object, errs: list[str], gt: int | None = None,
141
+ le: int | None = None) -> int:
142
+ n: int | None = None
143
+ if isinstance(v, bool):
144
+ pass
145
+ elif isinstance(v, int):
146
+ n = v
147
+ elif isinstance(v, str) and v.strip().lstrip("+-").isdigit():
148
+ n = int(v.strip())
149
+ if n is None:
150
+ errs.append(f"{loc}: harus bilangan bulat, dapat {v!r}")
151
+ return 0
152
+ if gt is not None and not n > gt:
153
+ errs.append(f"{loc}: harus > {gt}, dapat {n}")
154
+ if le is not None and not n <= le:
155
+ errs.append(f"{loc}: harus <= {le}, dapat {n}")
156
+ return n
157
+
158
+
159
+ def _float(loc: str, v: object, errs: list[str], ge: float | None = None,
160
+ le: float | None = None) -> float:
161
+ f: float | None = None
162
+ if isinstance(v, bool):
163
+ pass
164
+ elif isinstance(v, (int, float)):
165
+ f = float(v)
166
+ elif isinstance(v, str):
167
+ try:
168
+ f = float(v.strip())
169
+ except ValueError:
170
+ f = None
171
+ if f is None:
172
+ errs.append(f"{loc}: harus angka, dapat {v!r}")
173
+ return 0.0
174
+ if ge is not None and not f >= ge:
175
+ errs.append(f"{loc}: harus >= {ge}, dapat {f}")
176
+ if le is not None and not f <= le:
177
+ errs.append(f"{loc}: harus <= {le}, dapat {f}")
178
+ return f
179
+
180
+
181
+ def _bool(loc: str, v: object, errs: list[str]) -> bool:
182
+ if isinstance(v, bool):
183
+ return v
184
+ if isinstance(v, str) and v.strip().lower() in (
185
+ "true", "1", "yes", "false", "0", "no"):
186
+ return v.strip().lower() in ("true", "1", "yes")
187
+ errs.append(f"{loc}: harus true/false, dapat {v!r}")
188
+ return False
189
+
190
+
191
+ def _choice(loc: str, v: object, errs: list[str], options: tuple[str, ...],
192
+ normalize: bool = False) -> str:
193
+ s = v.lower().strip() if normalize and isinstance(v, str) else v
194
+ if not isinstance(s, str) or s not in options:
195
+ errs.append(f"{loc}: harus salah satu dari {', '.join(options)}, "
196
+ f"dapat {v!r}")
197
+ return options[0] if isinstance(s, str) else ""
198
+ return s
199
+
200
+
201
+ def _raise_if_errors(errs: list[str]) -> None:
202
+ if errs:
203
+ raise ConfigError("config tidak valid: " + "; ".join(errs), errs)
204
+
205
+
206
+ @dataclass
207
+ class TelegramConfig:
208
+ """Kredensial bot + whitelist (Phase 4). Default kosong = nonaktif."""
209
+
210
+ bot_token: str = ""
211
+ admin_id: int = 0
212
+ admin_username: str = ""
213
+ allowed_users: list[int] = field(default_factory=list)
214
+
215
+ def __post_init__(self) -> None:
216
+ errs: list[str] = []
217
+ self.bot_token = _str("telegram.bot_token", self.bot_token, errs)
218
+ self.admin_id = _int("telegram.admin_id", self.admin_id, errs)
219
+ self.admin_username = _str("telegram.admin_username",
220
+ self.admin_username, errs)
221
+ if not isinstance(self.allowed_users, list):
222
+ errs.append(f"telegram.allowed_users: harus list, "
223
+ f"dapat {self.allowed_users!r}")
224
+ self.allowed_users = []
225
+ else:
226
+ fixed = []
227
+ for i, uid in enumerate(self.allowed_users):
228
+ fixed.append(_int(f"telegram.allowed_users[{i}]", uid, errs))
229
+ self.allowed_users = fixed
230
+ _raise_if_errors(errs)
231
+
232
+
233
+ @dataclass
234
+ class ScheduleConfig:
235
+ """Satu jadwal cron (Phase 4). channel: telegram (saat ini satu-satunya)."""
236
+
237
+ name: str = ""
238
+ cron: str = ""
239
+ action: str = "briefing"
240
+ topic: str = ""
241
+ channel: str = "telegram"
242
+
243
+ def __post_init__(self) -> None:
244
+ errs: list[str] = []
245
+ self.name = _str("schedule.name", self.name, errs, min_len=1)
246
+ self.cron = _str("schedule.cron", self.cron, errs, min_len=1)
247
+ self.action = _choice("schedule.action", self.action, errs,
248
+ SCHEDULE_ACTIONS)
249
+ self.topic = _str("schedule.topic", self.topic, errs)
250
+ self.channel = _str("schedule.channel", self.channel, errs)
251
+ _raise_if_errors(errs)
252
+
253
+
254
+ @dataclass
255
+ class BriefingConfig:
256
+ """Konten daily briefing (Phase 4)."""
257
+
258
+ todo: bool = True
259
+ news: bool = True
260
+ git_status: bool = True
261
+ weather: bool = False # Phase 5 (butuh API cuaca)
262
+ news_topics: list[str] = field(
263
+ default_factory=lambda: ["artificial intelligence",
264
+ "software engineering"])
265
+ news_sources: int = 3
266
+
267
+ def __post_init__(self) -> None:
268
+ errs: list[str] = []
269
+ self.todo = _bool("briefing.todo", self.todo, errs)
270
+ self.news = _bool("briefing.news", self.news, errs)
271
+ self.git_status = _bool("briefing.git_status", self.git_status, errs)
272
+ self.weather = _bool("briefing.weather", self.weather, errs)
273
+ if not isinstance(self.news_topics, list) or not all(
274
+ isinstance(t, str) for t in self.news_topics):
275
+ errs.append("briefing.news_topics: harus list of string")
276
+ self.news_topics = []
277
+ self.news_sources = _int("briefing.news_sources", self.news_sources,
278
+ errs, gt=0)
279
+ _raise_if_errors(errs)
280
+
281
+
282
+ def _nested(loc: str, cls: type, v: object, errs: list[str]) -> object:
283
+ """Dict → dataclass nested. Error nested digabung ke errs caller."""
284
+ if isinstance(v, cls):
285
+ return v
286
+ if isinstance(v, dict):
287
+ known = {f.name for f in fields(cls)}
288
+ try:
289
+ return cls(**{k: val for k, val in v.items() if k in known})
290
+ except ConfigError as e:
291
+ errs.extend(e.field_errors)
292
+ return cls()
293
+ errs.append(f"{loc}: harus mapping, dapat {type(v).__name__}")
294
+ return cls()
295
+
296
+
297
+ @dataclass
298
+ class Config:
299
+ """Config utama multacd. Field flat 1:1 dengan contoh config.yaml."""
300
+
301
+ # ── Model (wajib) ──
302
+ model: str = ""
303
+ api_key: str = ""
304
+ api_base: str | None = None
305
+
306
+ # ── Agent settings ──
307
+ max_tokens: int = 8096
308
+ temperature: float = 0.3
309
+ max_tool_iterations: int = 20
310
+
311
+ # ── Permission settings ──
312
+ auto_approve_reads: bool = True
313
+ ask_before_write: bool = True
314
+ ask_before_bash: bool = True
315
+ ask_before_web: bool = True
316
+
317
+ # ── Display settings ──
318
+ theme: str = "dark"
319
+ show_tool_calls: bool = True
320
+ show_thinking: bool = False
321
+ icon_style: str = "auto"
322
+
323
+ # ── Search provider (BYOK, Phase 3) ──
324
+ search_provider: str = "tavily"
325
+ search_api_key: str = ""
326
+
327
+ # ── Research settings (Phase 3) ──
328
+ search_results_per_query: int = 5
329
+ research_quick_queries: int = 3
330
+ research_quick_max_sources: int = 5
331
+ research_deep_rounds: int = 5
332
+ research_deep_queries_per_round: int = 4
333
+ research_deep_max_sources: int = 20
334
+ research_scrape_timeout: int = 15
335
+ research_snippet_fallback: bool = True
336
+
337
+ # ── Personal agent (Phase 4, opsional) ──
338
+ telegram: TelegramConfig = field(default_factory=TelegramConfig) # type: ignore[assignment]
339
+ schedules: list = field(default_factory=list)
340
+ briefing: BriefingConfig = field(default_factory=BriefingConfig) # type: ignore[assignment]
341
+
342
+ def __post_init__(self) -> None:
343
+ errs: list[str] = []
344
+ self.model = _str("model", self.model, errs, min_len=1)
345
+ self.api_key = _str("api_key", self.api_key, errs, min_len=1)
346
+ if self.api_base is not None:
347
+ self.api_base = _str("api_base", self.api_base, errs)
348
+ self.max_tokens = _int("max_tokens", self.max_tokens, errs, gt=0)
349
+ self.temperature = _float("temperature", self.temperature, errs,
350
+ ge=0.0, le=2.0)
351
+ self.max_tool_iterations = _int("max_tool_iterations",
352
+ self.max_tool_iterations, errs, gt=0)
353
+ self.auto_approve_reads = _bool("auto_approve_reads",
354
+ self.auto_approve_reads, errs)
355
+ self.ask_before_write = _bool("ask_before_write",
356
+ self.ask_before_write, errs)
357
+ self.ask_before_bash = _bool("ask_before_bash",
358
+ self.ask_before_bash, errs)
359
+ self.ask_before_web = _bool("ask_before_web", self.ask_before_web,
360
+ errs)
361
+ # "Dark", " MOCHA " → maafkan case/spasi; nama pendek non-kanonis
362
+ # tetap ditolak dengan pesan jelas.
363
+ self.theme = _choice("theme", self.theme, errs, THEMES,
364
+ normalize=True)
365
+ self.show_tool_calls = _bool("show_tool_calls", self.show_tool_calls,
366
+ errs)
367
+ self.show_thinking = _bool("show_thinking", self.show_thinking, errs)
368
+ self.icon_style = _choice("icon_style", self.icon_style, errs,
369
+ ICON_STYLES)
370
+ # "Tavily", " TAVILY " → "tavily" (maafkan kapital/spasi user).
371
+ self.search_provider = _choice("search_provider", self.search_provider,
372
+ errs, SEARCH_PROVIDERS, normalize=True)
373
+ self.search_api_key = _str("search_api_key", self.search_api_key,
374
+ errs)
375
+ self.search_results_per_query = _int("search_results_per_query",
376
+ self.search_results_per_query,
377
+ errs, gt=0)
378
+ self.research_quick_queries = _int("research_quick_queries",
379
+ self.research_quick_queries,
380
+ errs, gt=0)
381
+ self.research_quick_max_sources = _int(
382
+ "research_quick_max_sources", self.research_quick_max_sources,
383
+ errs, gt=0)
384
+ self.research_deep_rounds = _int("research_deep_rounds",
385
+ self.research_deep_rounds,
386
+ errs, gt=0, le=10)
387
+ self.research_deep_queries_per_round = _int(
388
+ "research_deep_queries_per_round",
389
+ self.research_deep_queries_per_round, errs, gt=0)
390
+ self.research_deep_max_sources = _int("research_deep_max_sources",
391
+ self.research_deep_max_sources,
392
+ errs, gt=0)
393
+ self.research_scrape_timeout = _int("research_scrape_timeout",
394
+ self.research_scrape_timeout,
395
+ errs, gt=0)
396
+ self.research_snippet_fallback = _bool("research_snippet_fallback",
397
+ self.research_snippet_fallback,
398
+ errs)
399
+ self.telegram = _nested("telegram", TelegramConfig,
400
+ self.telegram, errs)
401
+ if not isinstance(self.schedules, list):
402
+ errs.append(f"schedules: harus list, "
403
+ f"dapat {type(self.schedules).__name__}")
404
+ self.schedules = []
405
+ else:
406
+ fixed = []
407
+ for i, item in enumerate(self.schedules):
408
+ if isinstance(item, ScheduleConfig):
409
+ fixed.append(item)
410
+ elif isinstance(item, dict):
411
+ known = {f.name for f in fields(ScheduleConfig)}
412
+ try:
413
+ fixed.append(ScheduleConfig(
414
+ **{k: v for k, v in item.items()
415
+ if k in known}))
416
+ except ConfigError as e:
417
+ # Item invalid dilaporkan; Config.__post_init__
418
+ # raise di akhir (errs tak kosong), jadi item
419
+ # ini memang tidak dipakai.
420
+ errs.extend(
421
+ f"schedules[{i}].{fe.split('.', 1)[1]}"
422
+ if fe.startswith("schedule.") else
423
+ f"schedules[{i}].{fe}"
424
+ for fe in e.field_errors)
425
+ else:
426
+ errs.append(f"schedules[{i}]: harus mapping, "
427
+ f"dapat {type(item).__name__}")
428
+ self.schedules = fixed
429
+ self.briefing = _nested("briefing", BriefingConfig,
430
+ self.briefing, errs)
431
+ _raise_if_errors(errs)
432
+
433
+
434
+ # ── Active config sesi (Bug 3) ───────────────────────────────────────────
435
+ # Tool research (quick/deep_research, web_search, query_generator) tidak
436
+ # menerima config dari LLM — mereka membaca dari sini. MultacdApp memasang
437
+ # config-nya saat init, jadi `--config PATH` dan `/model X` tetap dihormati.
438
+ # None → fallback load_config() seperti dulu (pemakaian standalone/CLI).
439
+ _active_config: Config | None = None
440
+
441
+
442
+ def set_active_config(cfg: Config | None) -> None:
443
+ """Pasang/cabut (None) config aktif sesi. Dipanggil MultacdApp saat init."""
444
+ global _active_config
445
+ _active_config = cfg
446
+
447
+
448
+ def get_active_config() -> Config | None:
449
+ """Config sesi yang sedang jalan, atau None kalau tidak ada."""
450
+ return _active_config
451
+
452
+
453
+ def resolve_config_path(explicit: Path | str | None = None) -> Path:
454
+ """Urutan prioritas: argumen eksplisit > $MULTACD_CONFIG > ~/.multacd/config.yaml."""
455
+ if explicit is not None:
456
+ return Path(explicit).expanduser()
457
+ env = os.environ.get(ENV_CONFIG_OVERRIDE)
458
+ if env:
459
+ return Path(env).expanduser()
460
+ return DEFAULT_CONFIG_PATH
461
+
462
+
463
+ def _setup_message(path: Path) -> str:
464
+ return (
465
+ "multacd — config belum ditemukan.\n"
466
+ "\n"
467
+ f"File yang dicari: {path}\n"
468
+ "\n"
469
+ "Cara setup (sekali saja):\n"
470
+ f" 1. Buat foldernya : mkdir -p {path.parent}\n"
471
+ f" 2. Simpan contoh di bawah ini sebagai: {path}\n"
472
+ " 3. Isi `model` dan `api_key` sesuai provider kamu (BYOK)\n"
473
+ " 4. Jalankan lagi: python main.py\n"
474
+ "\n"
475
+ "────── contoh config ──────\n"
476
+ f"{EXAMPLE_CONFIG}"
477
+ "───────────────────────────\n"
478
+ )
479
+
480
+
481
+ def config_from_dict(raw: dict) -> Config:
482
+ """Dict (YAML/wizard) → Config. Key asing diabaikan (forward-compat)."""
483
+ known = {f.name for f in fields(Config)}
484
+ return Config(**{k: v for k, v in raw.items() if k in known})
485
+
486
+
487
+ def load_config(explicit_path: Path | str | None = None) -> Config:
488
+ """Baca & validasi config. Exit(1) dengan pesan jelas kalau ada masalah."""
489
+ path = resolve_config_path(explicit_path)
490
+
491
+ # Buat folder ~/.multacd/ kalau belum ada (aman dipanggil berulang).
492
+ try:
493
+ path.parent.mkdir(parents=True, exist_ok=True)
494
+ except OSError as e:
495
+ print(f"Gagal membuat folder config {path.parent}: {e}", file=sys.stderr)
496
+ raise SystemExit(1) from e
497
+
498
+ if not path.is_file():
499
+ print(_setup_message(path))
500
+ raise SystemExit(1)
501
+
502
+ try:
503
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
504
+ except yaml.YAMLError as e:
505
+ print(f"config.yaml bukan YAML valid: {path}\n Detail: {e}", file=sys.stderr)
506
+ raise SystemExit(1) from e
507
+ except OSError as e:
508
+ print(f"Gagal membaca config {path}: {e}", file=sys.stderr)
509
+ raise SystemExit(1) from e
510
+
511
+ if not isinstance(raw, dict) or not raw:
512
+ print(
513
+ f"config.yaml kosong atau tidak berbentuk key: value: {path}\n"
514
+ " Isi dengan contoh config (jalankan tanpa config untuk melihatnya).",
515
+ file=sys.stderr,
516
+ )
517
+ raise SystemExit(1)
518
+
519
+ try:
520
+ cfg = config_from_dict(raw)
521
+ except ConfigError as e:
522
+ print(f"config.yaml tidak valid: {path}", file=sys.stderr)
523
+ for line in e.field_errors:
524
+ print(f" • {line}", file=sys.stderr)
525
+ print(" Lihat contoh field yang benar di pesan setup.", file=sys.stderr)
526
+ raise SystemExit(1) from e
527
+
528
+ if not cfg.model.strip():
529
+ print("Field `model` wajib diisi (contoh: anthropic/claude-sonnet-4-6).", file=sys.stderr)
530
+ raise SystemExit(1)
531
+ if not cfg.api_key.strip():
532
+ print("Field `api_key` wajib diisi (BYOK — pakai key provider kamu).", file=sys.stderr)
533
+ raise SystemExit(1)
534
+
535
+ return cfg
536
+
537
+
538
+ def _mask_key(key: str) -> str:
539
+ if len(key) <= 6:
540
+ return "***"
541
+ return f"{key[:4]}***{key[-2:]}"
542
+
543
+
544
+ if __name__ == "__main__":
545
+ # Hermetic: tulis config contoh ke dir isolasi (jangan baca config
546
+ # asli user — isinya beda-beda, assert di bawah harus deterministik).
547
+ import tempfile as _tempfile
548
+
549
+ _iso = Path(_tempfile.mkdtemp(prefix="multacd-cfgtest-"))
550
+ _cfg_path = _iso / "config.yaml"
551
+ _cfg_path.write_text(
552
+ "model: groq/llama-3.3-70b-versatile\napi_key: gsk-x\n"
553
+ "search_provider: ' Tavily '\ntheme: Dark\n"
554
+ "telegram:\n bot_token: t\n admin_id: 1\n admin_username: u\n"
555
+ " allowed_users: [2]\n"
556
+ "schedules:\n - name: j\n cron: '0 7 * * *'\n"
557
+ " action: briefing\n"
558
+ "kunci_asing: abaikan gue\n",
559
+ encoding="utf-8")
560
+ cfg = load_config(_cfg_path)
561
+
562
+ # Active config (Bug 3): set/get/clear simetris.
563
+ set_active_config(cfg)
564
+ assert get_active_config() is cfg
565
+ set_active_config(None)
566
+ assert get_active_config() is None
567
+
568
+ # Normalisasi + nested parse dari YAML.
569
+ assert cfg.search_provider == "tavily", cfg.search_provider
570
+ assert cfg.theme == "dark", cfg.theme
571
+ assert cfg.telegram.admin_id == 1 and cfg.telegram.allowed_users == [2]
572
+ assert cfg.schedules[0].cron == "0 7 * * *"
573
+
574
+ # Phase 4: nested default + parse dari dict.
575
+ legacy = Config(model="m", api_key="k") # yaml lama tanpa phase4
576
+ assert legacy.telegram.bot_token == "" and legacy.schedules == []
577
+ assert legacy.telegram.admin_id == 0 and legacy.briefing.todo
578
+ assert legacy.briefing.news_sources == 3
579
+ full = Config(model="m", api_key="k", telegram={
580
+ "bot_token": "t", "admin_id": 1, "admin_username": "u",
581
+ "allowed_users": [2]},
582
+ schedules=[{"name": "j", "cron": "0 7 * * *",
583
+ "action": "briefing"}])
584
+ assert full.telegram.admin_id == 1 and full.schedules[0].cron == "0 7 * * *"
585
+
586
+ # Invalid: satu ConfigError berisi SEMUA field bermasalah.
587
+ try:
588
+ Config(model=" ", api_key="k", search_provider="google",
589
+ temperature=9, research_deep_rounds=99,
590
+ schedules=[{"name": "", "action": "party"}])
591
+ raise AssertionError("config invalid harus ditolak")
592
+ except ConfigError as e:
593
+ for needle in ("search_provider", "temperature",
594
+ "research_deep_rounds", "schedules[0].name",
595
+ "schedules[0].action"):
596
+ assert needle in str(e), (needle, str(e)[:200])
597
+
598
+ print("✅ Config loaded OK")
599
+ print(f" model : {cfg.model}")
600
+ print(f" api_key : {_mask_key(cfg.api_key)}")
601
+ print(f" api_base : {cfg.api_base or '(default provider)'}")
602
+ print(f" max_tokens : {cfg.max_tokens}")
603
+ print(f" temperature : {cfg.temperature}")
604
+ print(f" max_tool_iterations : {cfg.max_tool_iterations}")
605
+ print(f" theme : {cfg.theme}")
@@ -8,7 +8,7 @@ Saat bump versi (Step 10 → 1.0.0): ubah pyproject + FALLBACK di sini.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- FALLBACK = "2.0.0b3" # beta v2; samakan dengan pyproject.toml
11
+ FALLBACK = "2.0.0b4" # beta v2; samakan dengan pyproject.toml
12
12
 
13
13
 
14
14
  def get_version() -> str: