multacd 1.0.0__py3-none-any.whl

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 (135) hide show
  1. briefing/__init__.py +6 -0
  2. briefing/generator.py +125 -0
  3. briefing/privacy.py +112 -0
  4. briefing/sources/__init__.py +3 -0
  5. briefing/sources/git_source.py +77 -0
  6. briefing/sources/news_source.py +76 -0
  7. briefing/sources/todo_source.py +69 -0
  8. core/__init__.py +0 -0
  9. core/agent_loop.py +410 -0
  10. core/codebase.py +318 -0
  11. core/config.py +340 -0
  12. core/llm_client.py +347 -0
  13. core/mode_manager.py +342 -0
  14. core/permissions.py +284 -0
  15. core/plugin_loader.py +194 -0
  16. core/prompt_composer.py +126 -0
  17. core/research/__init__.py +0 -0
  18. core/research/bus.py +41 -0
  19. core/research/orchestrator.py +638 -0
  20. core/research/query_generator.py +199 -0
  21. core/updater.py +187 -0
  22. core/version.py +20 -0
  23. daemon/__init__.py +3 -0
  24. daemon/ipc.py +127 -0
  25. daemon/process.py +205 -0
  26. main.py +195 -0
  27. memory/__init__.py +0 -0
  28. memory/context.py +117 -0
  29. memory/store.py +78 -0
  30. multacd-1.0.0.dist-info/METADATA +94 -0
  31. multacd-1.0.0.dist-info/RECORD +135 -0
  32. multacd-1.0.0.dist-info/WHEEL +4 -0
  33. multacd-1.0.0.dist-info/entry_points.txt +2 -0
  34. multacd-1.0.0.dist-info/licenses/LICENSE +21 -0
  35. scheduler/__init__.py +3 -0
  36. scheduler/engine.py +227 -0
  37. scheduler/jobs/__init__.py +37 -0
  38. scheduler/jobs/briefing_job.py +36 -0
  39. scheduler/jobs/research_job.py +43 -0
  40. search_providers/__init__.py +41 -0
  41. search_providers/__main__.py +43 -0
  42. search_providers/base.py +137 -0
  43. search_providers/brave.py +87 -0
  44. search_providers/duckduckgo.py +114 -0
  45. search_providers/exa.py +99 -0
  46. search_providers/serpapi.py +85 -0
  47. search_providers/tavily.py +79 -0
  48. soul.md +30 -0
  49. tg/__init__.py +7 -0
  50. tg/access_control.py +131 -0
  51. tg/agent.py +268 -0
  52. tg/bot.py +79 -0
  53. tg/file_handler.py +152 -0
  54. tg/formatter.py +94 -0
  55. tg/handlers.py +358 -0
  56. tools/__init__.py +0 -0
  57. tools/agent/__init__.py +0 -0
  58. tools/agent/ask.py +35 -0
  59. tools/agent/skill.py +67 -0
  60. tools/agent/task.py +33 -0
  61. tools/agent/todo_write.py +54 -0
  62. tools/code/__init__.py +0 -0
  63. tools/code/lint_python.py +144 -0
  64. tools/code/run_python.py +169 -0
  65. tools/code/run_tests.py +156 -0
  66. tools/codebase/__init__.py +0 -0
  67. tools/codebase/scan_codebase.py +28 -0
  68. tools/common.py +18 -0
  69. tools/filesystem/__init__.py +0 -0
  70. tools/filesystem/apply_patch.py +89 -0
  71. tools/filesystem/delete_file.py +36 -0
  72. tools/filesystem/edit_file.py +42 -0
  73. tools/filesystem/glob.py +35 -0
  74. tools/filesystem/grep.py +78 -0
  75. tools/filesystem/list_dir.py +46 -0
  76. tools/filesystem/move_file.py +38 -0
  77. tools/filesystem/multi_edit.py +46 -0
  78. tools/filesystem/read_file.py +42 -0
  79. tools/filesystem/read_many_files.py +35 -0
  80. tools/filesystem/write_file.py +37 -0
  81. tools/git/__init__.py +0 -0
  82. tools/git/_helper.py +44 -0
  83. tools/git/git_add.py +21 -0
  84. tools/git/git_branch.py +13 -0
  85. tools/git/git_checkout.py +25 -0
  86. tools/git/git_commit.py +21 -0
  87. tools/git/git_diff.py +18 -0
  88. tools/git/git_log.py +17 -0
  89. tools/git/git_merge.py +140 -0
  90. tools/git/git_pull.py +17 -0
  91. tools/git/git_push.py +92 -0
  92. tools/git/git_status.py +13 -0
  93. tools/memory/__init__.py +0 -0
  94. tools/memory/forget.py +33 -0
  95. tools/memory/recall.py +35 -0
  96. tools/memory/remember.py +34 -0
  97. tools/personal/__init__.py +3 -0
  98. tools/personal/cancel_job.py +55 -0
  99. tools/personal/daemon_status.py +64 -0
  100. tools/personal/generate_briefing.py +93 -0
  101. tools/personal/get_jobs.py +52 -0
  102. tools/personal/schedule_job.py +77 -0
  103. tools/personal/send_telegram.py +171 -0
  104. tools/personal/user_manager.py +103 -0
  105. tools/registry.py +286 -0
  106. tools/research/__init__.py +0 -0
  107. tools/research/deep_research.py +148 -0
  108. tools/research/export_research.py +146 -0
  109. tools/research/quick_research.py +159 -0
  110. tools/research/web_scrape.py +172 -0
  111. tools/research/web_search.py +176 -0
  112. tools/shell/__init__.py +0 -0
  113. tools/shell/bash.py +60 -0
  114. tools/web/__init__.py +0 -0
  115. tools/web/web_fetch.py +66 -0
  116. tui/__init__.py +0 -0
  117. tui/app.py +113 -0
  118. tui/icons.py +114 -0
  119. tui/screens/__init__.py +0 -0
  120. tui/screens/main_screen.py +424 -0
  121. tui/screens/setup_wizard.py +483 -0
  122. tui/themes/__init__.py +133 -0
  123. tui/themes/__main__.py +17 -0
  124. tui/widgets/__init__.py +0 -0
  125. tui/widgets/chat_panel.py +83 -0
  126. tui/widgets/confirm_dialog.py +52 -0
  127. tui/widgets/diff_viewer.py +68 -0
  128. tui/widgets/file_tree.py +121 -0
  129. tui/widgets/input_bar.py +92 -0
  130. tui/widgets/permission_popup.py +172 -0
  131. tui/widgets/slash_palette.py +131 -0
  132. tui/widgets/sources_panel.py +201 -0
  133. tui/widgets/status_bar.py +70 -0
  134. tui/widgets/thinking_bar.py +106 -0
  135. tui/widgets/tool_activity.py +163 -0
core/codebase.py ADDED
@@ -0,0 +1,318 @@
1
+ """Codebase awareness — scan project biar agent langsung ngerti konteks.
2
+
3
+ from core.codebase import scan_project, project_label
4
+
5
+ ctx = scan_project(".") # → string ProjectContext buat system prompt
6
+
7
+ Aturan scan (PLAN Phase 2 Section 5):
8
+ - SELALU: struktur folder (depth 3), file kunci root, entry point, git info.
9
+ - SKIP: file planning (PLAN*.md, ROADMAP.md, soul.md), dependency folder
10
+ (.venv, node_modules), compiled (__pycache__, *.pyc), .git, secrets
11
+ (.env), folder plan/ + roadmap/, pola di .gitignore.
12
+ - Gagal scan (I/O, git hilang) → fallback, tidak crash.
13
+
14
+ Test cepat:
15
+ python -m core.codebase
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ TREE_DEPTH = 3
24
+ MAX_TREE_ENTRIES = 200
25
+ MAX_FILE_CHARS = 1500
26
+
27
+ # File kunci yang dibaca isinya (kalau ada di root).
28
+ KEY_FILES = (
29
+ "README.md",
30
+ ".env.example",
31
+ "requirements.txt",
32
+ "pyproject.toml",
33
+ "setup.py",
34
+ "setup.cfg",
35
+ "Makefile",
36
+ "docker-compose.yml",
37
+ )
38
+
39
+ # File planning/bawaan — isi tidak relevan buat konteks kode.
40
+ SKIP_FILES = frozenset({"soul.md"})
41
+
42
+ SKIP_DIRS = frozenset({
43
+ ".git", ".venv", "node_modules", "__pycache__", ".pytest_cache",
44
+ ".idea", ".vscode", "dist", "build", "htmlcov",
45
+ "plan", "roadmap",
46
+ })
47
+
48
+ SECRET_SUFFIXES = (".key", ".pem")
49
+
50
+ ENTRY_CANDIDATES = ("main.py", "app.py", "run.py", "index.py")
51
+
52
+
53
+ def _is_planning_file(name: str) -> bool:
54
+ upper = name.upper()
55
+ return (upper in ("PLAN.MD", "ROADMAP.MD")
56
+ or upper.startswith(("PLAN-", "PLAN_", "ROADMAP-", "ROADMAP_")))
57
+
58
+
59
+ def detect_project_type(root: Path | str) -> str:
60
+ """Tebak jenis project dari file signature di root."""
61
+ root = Path(root)
62
+ if any((root / f).is_file()
63
+ for f in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg")):
64
+ return "Python"
65
+ if (root / "package.json").is_file():
66
+ return "Node.js"
67
+ if (root / "go.mod").is_file():
68
+ return "Go"
69
+ if (root / "Cargo.toml").is_file():
70
+ return "Rust"
71
+ if (root / "pom.xml").is_file() or list(root.glob("build.gradle*")):
72
+ return "Java"
73
+ return "Generic"
74
+
75
+
76
+ def _gitignore_names(root: Path) -> set[str]:
77
+ """Parse .gitignore seperlunya: ambil pola nama sederhana."""
78
+ names: set[str] = set()
79
+ try:
80
+ text = (root / ".gitignore").read_text(encoding="utf-8")
81
+ except OSError:
82
+ return names
83
+ for line in text.splitlines():
84
+ line = line.strip()
85
+ if not line or line.startswith(("#", "!")):
86
+ continue
87
+ line = line.strip("/").split("/")[0]
88
+ if line and "*" not in line:
89
+ names.add(line)
90
+ return names
91
+
92
+
93
+ def build_file_tree(root: Path | str, depth: int = TREE_DEPTH) -> str:
94
+ """Render struktur folder sebagai string tree (depth terbatas)."""
95
+ root = Path(root)
96
+ ignored = _gitignore_names(root)
97
+ lines: list[str] = []
98
+ count = 0
99
+ truncated = False
100
+
101
+ def skip_dir(d: Path) -> bool:
102
+ return d.name in SKIP_DIRS or d.name in ignored or d.name.startswith(".")
103
+
104
+ def skip_file(f: Path) -> bool:
105
+ n = f.name
106
+ return (
107
+ n in SKIP_FILES or _is_planning_file(n) or n in ignored
108
+ or n.startswith(".") or n.endswith(".pyc")
109
+ or n == ".env" or n.endswith(SECRET_SUFFIXES)
110
+ )
111
+
112
+ def walk(dir_path: Path, prefix: str, level: int) -> None:
113
+ nonlocal count, truncated
114
+ if level > depth or truncated:
115
+ return
116
+ try:
117
+ entries = sorted(dir_path.iterdir(),
118
+ key=lambda p: (p.is_file(), p.name.lower()))
119
+ except OSError:
120
+ return
121
+ for p in entries:
122
+ if count >= MAX_TREE_ENTRIES:
123
+ truncated = True
124
+ return
125
+ if p.is_dir():
126
+ if skip_dir(p):
127
+ continue
128
+ lines.append(f"{prefix}{p.name}/")
129
+ count += 1
130
+ walk(p, prefix + " ", level + 1)
131
+ else:
132
+ if skip_file(p):
133
+ continue
134
+ lines.append(f"{prefix}{p.name}")
135
+ count += 1
136
+
137
+ if not root.is_dir():
138
+ return "(folder tidak ditemukan)"
139
+ walk(root, "", 1)
140
+ if truncated:
141
+ lines.append(f"… (dipotong, >{MAX_TREE_ENTRIES} entri)")
142
+ return "\n".join(lines) if lines else "(folder kosong)"
143
+
144
+
145
+ def get_key_files(root: Path | str) -> dict[str, str]:
146
+ """Baca file kunci root. File hilang → skip; isi dipotong MAX_FILE_CHARS."""
147
+ root = Path(root)
148
+ out: dict[str, str] = {}
149
+ for name in KEY_FILES:
150
+ try:
151
+ text = (root / name).read_text(encoding="utf-8")
152
+ except (OSError, UnicodeDecodeError):
153
+ continue
154
+ if len(text) > MAX_FILE_CHARS:
155
+ text = text[:MAX_FILE_CHARS] + "\n…(dipotong)"
156
+ out[name] = text
157
+ return out
158
+
159
+
160
+ def get_entry_points(root: Path | str) -> list[str]:
161
+ root = Path(root)
162
+ return [c for c in ENTRY_CANDIDATES if (root / c).is_file()]
163
+
164
+
165
+ def get_git_summary(root: Path | str) -> dict[str, object]:
166
+ """Ringkasan git via subprocess. Bukan repo → {"is_repo": False}."""
167
+ root = Path(root)
168
+ try:
169
+ branch = subprocess.run(
170
+ ["git", "-C", str(root), "branch", "--show-current"],
171
+ capture_output=True, text=True, timeout=5)
172
+ if branch.returncode != 0:
173
+ return {"is_repo": False}
174
+ status = subprocess.run(
175
+ ["git", "-C", str(root), "status", "--porcelain"],
176
+ capture_output=True, text=True, timeout=5)
177
+ except (OSError, subprocess.SubprocessError):
178
+ return {"is_repo": False}
179
+ modified = untracked = 0
180
+ if status.returncode == 0:
181
+ for line in status.stdout.splitlines():
182
+ if line.startswith("??"):
183
+ untracked += 1
184
+ elif line.strip():
185
+ modified += 1
186
+ return {
187
+ "is_repo": True,
188
+ "branch": branch.stdout.strip() or "(detached)",
189
+ "modified": modified,
190
+ "untracked": untracked,
191
+ }
192
+
193
+
194
+ def _parse_dependencies(key_files: dict[str, str]) -> str:
195
+ req = key_files.get("requirements.txt", "")
196
+ names: list[str] = []
197
+ for line in req.splitlines():
198
+ line = line.strip()
199
+ if not line or line.startswith(("#", "-")):
200
+ continue
201
+ for sep in ("==", ">=", "<=", "~=", "!=", ">", "<", ";", "["):
202
+ line = line.split(sep)[0]
203
+ line = line.strip()
204
+ if line:
205
+ names.append(line)
206
+ if names:
207
+ shown = ", ".join(names[:12])
208
+ return shown + ("…" if len(names) > 12 else "")
209
+ if "pyproject.toml" in key_files:
210
+ return "(lihat pyproject.toml)"
211
+ if "package.json" in key_files:
212
+ return "(lihat package.json)"
213
+ return "-"
214
+
215
+
216
+ def scan_project(root: Path | str = ".") -> str:
217
+ """Scan project → string ProjectContext (tidak pernah raise)."""
218
+ try:
219
+ return _scan_project(Path(root).expanduser())
220
+ except Exception as e:
221
+ return f"Project: (scan gagal: {type(e).__name__}: {e})"
222
+
223
+
224
+ def _scan_project(root: Path) -> str:
225
+ name = root.resolve().name
226
+ ptype = detect_project_type(root)
227
+ key_files = get_key_files(root)
228
+ tree = build_file_tree(root)
229
+ entries = get_entry_points(root)
230
+ git = get_git_summary(root)
231
+
232
+ readme = key_files.get("README.md", "").replace("\n", " ").strip()
233
+ readme = (readme[:500] + "…") if len(readme) > 500 else (readme or "-")
234
+
235
+ if git.get("is_repo"):
236
+ git_line = (f"branch '{git['branch']}', "
237
+ f"{git['modified']} modified, {git['untracked']} untracked")
238
+ else:
239
+ git_line = "bukan git repo"
240
+
241
+ return (
242
+ f"Project: {name}\n"
243
+ f"Type: {ptype}\n"
244
+ f"Entry point: {', '.join(entries) if entries else '-'}\n"
245
+ f"Dependencies: {_parse_dependencies(key_files)}\n"
246
+ f"Structure:\n{tree}\n"
247
+ f"Git: {git_line}\n"
248
+ f"README: {readme}"
249
+ )
250
+
251
+
252
+ def project_label(root: Path | str = ".") -> str:
253
+ """Label satu baris buat status/welcome: `nama · Tipe · branch +M ~U`."""
254
+ root = Path(root)
255
+ git = get_git_summary(root)
256
+ if git.get("is_repo"):
257
+ return (f"{root.resolve().name} · {detect_project_type(root)} · "
258
+ f"{git['branch']} +{git['modified']} ~{git['untracked']}")
259
+ return f"{root.resolve().name} · {detect_project_type(root)}"
260
+
261
+
262
+ if __name__ == "__main__":
263
+ import tempfile
264
+
265
+ tmp = Path(tempfile.mkdtemp(prefix="multacd-codebase-"))
266
+ (tmp / "README.md").write_text("# Demo\nProject contoh untuk test.\n" * 5)
267
+ (tmp / "requirements.txt").write_text("textual>=8\nlitellm==1.100.0\n# komen\n")
268
+ (tmp / "main.py").write_text("print('hi')\n")
269
+ (tmp / "PLAN.md").write_text("planning, harus di-skip")
270
+ (tmp / "soul.md").write_text("kepribadian, harus di-skip")
271
+ (tmp / ".env").write_text("SECRET=xxx")
272
+ (tmp / ".venv").mkdir()
273
+ (tmp / ".venv" / "x.py").write_text("dep, harus di-skip")
274
+ (tmp / "__pycache__").mkdir()
275
+ (tmp / "__pycache__" / "a.pyc").write_bytes(b"\x00")
276
+ (tmp / "plan").mkdir()
277
+ (tmp / "sub").mkdir()
278
+ (tmp / "sub" / "deep.py").write_text("x=1\n")
279
+
280
+ # 1. Deteksi Python + entry point
281
+ assert detect_project_type(tmp) == "Python", detect_project_type(tmp)
282
+ assert get_entry_points(tmp) == ["main.py"]
283
+ assert detect_project_type(tempfile.mkdtemp()) == "Generic"
284
+
285
+ # 2. Tree skip yang harus di-skip, tampilkan yang relevan
286
+ tree = build_file_tree(tmp)
287
+ for bad in ("PLAN.md", "soul.md", ".env", ".venv", "__pycache__", "plan", ".pyc"):
288
+ assert bad not in tree, (bad, tree)
289
+ for good in ("main.py", "README.md", "requirements.txt", "sub/", "deep.py"):
290
+ assert good in tree, (good, tree)
291
+
292
+ # 3. Key files: baca yang perlu, potong yang panjang
293
+ keys = get_key_files(tmp)
294
+ assert set(keys) == {"README.md", "requirements.txt"}, set(keys)
295
+
296
+ # 4. Bukan git repo → fallback sopan
297
+ assert get_git_summary(tmp) == {"is_repo": False}
298
+
299
+ # 5. scan_project lengkap + format sesuai spec
300
+ ctx = scan_project(tmp)
301
+ for needle in ("Project: ", "Type: Python", "Entry point: main.py",
302
+ "textual, litellm", "Structure:", "Git: bukan git repo",
303
+ "README: # Demo"):
304
+ assert needle in ctx, (needle, ctx[:300])
305
+ assert "SECRET" not in ctx and "planning" not in ctx
306
+
307
+ # 6. Folder hilang / aneh → tidak crash
308
+ assert "tidak ditemukan" in build_file_tree(tmp / "nope")
309
+ assert scan_project(tmp / "nope").startswith("Project: nope")
310
+ assert "scan gagal" not in scan_project(tmp / "nope")
311
+
312
+ # 7. Repo asli multacd ke-scan beneran
313
+ real = Path(__file__).resolve().parent.parent
314
+ ctx = scan_project(real)
315
+ assert "Type: Python" in ctx and "agent_loop.py" in ctx, ctx[:500]
316
+ assert "soul.md" not in ctx.split("Structure:")[1].split("Git:")[0]
317
+
318
+ print("✅ codebase self-test OK (7 skenario)")
core/config.py ADDED
@@ -0,0 +1,340 @@
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 pathlib import Path
12
+ from typing import Literal
13
+
14
+ import yaml
15
+ from pydantic import BaseModel, Field, ValidationError, field_validator
16
+
17
+ # Hormati MULTACD_HOME (isolation test) — konsisten dengan memory/store.py
18
+ # dan tools/agent/skill.py. Dievaluasi saat import; test set env sebelum subprocess.
19
+ CONFIG_DIR = Path(os.environ.get("MULTACD_HOME", str(Path.home()))) / ".multacd"
20
+ DEFAULT_CONFIG_PATH = CONFIG_DIR / "config.yaml"
21
+ ENV_CONFIG_OVERRIDE = "MULTACD_CONFIG"
22
+
23
+ EXAMPLE_CONFIG = """\
24
+ # ~/.multacd/config.yaml — contoh config lengkap multacd (BYOK)
25
+
26
+ # ── Model (wajib diisi) ──────────────────────────────
27
+ model: "anthropic/claude-sonnet-4-6"
28
+ api_key: "sk-ant-xxxx"
29
+
30
+ # Contoh provider lain (uncomment salah satu):
31
+ #
32
+ # OpenAI
33
+ # model: "openai/gpt-4o"
34
+ # api_key: "sk-xxxx"
35
+ #
36
+ # Google Gemini
37
+ # model: "gemini/gemini-2.0-flash"
38
+ # api_key: "AIzaxxxx"
39
+ #
40
+ # Groq (cepat & murah)
41
+ # model: "groq/llama-3.3-70b-versatile"
42
+ # api_key: "gsk_xxxx"
43
+ #
44
+ # Ollama (lokal, gratis)
45
+ # model: "ollama/llama3.2"
46
+ # api_base: "http://localhost:11434"
47
+ # api_key: "none"
48
+ #
49
+ # Custom OpenAI-compatible endpoint
50
+ # model: "openai/nama-model"
51
+ # api_base: "https://endpoint-kamu.com/v1"
52
+ # api_key: "key-kamu"
53
+
54
+ # ── Agent Settings ───────────────────────────────────
55
+ max_tokens: 8096
56
+ temperature: 0.3
57
+ max_tool_iterations: 20 # batas loop per task, hindari infinite loop
58
+
59
+ # ── Permission Settings ──────────────────────────────
60
+ auto_approve_reads: true # semua tool READ → langsung jalan
61
+ ask_before_write: true # semua tool WRITE → konfirmasi dulu
62
+ ask_before_bash: true # bash → konfirmasi dulu
63
+ ask_before_web: true # web_fetch → konfirmasi dulu
64
+
65
+ # ── Display Settings ─────────────────────────────────
66
+ theme: "dark" # dark | light | catppuccin-mocha | catppuccin-latte
67
+ # | catppuccin-frappe | catppuccin-macchiato
68
+ # (dark/light = alias mocha/latte)
69
+ show_tool_calls: true # tampilkan nama tool yang dijalankan
70
+ show_thinking: false # tampilkan reasoning LLM (verbose mode)
71
+ icon_style: "auto" # auto | nerdfonts | unicode | ascii
72
+
73
+ # ── Search Provider (BYOK, Phase 3) ────────────────────
74
+ search_provider: "tavily" # tavily | exa | brave | serpapi | duckduckgo
75
+ search_api_key: "tvly-xxxx" # tidak perlu untuk duckduckgo (gratis, tidak resmi)
76
+
77
+ # ── Research Settings (Phase 3) ────────────────────────
78
+ search_results_per_query: 5 # hasil per query saat search
79
+ research_quick_queries: 3 # jumlah query untuk quick research
80
+ research_quick_max_sources: 5 # max sumber yang dibaca quick research
81
+ research_deep_rounds: 5 # max round untuk deep research
82
+ research_deep_queries_per_round: 4 # query per round di deep research
83
+ research_deep_max_sources: 20 # max total sumber deep research
84
+ research_scrape_timeout: 15 # timeout scraping per URL (detik)
85
+ research_snippet_fallback: true # pakai snippet kalau scraping gagal
86
+
87
+ # ── Telegram Bot (Phase 4, opsional) ───────────────────
88
+ # Diisi via setup wizard atau manual. Kosong = bot nonaktif.
89
+ # telegram:
90
+ # bot_token: "123456:AAF..." # dari @BotFather
91
+ # admin_id: 123456789 # Telegram user ID kamu
92
+ # admin_username: "usernamekamu"
93
+ # allowed_users: [987654321]
94
+
95
+ # ── Scheduler (Phase 4, opsional) ──────────────────────
96
+ # schedules:
97
+ # - name: "daily_briefing"
98
+ # cron: "0 7 * * *"
99
+ # action: "briefing"
100
+ # channel: "telegram"
101
+
102
+ # ── Daily Briefing (Phase 4, opsional) ─────────────────
103
+ # briefing:
104
+ # todo: true
105
+ # git_status: true
106
+ # news: true
107
+ # news_topics: ["artificial intelligence"]
108
+ # news_sources: 3
109
+ """
110
+
111
+
112
+ class TelegramConfig(BaseModel):
113
+ """Kredensial bot + whitelist (Phase 4). Default kosong = nonaktif."""
114
+
115
+ bot_token: str = ""
116
+ admin_id: int = 0
117
+ admin_username: str = ""
118
+ allowed_users: list[int] = Field(default_factory=list)
119
+
120
+
121
+ class ScheduleConfig(BaseModel):
122
+ """Satu jadwal cron (Phase 4). channel: telegram (saat ini satu-satunya)."""
123
+
124
+ name: str = Field(min_length=1)
125
+ cron: str = Field(min_length=1)
126
+ action: Literal["briefing", "research"] = "briefing"
127
+ topic: str = ""
128
+ channel: str = "telegram"
129
+
130
+
131
+ class BriefingConfig(BaseModel):
132
+ """Konten daily briefing (Phase 4)."""
133
+
134
+ todo: bool = True
135
+ news: bool = True
136
+ git_status: bool = True
137
+ weather: bool = False # Phase 5 (butuh API cuaca)
138
+ news_topics: list[str] = Field(
139
+ default_factory=lambda: ["artificial intelligence",
140
+ "software engineering"])
141
+ news_sources: int = Field(default=3, gt=0)
142
+
143
+
144
+ class Config(BaseModel):
145
+ """Config utama multacd. Field flat 1:1 dengan contoh config.yaml."""
146
+
147
+ # ── Model (wajib) ──
148
+ model: str = Field(min_length=1)
149
+ api_key: str = Field(min_length=1)
150
+ api_base: str | None = None
151
+
152
+ # ── Agent settings ──
153
+ max_tokens: int = Field(default=8096, gt=0)
154
+ temperature: float = Field(default=0.3, ge=0.0, le=2.0)
155
+ max_tool_iterations: int = Field(default=20, gt=0)
156
+
157
+ # ── Permission settings ──
158
+ auto_approve_reads: bool = True
159
+ ask_before_write: bool = True
160
+ ask_before_bash: bool = True
161
+ ask_before_web: bool = True
162
+
163
+ # ── Display settings ──
164
+ theme: Literal["dark", "light", "catppuccin-mocha", "catppuccin-latte",
165
+ "catppuccin-frappe", "catppuccin-macchiato"] = "dark"
166
+ show_tool_calls: bool = True
167
+ show_thinking: bool = False
168
+ icon_style: Literal["auto", "nerdfonts", "unicode", "ascii"] = "auto"
169
+
170
+ # ── Search provider (BYOK, Phase 3) ──
171
+ search_provider: Literal["tavily", "exa", "brave", "serpapi", "duckduckgo"] = "tavily"
172
+ search_api_key: str = ""
173
+
174
+ # ── Research settings (Phase 3) ──
175
+ search_results_per_query: int = Field(default=5, gt=0)
176
+ research_quick_queries: int = Field(default=3, gt=0)
177
+ research_quick_max_sources: int = Field(default=5, gt=0)
178
+ research_deep_rounds: int = Field(default=5, gt=0, le=10)
179
+ research_deep_queries_per_round: int = Field(default=4, gt=0)
180
+ research_deep_max_sources: int = Field(default=20, gt=0)
181
+ research_scrape_timeout: int = Field(default=15, gt=0)
182
+ research_snippet_fallback: bool = True
183
+
184
+ # ── Personal agent (Phase 4, opsional) ──
185
+ telegram: TelegramConfig = Field(default_factory=TelegramConfig)
186
+ schedules: list[ScheduleConfig] = Field(default_factory=list)
187
+ briefing: BriefingConfig = Field(default_factory=BriefingConfig)
188
+
189
+ @field_validator("search_provider", mode="before")
190
+ @classmethod
191
+ def _norm_provider(cls, v: object) -> object:
192
+ # "Tavily", " TAVILY " → "tavily" (maafkan kapital/spasi user).
193
+ return v.lower().strip() if isinstance(v, str) else v
194
+
195
+ @field_validator("theme", mode="before")
196
+ @classmethod
197
+ def _norm_theme(cls, v: object) -> object:
198
+ # "Dark", " MOCHA " → canonical ("dark", "mocha" bukan nama penuh
199
+ # tetap ditolak pydantic dengan pesan jelas — cuma maafkan case).
200
+ return v.lower().strip() if isinstance(v, str) else v
201
+
202
+
203
+ class ConfigError(Exception):
204
+ """Error config yang friendly — pesannya bisa langsung ditampilkan ke user."""
205
+
206
+
207
+ # ── Active config sesi (Bug 3) ───────────────────────────────────────────
208
+ # Tool research (quick/deep_research, web_search, query_generator) tidak
209
+ # menerima config dari LLM — mereka membaca dari sini. MultacdApp memasang
210
+ # config-nya saat init, jadi `--config PATH` dan `/model X` tetap dihormati.
211
+ # None → fallback load_config() seperti dulu (pemakaian standalone/CLI).
212
+ _active_config: Config | None = None
213
+
214
+
215
+ def set_active_config(cfg: Config | None) -> None:
216
+ """Pasang/cabut (None) config aktif sesi. Dipanggil MultacdApp saat init."""
217
+ global _active_config
218
+ _active_config = cfg
219
+
220
+
221
+ def get_active_config() -> Config | None:
222
+ """Config sesi yang sedang jalan, atau None kalau tidak ada."""
223
+ return _active_config
224
+
225
+
226
+ def resolve_config_path(explicit: Path | str | None = None) -> Path:
227
+ """Urutan prioritas: argumen eksplisit > $MULTACD_CONFIG > ~/.multacd/config.yaml."""
228
+ if explicit is not None:
229
+ return Path(explicit).expanduser()
230
+ env = os.environ.get(ENV_CONFIG_OVERRIDE)
231
+ if env:
232
+ return Path(env).expanduser()
233
+ return DEFAULT_CONFIG_PATH
234
+
235
+
236
+ def _setup_message(path: Path) -> str:
237
+ return (
238
+ "⚡ multacd — config belum ditemukan.\n"
239
+ "\n"
240
+ f"File yang dicari: {path}\n"
241
+ "\n"
242
+ "Cara setup (sekali saja):\n"
243
+ f" 1. Buat foldernya : mkdir -p {path.parent}\n"
244
+ f" 2. Simpan contoh di bawah ini sebagai: {path}\n"
245
+ " 3. Isi `model` dan `api_key` sesuai provider kamu (BYOK)\n"
246
+ " 4. Jalankan lagi: python main.py\n"
247
+ "\n"
248
+ "────── contoh config ──────\n"
249
+ f"{EXAMPLE_CONFIG}"
250
+ "───────────────────────────\n"
251
+ )
252
+
253
+
254
+ def load_config(explicit_path: Path | str | None = None) -> Config:
255
+ """Baca & validasi config. Exit(1) dengan pesan jelas kalau ada masalah."""
256
+ path = resolve_config_path(explicit_path)
257
+
258
+ # Buat folder ~/.multacd/ kalau belum ada (aman dipanggil berulang).
259
+ try:
260
+ path.parent.mkdir(parents=True, exist_ok=True)
261
+ except OSError as e:
262
+ print(f"❌ Gagal membuat folder config {path.parent}: {e}", file=sys.stderr)
263
+ raise SystemExit(1) from e
264
+
265
+ if not path.is_file():
266
+ print(_setup_message(path))
267
+ raise SystemExit(1)
268
+
269
+ try:
270
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
271
+ except yaml.YAMLError as e:
272
+ print(f"❌ config.yaml bukan YAML valid: {path}\n Detail: {e}", file=sys.stderr)
273
+ raise SystemExit(1) from e
274
+ except OSError as e:
275
+ print(f"❌ Gagal membaca config {path}: {e}", file=sys.stderr)
276
+ raise SystemExit(1) from e
277
+
278
+ if not isinstance(raw, dict) or not raw:
279
+ print(
280
+ f"❌ config.yaml kosong atau tidak berbentuk key: value: {path}\n"
281
+ " Isi dengan contoh config (jalankan tanpa config untuk melihatnya).",
282
+ file=sys.stderr,
283
+ )
284
+ raise SystemExit(1)
285
+
286
+ try:
287
+ cfg = Config(**raw)
288
+ except ValidationError as e:
289
+ print(f"❌ config.yaml tidak valid: {path}", file=sys.stderr)
290
+ for err in e.errors():
291
+ field = ".".join(str(p) for p in err["loc"])
292
+ print(f" • {field}: {err['msg']}", file=sys.stderr)
293
+ print(" Lihat contoh field yang benar di pesan setup.", file=sys.stderr)
294
+ raise SystemExit(1) from e
295
+
296
+ if not cfg.model.strip():
297
+ print("❌ Field `model` wajib diisi (contoh: anthropic/claude-sonnet-4-6).", file=sys.stderr)
298
+ raise SystemExit(1)
299
+ if not cfg.api_key.strip():
300
+ print("❌ Field `api_key` wajib diisi (BYOK — pakai key provider kamu).", file=sys.stderr)
301
+ raise SystemExit(1)
302
+
303
+ return cfg
304
+
305
+
306
+ def _mask_key(key: str) -> str:
307
+ if len(key) <= 6:
308
+ return "***"
309
+ return f"{key[:4]}***{key[-2:]}"
310
+
311
+
312
+ if __name__ == "__main__":
313
+ cfg = load_config()
314
+
315
+ # Active config (Bug 3): set/get/clear simetris.
316
+ set_active_config(cfg)
317
+ assert get_active_config() is cfg
318
+ set_active_config(None)
319
+ assert get_active_config() is None
320
+
321
+ # Phase 4: nested default + parse dari dict.
322
+ assert cfg.telegram.bot_token == "" and cfg.schedules == []
323
+ assert cfg.briefing.news_sources == 3
324
+ legacy = Config(model="m", api_key="k") # yaml lama tanpa phase4
325
+ assert legacy.telegram.admin_id == 0 and legacy.briefing.todo
326
+ full = Config(model="m", api_key="k", telegram={
327
+ "bot_token": "t", "admin_id": 1, "admin_username": "u",
328
+ "allowed_users": [2]},
329
+ schedules=[{"name": "j", "cron": "0 7 * * *",
330
+ "action": "briefing"}])
331
+ assert full.telegram.admin_id == 1 and full.schedules[0].cron == "0 7 * * *"
332
+
333
+ print("✅ Config loaded OK")
334
+ print(f" model : {cfg.model}")
335
+ print(f" api_key : {_mask_key(cfg.api_key)}")
336
+ print(f" api_base : {cfg.api_base or '(default provider)'}")
337
+ print(f" max_tokens : {cfg.max_tokens}")
338
+ print(f" temperature : {cfg.temperature}")
339
+ print(f" max_tool_iterations : {cfg.max_tool_iterations}")
340
+ print(f" theme : {cfg.theme}")