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.
- briefing/__init__.py +6 -0
- briefing/generator.py +125 -0
- briefing/privacy.py +112 -0
- briefing/sources/__init__.py +3 -0
- briefing/sources/git_source.py +77 -0
- briefing/sources/news_source.py +76 -0
- briefing/sources/todo_source.py +69 -0
- core/__init__.py +0 -0
- core/agent_loop.py +410 -0
- core/codebase.py +318 -0
- core/config.py +340 -0
- core/llm_client.py +347 -0
- core/mode_manager.py +342 -0
- core/permissions.py +284 -0
- core/plugin_loader.py +194 -0
- core/prompt_composer.py +126 -0
- core/research/__init__.py +0 -0
- core/research/bus.py +41 -0
- core/research/orchestrator.py +638 -0
- core/research/query_generator.py +199 -0
- core/updater.py +187 -0
- core/version.py +20 -0
- daemon/__init__.py +3 -0
- daemon/ipc.py +127 -0
- daemon/process.py +205 -0
- main.py +195 -0
- memory/__init__.py +0 -0
- memory/context.py +117 -0
- memory/store.py +78 -0
- multacd-1.0.0.dist-info/METADATA +94 -0
- multacd-1.0.0.dist-info/RECORD +135 -0
- multacd-1.0.0.dist-info/WHEEL +4 -0
- multacd-1.0.0.dist-info/entry_points.txt +2 -0
- multacd-1.0.0.dist-info/licenses/LICENSE +21 -0
- scheduler/__init__.py +3 -0
- scheduler/engine.py +227 -0
- scheduler/jobs/__init__.py +37 -0
- scheduler/jobs/briefing_job.py +36 -0
- scheduler/jobs/research_job.py +43 -0
- search_providers/__init__.py +41 -0
- search_providers/__main__.py +43 -0
- search_providers/base.py +137 -0
- search_providers/brave.py +87 -0
- search_providers/duckduckgo.py +114 -0
- search_providers/exa.py +99 -0
- search_providers/serpapi.py +85 -0
- search_providers/tavily.py +79 -0
- soul.md +30 -0
- tg/__init__.py +7 -0
- tg/access_control.py +131 -0
- tg/agent.py +268 -0
- tg/bot.py +79 -0
- tg/file_handler.py +152 -0
- tg/formatter.py +94 -0
- tg/handlers.py +358 -0
- tools/__init__.py +0 -0
- tools/agent/__init__.py +0 -0
- tools/agent/ask.py +35 -0
- tools/agent/skill.py +67 -0
- tools/agent/task.py +33 -0
- tools/agent/todo_write.py +54 -0
- tools/code/__init__.py +0 -0
- tools/code/lint_python.py +144 -0
- tools/code/run_python.py +169 -0
- tools/code/run_tests.py +156 -0
- tools/codebase/__init__.py +0 -0
- tools/codebase/scan_codebase.py +28 -0
- tools/common.py +18 -0
- tools/filesystem/__init__.py +0 -0
- tools/filesystem/apply_patch.py +89 -0
- tools/filesystem/delete_file.py +36 -0
- tools/filesystem/edit_file.py +42 -0
- tools/filesystem/glob.py +35 -0
- tools/filesystem/grep.py +78 -0
- tools/filesystem/list_dir.py +46 -0
- tools/filesystem/move_file.py +38 -0
- tools/filesystem/multi_edit.py +46 -0
- tools/filesystem/read_file.py +42 -0
- tools/filesystem/read_many_files.py +35 -0
- tools/filesystem/write_file.py +37 -0
- tools/git/__init__.py +0 -0
- tools/git/_helper.py +44 -0
- tools/git/git_add.py +21 -0
- tools/git/git_branch.py +13 -0
- tools/git/git_checkout.py +25 -0
- tools/git/git_commit.py +21 -0
- tools/git/git_diff.py +18 -0
- tools/git/git_log.py +17 -0
- tools/git/git_merge.py +140 -0
- tools/git/git_pull.py +17 -0
- tools/git/git_push.py +92 -0
- tools/git/git_status.py +13 -0
- tools/memory/__init__.py +0 -0
- tools/memory/forget.py +33 -0
- tools/memory/recall.py +35 -0
- tools/memory/remember.py +34 -0
- tools/personal/__init__.py +3 -0
- tools/personal/cancel_job.py +55 -0
- tools/personal/daemon_status.py +64 -0
- tools/personal/generate_briefing.py +93 -0
- tools/personal/get_jobs.py +52 -0
- tools/personal/schedule_job.py +77 -0
- tools/personal/send_telegram.py +171 -0
- tools/personal/user_manager.py +103 -0
- tools/registry.py +286 -0
- tools/research/__init__.py +0 -0
- tools/research/deep_research.py +148 -0
- tools/research/export_research.py +146 -0
- tools/research/quick_research.py +159 -0
- tools/research/web_scrape.py +172 -0
- tools/research/web_search.py +176 -0
- tools/shell/__init__.py +0 -0
- tools/shell/bash.py +60 -0
- tools/web/__init__.py +0 -0
- tools/web/web_fetch.py +66 -0
- tui/__init__.py +0 -0
- tui/app.py +113 -0
- tui/icons.py +114 -0
- tui/screens/__init__.py +0 -0
- tui/screens/main_screen.py +424 -0
- tui/screens/setup_wizard.py +483 -0
- tui/themes/__init__.py +133 -0
- tui/themes/__main__.py +17 -0
- tui/widgets/__init__.py +0 -0
- tui/widgets/chat_panel.py +83 -0
- tui/widgets/confirm_dialog.py +52 -0
- tui/widgets/diff_viewer.py +68 -0
- tui/widgets/file_tree.py +121 -0
- tui/widgets/input_bar.py +92 -0
- tui/widgets/permission_popup.py +172 -0
- tui/widgets/slash_palette.py +131 -0
- tui/widgets/sources_panel.py +201 -0
- tui/widgets/status_bar.py +70 -0
- tui/widgets/thinking_bar.py +106 -0
- 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}")
|