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
briefing/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Briefing package — daily briefing generator (Phase 4).
2
+
3
+ Step 1: privacy filter. Step 7: sources + generator.
4
+ """
5
+
6
+ from __future__ import annotations
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,3 @@
1
+ """Briefing sources package — todo/git/news (diisi Step 7)."""
2
+
3
+ from __future__ import annotations
@@ -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