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
briefing/__init__.py
ADDED
briefing/generator.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Briefing generator — kumpul → filter privasi → narasi LLM → gabung.
|
|
2
|
+
|
|
3
|
+
cepat:
|
|
4
|
+
python -m briefing.generator
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from briefing.privacy import PrivacyFilter
|
|
13
|
+
from briefing.sources.git_source import get_git_status
|
|
14
|
+
from briefing.sources.news_source import get_news_sync
|
|
15
|
+
from briefing.sources.todo_source import get_todos
|
|
16
|
+
|
|
17
|
+
_HARI = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"]
|
|
18
|
+
_BULAN = ["", "Jan", "Feb", "Mar", "Apr", "Mei", "Jun",
|
|
19
|
+
"Jul", "Agu", "Sep", "Okt", "Nov", "Des"]
|
|
20
|
+
|
|
21
|
+
NARRATIVE_PROMPT = (
|
|
22
|
+
"Buat briefing pagi yang ringkas dan informatif dari data berikut. "
|
|
23
|
+
"Bahasa: Indonesia santai tapi informatif. "
|
|
24
|
+
"Format: teks biasa untuk Telegram (tanpa markdown, tanpa header #). "
|
|
25
|
+
"Fokus pada yang actionable.\n\nDATA:\n{data}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _today_str(today: datetime.date | None = None) -> str:
|
|
29
|
+
today = today or datetime.date.today()
|
|
30
|
+
return (f"{_HARI[today.weekday()]}, {today.day} "
|
|
31
|
+
f"{_BULAN[today.month]} {today.year}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _collect_raw(cfg: Any, research_fn: Any = None,
|
|
35
|
+
todo_extra: Any = None, projects: Any = None) -> str:
|
|
36
|
+
"""Kumpulkan data mentah semua section yang aktif di config."""
|
|
37
|
+
b = cfg.briefing
|
|
38
|
+
blocks: list[str] = []
|
|
39
|
+
if b.todo:
|
|
40
|
+
todos = get_todos(todo_extra)
|
|
41
|
+
blocks.append("TODO:\n" + ("\n".join(f"- {t}" for t in todos)
|
|
42
|
+
if todos else "(tidak ada todo pending)"))
|
|
43
|
+
if b.git_status:
|
|
44
|
+
statuses = get_git_status(projects)
|
|
45
|
+
blocks.append("GIT:\n" + ("\n".join(statuses)
|
|
46
|
+
if statuses else "(tidak ada project)"))
|
|
47
|
+
if b.news:
|
|
48
|
+
topics = list(b.news_topics or [])
|
|
49
|
+
if topics:
|
|
50
|
+
news = get_news_sync(topics, research_fn, b.news_sources)
|
|
51
|
+
lines = [f"[{t}] {news.get(t, '-')}" for t in topics]
|
|
52
|
+
blocks.append("BERITA:\n" + "\n".join(lines))
|
|
53
|
+
return "\n\n".join(blocks)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _narrate(llm: Any, clean: str) -> str:
|
|
57
|
+
done = await llm.complete(
|
|
58
|
+
[{"role": "user",
|
|
59
|
+
"content": NARRATIVE_PROMPT.format(data=clean[:12000])}])
|
|
60
|
+
return (done.text or "").strip() or "(LLM tidak memberi narasi)"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def generate_briefing(config: Any = None, llm: Any = None,
|
|
64
|
+
research_fn: Any = None,
|
|
65
|
+
todo_extra: Any = None, projects: Any = None,
|
|
66
|
+
today: datetime.date | None = None) -> str:
|
|
67
|
+
"""Generate teks briefing lengkap (sync — aman dari job thread)."""
|
|
68
|
+
from tools.research.quick_research import _run_coro
|
|
69
|
+
|
|
70
|
+
if config is None:
|
|
71
|
+
from core.config import get_active_config, load_config
|
|
72
|
+
config = get_active_config() or load_config()
|
|
73
|
+
if llm is None:
|
|
74
|
+
from core.llm_client import setup_client
|
|
75
|
+
llm = setup_client(config)
|
|
76
|
+
|
|
77
|
+
raw = _collect_raw(config, research_fn, todo_extra, projects)
|
|
78
|
+
clean, private = PrivacyFilter().filter(raw)
|
|
79
|
+
narrative = (_run_coro(_narrate(llm, clean)) if clean.strip()
|
|
80
|
+
else "(tidak ada data briefing hari ini)")
|
|
81
|
+
parts = [f"Briefing Pagi — {_today_str(today)}", "", narrative]
|
|
82
|
+
if private:
|
|
83
|
+
parts += ["", "─────", "PRIVATE (tidak dikirim ke AI):",
|
|
84
|
+
*("- " + item for item in private)]
|
|
85
|
+
return "\n".join(parts).strip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
import os
|
|
90
|
+
import tempfile
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
from types import SimpleNamespace
|
|
93
|
+
|
|
94
|
+
from core.llm_client import StreamDone
|
|
95
|
+
|
|
96
|
+
seen_prompts: list[str] = []
|
|
97
|
+
|
|
98
|
+
class _FakeLLM:
|
|
99
|
+
async def complete(self, messages: list) -> StreamDone:
|
|
100
|
+
seen_prompts.append(messages[0]["content"])
|
|
101
|
+
return StreamDone("Ringkasan: 2 todo, git bersih.", [])
|
|
102
|
+
|
|
103
|
+
async def _fake_news(topic: str) -> str:
|
|
104
|
+
return f"kabar {topic}: baik"
|
|
105
|
+
|
|
106
|
+
with tempfile.TemporaryDirectory() as d:
|
|
107
|
+
os.environ["MULTACD_HOME"] = d
|
|
108
|
+
todo = Path(d) / "todo.md"
|
|
109
|
+
todo.write_text("- Fix bug\n- [private] Password db: s3cr3t\n",
|
|
110
|
+
encoding="utf-8")
|
|
111
|
+
cfg = SimpleNamespace(briefing=SimpleNamespace(
|
|
112
|
+
todo=True, news=True, git_status=False, news_topics=["AI"],
|
|
113
|
+
news_sources=3))
|
|
114
|
+
out = generate_briefing(config=cfg, llm=_FakeLLM(),
|
|
115
|
+
research_fn=_fake_news,
|
|
116
|
+
todo_extra=[todo],
|
|
117
|
+
today=datetime.date(2026, 9, 11))
|
|
118
|
+
assert "Briefing Pagi — Jumat, 11 Sep 2026" in out
|
|
119
|
+
assert "Ringkasan" in out and "Fix bug" not in out.split("PRIVATE")[0]
|
|
120
|
+
assert "s3cr3t" in out.split("PRIVATE")[1] # private ada di akhir
|
|
121
|
+
assert "s3cr3t" not in seen_prompts[0] # TIDAK bocor ke LLM
|
|
122
|
+
assert "kabar AI" in seen_prompts[0] # berita masuk LLM
|
|
123
|
+
del os.environ["MULTACD_HOME"]
|
|
124
|
+
|
|
125
|
+
print("✅ generator self-test OK (narrate + privacy no-leak)")
|
briefing/privacy.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Privacy filter — pisahkan item sensitif sebelum data dikirim ke LLM.
|
|
2
|
+
|
|
3
|
+
Kontrak (PLAN Phase 4 §5):
|
|
4
|
+
filter(content) -> (clean_content, private_items)
|
|
5
|
+
- Baris bertag [private] (semua format, case-insensitive) → private.
|
|
6
|
+
- Baris auto-sensitif (password, api key, token panjang) → private.
|
|
7
|
+
- clean_content aman dikirim ke LLM; private_items diproses lokal.
|
|
8
|
+
|
|
9
|
+
Test cepat:
|
|
10
|
+
python -m briefing.privacy
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
# Tag eksplisit: [private], # private:, >>private: (case-insensitive).
|
|
18
|
+
PRIVATE_PATTERNS = [
|
|
19
|
+
r"\[private\]",
|
|
20
|
+
r"#\s*private\s*:",
|
|
21
|
+
r">>\s*private\s*:",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Auto-detect: pola password/secret/kunci + token panjang mirip API key.
|
|
25
|
+
AUTO_SENSITIVE_PATTERNS = [
|
|
26
|
+
r"password[:\s=]",
|
|
27
|
+
r"passwd[:\s=]",
|
|
28
|
+
r"secret[:\s=]",
|
|
29
|
+
r"api.?key[:\s=]",
|
|
30
|
+
r"\b(?:sk|tvly|exa|brv|tavily)[-_][A-Za-z0-9_\-]{8,}",
|
|
31
|
+
r"\b[A-Za-z0-9_\-]{20,}\b",
|
|
32
|
+
r"\b\d{4}[-\s]?\d{4}[-\s]?\d{2,}\b",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_TAG_RE = re.compile("|".join(f"(?:{p})" for p in PRIVATE_PATTERNS),
|
|
36
|
+
re.IGNORECASE)
|
|
37
|
+
_AUTO_RES = [re.compile(p, re.IGNORECASE)
|
|
38
|
+
for p in AUTO_SENSITIVE_PATTERNS]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clean_item(line: str) -> str:
|
|
42
|
+
"""Buang tag [private] + marker list, sisakan isi mentah."""
|
|
43
|
+
item = _TAG_RE.sub("", line).strip()
|
|
44
|
+
return re.sub(r"^[\-\*>\s]+", "", item).strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PrivacyFilter:
|
|
48
|
+
"""Filter stateless — satu instance bisa dipakai ulang."""
|
|
49
|
+
|
|
50
|
+
def is_sensitive_line(self, line: str) -> bool:
|
|
51
|
+
"""True kalau baris bertag private atau cocok pola sensitif."""
|
|
52
|
+
if not line or not line.strip():
|
|
53
|
+
return False
|
|
54
|
+
if _TAG_RE.search(line):
|
|
55
|
+
return True
|
|
56
|
+
return any(r.search(line) for r in _AUTO_RES)
|
|
57
|
+
|
|
58
|
+
def filter(self, content: str) -> tuple[str, list[str]]:
|
|
59
|
+
"""Pisahkan konten. Return (bersih_untuk_LLM, item_private)."""
|
|
60
|
+
if not content:
|
|
61
|
+
return "", []
|
|
62
|
+
clean_lines: list[str] = []
|
|
63
|
+
private_items: list[str] = []
|
|
64
|
+
for line in content.splitlines():
|
|
65
|
+
if self.is_sensitive_line(line):
|
|
66
|
+
item = _clean_item(line)
|
|
67
|
+
private_items.append(item or line.strip())
|
|
68
|
+
else:
|
|
69
|
+
clean_lines.append(line)
|
|
70
|
+
return "\n".join(clean_lines), private_items
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
f = PrivacyFilter()
|
|
75
|
+
|
|
76
|
+
# 1. Semua format tag eksplisit (case-insensitive).
|
|
77
|
+
for tag in ("[private] Password db: x", "[PRIVATE] No rek: 1",
|
|
78
|
+
"- # private: antar obat", ">>private: token abc",
|
|
79
|
+
">>PRIVATE: rahasia", "# PRIVATE: pin"):
|
|
80
|
+
assert f.is_sensitive_line(tag), tag
|
|
81
|
+
clean, priv = f.filter(tag)
|
|
82
|
+
assert clean.strip() == "" and len(priv) == 1, tag
|
|
83
|
+
assert _TAG_RE.search(tag) and not _TAG_RE.search(priv[0]), tag
|
|
84
|
+
|
|
85
|
+
# 2. Baris biasa lolos, kosong diabaikan.
|
|
86
|
+
assert not f.is_sensitive_line("Beli susu besok")
|
|
87
|
+
assert not f.is_sensitive_line("")
|
|
88
|
+
assert not f.is_sensitive_line(" ")
|
|
89
|
+
|
|
90
|
+
# 3. Auto-detect tanpa tag: password + key panjang.
|
|
91
|
+
assert f.is_sensitive_line("db password: hunter2")
|
|
92
|
+
assert f.is_sensitive_line("api_key=tvly-abc123XYZ")
|
|
93
|
+
assert f.is_sensitive_line("token sk-ant-abcd1234efgh")
|
|
94
|
+
assert f.is_sensitive_line("kartu 1234-5678-9012")
|
|
95
|
+
|
|
96
|
+
# 4. Campuran: bersih tidak bocor, private lengkap.
|
|
97
|
+
mixed = "\n".join([
|
|
98
|
+
"- Beli susu besok",
|
|
99
|
+
"[private] Password database: xxxxx",
|
|
100
|
+
"- Meeting klien jam 3",
|
|
101
|
+
"api_key = sk-1234567890abcdefghij",
|
|
102
|
+
])
|
|
103
|
+
clean, priv = f.filter(mixed)
|
|
104
|
+
assert "Beli susu" in clean and "Meeting" in clean
|
|
105
|
+
assert "xxxxx" not in clean and "sk-1234" not in clean
|
|
106
|
+
assert len(priv) == 2 and "Password database" in priv[0]
|
|
107
|
+
|
|
108
|
+
# 5. Kosong & tanpa private.
|
|
109
|
+
assert f.filter("") == ("", [])
|
|
110
|
+
assert f.filter("a\nb") == ("a\nb", [])
|
|
111
|
+
|
|
112
|
+
print("✅ privacy self-test OK (tag + auto-detect + no-leak)")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Git source — ringkasan status project (PLAN Phase 4 Step 7).
|
|
2
|
+
|
|
3
|
+
Default [cwd] + path eksplisit yang mengandung .git. Via git CLI langsung
|
|
4
|
+
(sync, tanpa dependensi tools.git).
|
|
5
|
+
|
|
6
|
+
Test cepat:
|
|
7
|
+
python -m briefing.sources.git_source
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_projects(extra: list[str | Path] | None = None) -> list[Path]:
|
|
17
|
+
"""Project = cwd + path eksplisit bervolume .git (unik, yang ada saja)."""
|
|
18
|
+
seen: list[Path] = []
|
|
19
|
+
for raw in [Path.cwd(), *(Path(p) for p in (extra or []))]:
|
|
20
|
+
try:
|
|
21
|
+
p = raw.resolve()
|
|
22
|
+
except OSError:
|
|
23
|
+
continue
|
|
24
|
+
if (p / ".git").is_dir() and p not in seen:
|
|
25
|
+
seen.append(p)
|
|
26
|
+
return seen
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _git(args: list[str], cwd: Path, timeout: int = 10) -> str | None:
|
|
30
|
+
try:
|
|
31
|
+
proc = subprocess.run(
|
|
32
|
+
["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
33
|
+
timeout=timeout)
|
|
34
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
35
|
+
return None
|
|
36
|
+
if proc.returncode != 0:
|
|
37
|
+
return None
|
|
38
|
+
return proc.stdout.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def git_summary(path: str | Path) -> str:
|
|
42
|
+
"""Satu baris ringkas: 'nama (branch): N berubah, commit terakhir ...'."""
|
|
43
|
+
p = Path(path)
|
|
44
|
+
branch = _git(["branch", "--show-current"], p) or "?"
|
|
45
|
+
porcelain = _git(["status", "--porcelain"], p)
|
|
46
|
+
changed = len(porcelain.splitlines()) if porcelain else 0
|
|
47
|
+
last = _git(["log", "-1", "--format=%h %s (%ar)"], p) or "belum ada commit"
|
|
48
|
+
return (f"{p.name} ({branch}): {changed} file berubah, "
|
|
49
|
+
f"terakhir {last}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_git_status(extra: list[str | Path] | None = None) -> list[str]:
|
|
53
|
+
"""Ringkasan semua project yang ketemu."""
|
|
54
|
+
return [git_summary(p) for p in get_projects(extra)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
import tempfile
|
|
59
|
+
|
|
60
|
+
with tempfile.TemporaryDirectory() as d:
|
|
61
|
+
repo = Path(d) / "demo"
|
|
62
|
+
repo.mkdir()
|
|
63
|
+
assert repo.resolve() not in get_projects(extra=[repo]) # bukan git
|
|
64
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
65
|
+
subprocess.run(["git", "config", "user.email", "t@t.id"],
|
|
66
|
+
cwd=repo, check=True)
|
|
67
|
+
subprocess.run(["git", "config", "user.name", "t"], cwd=repo,
|
|
68
|
+
check=True)
|
|
69
|
+
(repo / "a.txt").write_text("hi", encoding="utf-8")
|
|
70
|
+
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
71
|
+
subprocess.run(["git", "commit", "-qm", "awal"], cwd=repo, check=True)
|
|
72
|
+
(repo / "b.txt").write_text("baru", encoding="utf-8")
|
|
73
|
+
assert repo.resolve() in get_projects(extra=[repo])
|
|
74
|
+
s = git_summary(repo)
|
|
75
|
+
assert "demo" in s and "1 file berubah" in s and "awal" in s, s
|
|
76
|
+
|
|
77
|
+
print("✅ git_source self-test OK (detect + summary)")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""News source — riset berita per topik, paralel (PLAN Phase 4 Step 7).
|
|
2
|
+
|
|
3
|
+
research_fn(topic) -> str di-inject biar test tanpa network.
|
|
4
|
+
Default: orchestrator quick_research (active config → setup_client).
|
|
5
|
+
|
|
6
|
+
Test cepat:
|
|
7
|
+
python -m briefing.sources.news_source
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
ResearchFn = Callable[[str], Awaitable[str]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _default_research(topic: str, max_sources: int = 3) -> str:
|
|
20
|
+
from core.config import get_active_config, load_config
|
|
21
|
+
from core.llm_client import setup_client
|
|
22
|
+
from core.research.orchestrator import quick_research as _qr
|
|
23
|
+
|
|
24
|
+
config = get_active_config() or load_config()
|
|
25
|
+
llm = setup_client(config)
|
|
26
|
+
res = await _qr(f"latest news about {topic}", config=config, llm=llm)
|
|
27
|
+
answer = (res.answer or "").strip()
|
|
28
|
+
shown = (res.sources[:max_sources]
|
|
29
|
+
if max_sources > 0 and len(res.sources) > max_sources
|
|
30
|
+
else res.sources)
|
|
31
|
+
if shown:
|
|
32
|
+
names = ", ".join(getattr(s, "title", "?") or "?" for s in shown)
|
|
33
|
+
answer += f" ({len(shown)} sumber: {names})"
|
|
34
|
+
return answer or "(tidak ada berita ditemukan)"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def get_news(topics: list[str],
|
|
38
|
+
research_fn: ResearchFn | None = None,
|
|
39
|
+
max_sources: int = 3) -> dict[str, str]:
|
|
40
|
+
"""Riset semua topik paralel. Gagal satu → pesan, bukan raise."""
|
|
41
|
+
async def _one(topic: str) -> tuple[str, str]:
|
|
42
|
+
try:
|
|
43
|
+
if research_fn is not None:
|
|
44
|
+
return topic, await research_fn(topic)
|
|
45
|
+
return topic, await _default_research(topic, max_sources)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return topic, f"(gagal riset: {e})"
|
|
48
|
+
|
|
49
|
+
results = await asyncio.gather(*[_one(t) for t in topics])
|
|
50
|
+
return dict(results)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_news_sync(topics: list[str], research_fn: Any = None,
|
|
54
|
+
max_sources: int = 3) -> dict[str, str]:
|
|
55
|
+
"""Wrapper sync (dipakai generator yang jalan di job thread)."""
|
|
56
|
+
from tools.research.quick_research import _run_coro
|
|
57
|
+
return _run_coro(get_news(topics, research_fn, max_sources))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
async def _fake(topic: str) -> str:
|
|
62
|
+
if topic == "rusak":
|
|
63
|
+
raise RuntimeError("jaringan putus")
|
|
64
|
+
return f"berita {topic}: model baru rilis"
|
|
65
|
+
|
|
66
|
+
async def _go() -> None:
|
|
67
|
+
out = await get_news(["AI", "rusak"], _fake)
|
|
68
|
+
assert out["AI"] == "berita AI: model baru rilis"
|
|
69
|
+
assert "gagal riset" in out["rusak"]
|
|
70
|
+
assert await get_news([], _fake) == {}
|
|
71
|
+
|
|
72
|
+
asyncio.run(_go())
|
|
73
|
+
out = get_news_sync(["AI"], _fake)
|
|
74
|
+
assert out == {"AI": "berita AI: model baru rilis"}, out
|
|
75
|
+
|
|
76
|
+
print("✅ news_source self-test OK (paralel + gagal-satu)")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Todo source — baca item belum selesai (PLAN Phase 4 Step 7).
|
|
2
|
+
|
|
3
|
+
Cari: ./todo.md, ./TODO.md, ~/.multacd/todo.md (hormati MULTACD_HOME).
|
|
4
|
+
Format: '- [ ] x' / '- x' / '* x' / '1. x'. Selesai ([x]/[X]) di-skip.
|
|
5
|
+
|
|
6
|
+
Test cepat:
|
|
7
|
+
python -m briefing.sources.todo_source
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_DONE_RE = re.compile(r"^\s*[-*]\s*\[[xX]\]\s*")
|
|
17
|
+
_ITEM_RE = re.compile(r"^\s*(?:[-*]\s*(?:\[[ ]\]\s*)?|\d+[.)]\s*)(.+?)\s*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_todo_files(extra: list[str | Path] | None = None) -> list[Path]:
|
|
21
|
+
"""Kembalikan path todo yang ada (tidak raise kalau kosong)."""
|
|
22
|
+
candidates = [Path("todo.md"), Path("TODO.md"),
|
|
23
|
+
Path(os.environ.get("MULTACD_HOME", str(Path.home())))
|
|
24
|
+
/ ".multacd" / "todo.md"]
|
|
25
|
+
if extra:
|
|
26
|
+
candidates.extend(Path(p) for p in extra)
|
|
27
|
+
return [p for p in candidates if p.is_file()]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_todo_file(path: str | Path) -> list[str]:
|
|
31
|
+
"""Parse item belum selesai dari satu file."""
|
|
32
|
+
items: list[str] = []
|
|
33
|
+
try:
|
|
34
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
35
|
+
except OSError:
|
|
36
|
+
return []
|
|
37
|
+
for line in lines:
|
|
38
|
+
if not line.strip() or _DONE_RE.match(line):
|
|
39
|
+
continue
|
|
40
|
+
m = _ITEM_RE.match(line)
|
|
41
|
+
if m and m.group(1).strip():
|
|
42
|
+
items.append(m.group(1).strip())
|
|
43
|
+
return items
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_todos(extra: list[str | Path] | None = None) -> list[str]:
|
|
47
|
+
"""Semua todo pending dari semua file yang ketemu."""
|
|
48
|
+
out: list[str] = []
|
|
49
|
+
for path in find_todo_files(extra):
|
|
50
|
+
out.extend(parse_todo_file(path))
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
import tempfile
|
|
56
|
+
|
|
57
|
+
with tempfile.TemporaryDirectory() as d:
|
|
58
|
+
os.environ["MULTACD_HOME"] = d
|
|
59
|
+
assert get_todos() == [] # tidak ada file → kosong, bukan error
|
|
60
|
+
f = Path(d) / "todo.md"
|
|
61
|
+
f.write_text("# Hari ini\n- [ ] Fix bug agent\n- [x] Sudah ini\n"
|
|
62
|
+
"- Beli susu\n1. Review PR\n", encoding="utf-8")
|
|
63
|
+
items = get_todos(extra=[f])
|
|
64
|
+
assert "Fix bug agent" in items and "Beli susu" in items
|
|
65
|
+
assert "Review PR" in items
|
|
66
|
+
assert not any("Sudah ini" in i for i in items)
|
|
67
|
+
del os.environ["MULTACD_HOME"]
|
|
68
|
+
|
|
69
|
+
print("✅ todo_source self-test OK (parse + skip-done)")
|
core/__init__.py
ADDED
|
File without changes
|