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.
- {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/workflows/release.yml +1 -1
- {multacd-2.0.0b3 → multacd-2.0.0b4}/CHANGELOG.md +11 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/PKG-INFO +4 -4
- multacd-2.0.0b4/core/config.py +605 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/version.py +1 -1
- multacd-2.0.0b4/docs/install.md +49 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/pyproject.toml +9 -7
- multacd-2.0.0b4/requirements.txt +12 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/scripts/install.sh +1 -1
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/__main__.py +3 -5
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/tavily.py +2 -2
- multacd-2.0.0b3/core/config.py +0 -340
- multacd-2.0.0b3/docs/install.md +0 -35
- multacd-2.0.0b3/requirements.txt +0 -13
- {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/pull_request_template.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/.github/workflows/test.yml +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/.gitignore +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/CONTRIBUTING.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/DESIGN.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/LICENSE +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/README.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/generator.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/privacy.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/git_source.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/news_source.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/briefing/sources/todo_source.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/agent_loop.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/clipboard.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/codebase.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/llm_client.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/mode_manager.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/permissions.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/plugin_loader.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/prompt_composer.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/__main__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/anthropic.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/providers/openai_compat.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/bus.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/orchestrator.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/research/query_generator.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/core/updater.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/daemon/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/daemon/ipc.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/daemon/process.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/configuration.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/index.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/mkdocs.yml +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/modes/coding.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/modes/personal.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/modes/research.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/plugins.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/docs/quickstart.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/main.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/memory/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/memory/context.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/memory/store.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/multacd.spec +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/plan/PLAN-phase1.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/plan/PLAN-phase2.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/plugins/README.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/plugins/example_plugin.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/roadmap/ROADMAP.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/engine.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/jobs/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/jobs/briefing_job.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/scheduler/jobs/research_job.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/scripts/install.ps1 +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/base.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/brave.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/duckduckgo.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/exa.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/search_providers/serpapi.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/soul.md +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/conftest.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/integration/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/integration/test_agent_loop.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/test_selftests.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_binary.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_config.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_docs.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_entrypoint.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_github.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_installer.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_permissions.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_plugin_loader.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_privacy_filter.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_search_providers/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_search_providers/test_tavily.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_bash.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_read_file.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_web_scrape.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_tools/test_write_file.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tests/unit/test_updater.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/access_control.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/agent.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/bot.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/file_handler.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/formatter.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tg/handlers.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/ask.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/skill.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/task.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/agent/todo_write.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/lint_python.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/run_python.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/code/run_tests.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/codebase/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/codebase/scan_codebase.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/common.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/apply_patch.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/delete_file.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/edit_file.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/glob.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/grep.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/list_dir.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/move_file.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/multi_edit.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/read_file.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/read_many_files.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/filesystem/write_file.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/_helper.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_add.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_branch.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_checkout.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_commit.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_diff.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_log.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_merge.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_pull.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_push.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/git/git_status.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/forget.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/recall.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/memory/remember.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/cancel_job.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/daemon_status.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/generate_briefing.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/get_jobs.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/schedule_job.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/send_telegram.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/personal/user_manager.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/registry.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/deep_research.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/export_research.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/quick_research.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/web_scrape.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/research/web_search.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/shell/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/shell/bash.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/streaming.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/web/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tools/web/web_fetch.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/app.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/icons.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/screens/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/screens/main_screen.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/screens/setup_wizard.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/themes/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/themes/__main__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/tokens.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/__init__.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/chat_panel.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/confirm_dialog.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/diff_viewer.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/file_tree.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/info_panel.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/input_bar.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/model_selector.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/permission_popup.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/slash_palette.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/sources_panel.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/status_bar.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/thinking_bar.py +0 -0
- {multacd-2.0.0b3 → multacd-2.0.0b4}/tui/widgets/tool_activity.py +0 -0
|
@@ -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.
|
|
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
|
|
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.
|
|
11
|
+
FALLBACK = "2.0.0b4" # beta v2; samakan dengan pyproject.toml
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def get_version() -> str:
|