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/llm_client.py ADDED
@@ -0,0 +1,347 @@
1
+ """LLM client โ€” wrapper LiteLLM: streaming + tool call + retry.
2
+
3
+ Dipakai oleh agent loop (Step 7) lewat async generator:
4
+
5
+ client = setup_client(config)
6
+ async for event in client.stream_completion(messages, tools):
7
+ if isinstance(event, StreamText):
8
+ ... tampilkan event.content ke TUI ...
9
+ elif isinstance(event, StreamDone):
10
+ ... event.text = teks penuh, event.tool_calls = [ToolCallRequest] ...
11
+
12
+ Test cepat tanpa API key (logika akumulasi + error mapping):
13
+ python -m core.llm_client
14
+ Test live (butuh ~/.multacd/config.yaml yang valid):
15
+ python -m core.llm_client --live "hello"
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import asyncio
22
+ import json
23
+ from collections.abc import AsyncIterator
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ import litellm
28
+ from litellm import (
29
+ APIConnectionError,
30
+ AuthenticationError,
31
+ NotFoundError,
32
+ RateLimitError,
33
+ )
34
+
35
+ from core.config import Config, load_config
36
+
37
+ MAX_CONNECTION_RETRIES = 3
38
+ MAX_RATE_LIMIT_RETRIES = 3
39
+ RETRY_BASE_DELAY = 2.0 # detik, exponential backoff
40
+ REQUEST_TIMEOUT = 120.0
41
+
42
+
43
+ class LLMError(Exception):
44
+ """Error LLM dengan pesan yang friendly โ€” tampilkan langsung ke user."""
45
+
46
+
47
+ @dataclass
48
+ class ToolCallRequest:
49
+ """Satu tool call yang diminta LLM (sudah dirakit dari fragment streaming)."""
50
+
51
+ id: str
52
+ name: str
53
+ arguments: dict[str, Any]
54
+
55
+
56
+ @dataclass
57
+ class StreamText:
58
+ """Potongan teks baru dari LLM (tampilkan real-time ke TUI)."""
59
+
60
+ content: str
61
+
62
+
63
+ @dataclass
64
+ class StreamDone:
65
+ """Stream selesai: teks penuh + daftar tool call (bisa kosong)."""
66
+
67
+ text: str
68
+ tool_calls: list[ToolCallRequest] = field(default_factory=list)
69
+
70
+
71
+ StreamEvent = StreamText | StreamDone
72
+
73
+
74
+ def setup_client(config: Config) -> LLMClient:
75
+ """Init client dari config (LiteLLM stateless โ€” ini bungkus kwargs + retry)."""
76
+ return LLMClient(config)
77
+
78
+
79
+ class LLMClient:
80
+ def __init__(self, config: Config) -> None:
81
+ self.config = config
82
+
83
+ def _base_kwargs(self) -> dict[str, Any]:
84
+ kwargs: dict[str, Any] = {
85
+ "model": self.config.model,
86
+ "api_key": self.config.api_key,
87
+ "max_tokens": self.config.max_tokens,
88
+ "temperature": self.config.temperature,
89
+ "timeout": REQUEST_TIMEOUT,
90
+ }
91
+ if self.config.api_base:
92
+ kwargs["api_base"] = self.config.api_base
93
+ return kwargs
94
+
95
+ async def stream_completion(
96
+ self,
97
+ messages: list[dict[str, Any]],
98
+ tools: list[dict[str, Any]] | None = None,
99
+ ) -> AsyncIterator[StreamEvent]:
100
+ """Kirim messages ke LLM, yield StreamText lalu diakhiri satu StreamDone.
101
+
102
+ Retry hanya dilakukan jika stream BELUM menghasilkan teks apapun
103
+ (aman โ€” tidak ada duplikasi output). Gagal di tengah stream
104
+ langsung raise LLMError.
105
+ """
106
+ kwargs = self._base_kwargs()
107
+ if tools:
108
+ kwargs["tools"] = tools
109
+
110
+ conn_attempts = 0
111
+ rate_attempts = 0
112
+ while True:
113
+ accumulator = _StreamAccumulator()
114
+ yielded_any_text = False
115
+ try:
116
+ stream = await litellm.acompletion(stream=True, messages=messages, **kwargs)
117
+ async for chunk in stream:
118
+ for event in accumulator.feed(chunk):
119
+ if isinstance(event, StreamText) and event.content:
120
+ yielded_any_text = True
121
+ yield event
122
+ yield accumulator.done()
123
+ return
124
+ except RateLimitError as e:
125
+ rate_attempts += 1
126
+ if rate_attempts > MAX_RATE_LIMIT_RETRIES or yielded_any_text:
127
+ raise LLMError(
128
+ "โณ Rate limit dari provider (429) โ€” "
129
+ f"sudah retry {MAX_RATE_LIMIT_RETRIES}x, masih dibatasi. "
130
+ "Tunggu sebentar lalu coba lagi."
131
+ ) from e
132
+ await asyncio.sleep(RETRY_BASE_DELAY * (2 ** (rate_attempts - 1)))
133
+ except APIConnectionError as e:
134
+ conn_attempts += 1
135
+ if conn_attempts > MAX_CONNECTION_RETRIES or yielded_any_text:
136
+ raise LLMError(
137
+ "๐ŸŒ Gagal konek ke LLM provider "
138
+ f"({MAX_CONNECTION_RETRIES}x percobaan). "
139
+ "Cek koneksi internet / api_base di config."
140
+ ) from e
141
+ await asyncio.sleep(RETRY_BASE_DELAY * conn_attempts)
142
+ except AuthenticationError as e:
143
+ raise LLMError(
144
+ "๐Ÿ”‘ API key ditolak provider. Cek `api_key` di ~/.multacd/config.yaml "
145
+ "(BYOK โ€” pastikan key cocok dengan `model` yang dipilih)."
146
+ ) from e
147
+ except NotFoundError as e:
148
+ raise LLMError(
149
+ f"๐Ÿ” Model `{self.config.model}` tidak ditemukan oleh provider. "
150
+ "Cek penulisan `model` di config (format LiteLLM, mis. "
151
+ "`anthropic/claude-sonnet-4-6`, `openai/gpt-4o`)."
152
+ ) from e
153
+ except LLMError:
154
+ raise
155
+ except Exception as e:
156
+ raise LLMError(f"โŒ LLM error tak terduga ({type(e).__name__}): {e}") from e
157
+
158
+ async def complete(
159
+ self,
160
+ messages: list[dict[str, Any]],
161
+ tools: list[dict[str, Any]] | None = None,
162
+ ) -> StreamDone:
163
+ """Non-streaming: kumpulkan stream jadi satu hasil (buat test/debug)."""
164
+ full: StreamDone | None = None
165
+ async for event in self.stream_completion(messages, tools):
166
+ if isinstance(event, StreamDone):
167
+ full = event
168
+ assert full is not None
169
+ return full
170
+
171
+
172
+ def _get(obj: Any, key: str, default: Any = None) -> Any:
173
+ """Ambil atribut ATAU key dict โ€” chunk LiteLLM bisa berbentuk keduanya."""
174
+ if obj is None:
175
+ return default
176
+ if isinstance(obj, dict):
177
+ return obj.get(key, default)
178
+ return getattr(obj, key, default)
179
+
180
+
181
+ class _StreamAccumulator:
182
+ """Rakit fragment streaming LiteLLM jadi teks + tool call utuh."""
183
+
184
+ def __init__(self) -> None:
185
+ self._text_parts: list[str] = []
186
+ self._calls: dict[int, dict[str, Any]] = {}
187
+
188
+ def feed(self, chunk: Any) -> list[StreamText]:
189
+ events: list[StreamText] = []
190
+ choices = _get(chunk, "choices", []) or []
191
+ if not choices:
192
+ return events
193
+ delta = _get(choices[0], "delta", {}) or {}
194
+
195
+ content = _get(delta, "content")
196
+ if content:
197
+ self._text_parts.append(content)
198
+ events.append(StreamText(content))
199
+
200
+ for tc in _get(delta, "tool_calls", None) or []:
201
+ index = _get(tc, "index", 0) or 0
202
+ slot = self._calls.setdefault(index, {"id": "", "name": "", "args": ""})
203
+ tc_id = _get(tc, "id")
204
+ if tc_id:
205
+ slot["id"] = tc_id
206
+ func = _get(tc, "function", {}) or {}
207
+ name = _get(func, "name")
208
+ if name:
209
+ slot["name"] = name
210
+ args = _get(func, "arguments")
211
+ if args:
212
+ slot["args"] += args
213
+ return events
214
+
215
+ def done(self) -> StreamDone:
216
+ calls: list[ToolCallRequest] = []
217
+ for index in sorted(self._calls):
218
+ slot = self._calls[index]
219
+ name = slot["name"]
220
+ if not name:
221
+ continue # fragment tanpa nama โ€” abaikan
222
+ raw_args = slot["args"] or "{}"
223
+ try:
224
+ arguments = json.loads(raw_args)
225
+ except json.JSONDecodeError as e:
226
+ raise LLMError(
227
+ f"โŒ LLM mengirim tool_call `{name}` dengan argumen bukan JSON valid: {e}"
228
+ ) from e
229
+ if not isinstance(arguments, dict):
230
+ raise LLMError(
231
+ f"โŒ Argumen tool_call `{name}` harus object JSON, dapat: {type(arguments).__name__}"
232
+ )
233
+ calls.append(ToolCallRequest(id=slot["id"] or f"call_{index}", name=name, arguments=arguments))
234
+ return StreamDone(text="".join(self._text_parts), tool_calls=calls)
235
+
236
+
237
+ # โ”€โ”€ Self-test (tanpa API key) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
238
+
239
+ def _fake_chunk(text: str = "", tool_deltas: list[dict] | None = None) -> dict:
240
+ return {
241
+ "choices": [
242
+ {"delta": {"content": text, "tool_calls": tool_deltas or []}},
243
+ ]
244
+ }
245
+
246
+
247
+ async def _self_test() -> None:
248
+ # 1. Teks murni
249
+ acc = _StreamAccumulator()
250
+ events: list[StreamEvent] = []
251
+ for ch in [_fake_chunk("Halo, "), _fake_chunk("dunia!")]:
252
+ events += acc.feed(ch)
253
+ done = acc.done()
254
+ assert [e.content for e in events] == ["Halo, ", "dunia!"], events
255
+ assert done.text == "Halo, dunia!" and done.tool_calls == []
256
+
257
+ # 2. Tool call terpecah jadi fragment (kasus nyata streaming)
258
+ acc = _StreamAccumulator()
259
+ acc.feed(_fake_chunk("bentar, ", [{"index": 0, "id": "call_1",
260
+ "function": {"name": "read_file", "arguments": '{"path": "ma'}}]))
261
+ acc.feed(_fake_chunk("", [{"index": 0, "function": {"arguments": 'in.py"}'}}]))
262
+ acc.feed(_fake_chunk("", [{"index": 1, "id": "call_2",
263
+ "function": {"name": "bash", "arguments": '{"command": "ls"}'}}]))
264
+ done = acc.done()
265
+ assert done.text == "bentar, ", done.text
266
+ assert len(done.tool_calls) == 2, done.tool_calls
267
+ assert done.tool_calls[0].name == "read_file"
268
+ assert done.tool_calls[0].arguments == {"path": "main.py"}, done.tool_calls[0].arguments
269
+ assert done.tool_calls[1].name == "bash"
270
+
271
+ # 3. Argumen bukan JSON โ†’ LLMError (bukan crash)
272
+ acc = _StreamAccumulator()
273
+ acc.feed(_fake_chunk("", [{"index": 0, "function": {"name": "x", "arguments": "{oops"}}]))
274
+ try:
275
+ acc.done()
276
+ except LLMError:
277
+ pass
278
+ else:
279
+ raise AssertionError("argumen invalid harus raise LLMError")
280
+
281
+ # 4. Error mapping: auth, not-found, retry koneksi 3x
282
+ from unittest.mock import patch
283
+
284
+ cfg = Config(model="openai/gpt-4o", api_key="sk-bad")
285
+ client = LLMClient(cfg)
286
+
287
+ async def _drain(c: LLMClient) -> StreamDone:
288
+ async for ev in c.stream_completion([{"role": "user", "content": "hi"}]):
289
+ if isinstance(ev, StreamDone):
290
+ return ev
291
+ raise AssertionError("unreachable")
292
+
293
+ with patch("core.llm_client.litellm.acompletion", side_effect=AuthenticationError("bad", "x", "x")):
294
+ try:
295
+ await _drain(client)
296
+ except LLMError as e:
297
+ assert "API key" in str(e), e
298
+ else:
299
+ raise AssertionError("auth error harus jadi LLMError")
300
+
301
+ with patch("core.llm_client.litellm.acompletion", side_effect=NotFoundError("nf", "x", "x")):
302
+ try:
303
+ await _drain(client)
304
+ except LLMError as e:
305
+ assert "tidak ditemukan" in str(e), e
306
+ else:
307
+ raise AssertionError("not-found harus jadi LLMError")
308
+
309
+ calls = {"n": 0}
310
+
311
+ async def _flaky(*a: Any, **k: Any) -> Any:
312
+ calls["n"] += 1
313
+ raise APIConnectionError("down", "x", "x")
314
+
315
+ with (patch("core.llm_client.litellm.acompletion", side_effect=_flaky),
316
+ patch("core.llm_client.asyncio.sleep", return_value=None)):
317
+ try:
318
+ await _drain(client)
319
+ except LLMError as e:
320
+ assert "Gagal konek" in str(e), e
321
+ else:
322
+ raise AssertionError("conn error harus jadi LLMError")
323
+ assert calls["n"] == MAX_CONNECTION_RETRIES + 1, calls
324
+
325
+ print("โœ… llm_client self-test OK (akumulasi + error mapping + retry)")
326
+
327
+
328
+ async def _live_test(prompt: str) -> None:
329
+ cfg = load_config()
330
+ client = setup_client(cfg)
331
+ print(f"model: {cfg.model} โ€” streaming...\n---")
332
+ async for event in client.stream_completion([{"role": "user", "content": prompt}]):
333
+ if isinstance(event, StreamText):
334
+ print(event.content, end="", flush=True)
335
+ elif isinstance(event, StreamDone):
336
+ print(f"\n---\nโœ… done. tool_calls={len(event.tool_calls)}")
337
+
338
+
339
+ if __name__ == "__main__":
340
+ parser = argparse.ArgumentParser()
341
+ parser.add_argument("--live", metavar="PROMPT", default=None,
342
+ help="test live ke provider (butuh config valid)")
343
+ args = parser.parse_args()
344
+ if args.live:
345
+ asyncio.run(_live_test(args.live))
346
+ else:
347
+ asyncio.run(_self_test())
core/mode_manager.py ADDED
@@ -0,0 +1,342 @@
1
+ """Mode manager โ€” mode agent + parse slash command.
2
+
3
+ Mode aktif:
4
+ code โ†’ Coding Agent (penuh di Phase 2)
5
+ research โ†’ Research Agent (penuh di Phase 3)
6
+ personal โ†’ Personal Agent (penuh di Phase 4)
7
+
8
+ Command yang dikenal (lihat HELP_TEXT):
9
+ /code /research /personal /clear /scan /model [nama] /help /soul
10
+
11
+ Command di-intercept sebelum LLM (lihat core/agent_loop.py):
12
+ tidak masuk history, tidak panggil LLM.
13
+
14
+ Test cepat:
15
+ python -m core.mode_manager
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ from core.config import Config
26
+
27
+ MODE_CODE = "code"
28
+ MODE_RESEARCH = "research"
29
+ MODE_PERSONAL = "personal"
30
+
31
+ HELP_TEXT = (
32
+ "๐Ÿ“– Command multacd:\n"
33
+ " /code โ†’ mode Coding Agent (default)\n"
34
+ " /research โ†’ mode Research Agent (riset internet + sumber)\n"
35
+ " /personal โ†’ Personal Agent (bot, jadwal, briefing, daemon)\n"
36
+ " /clear โ†’ bersihkan history, mulai sesi baru\n"
37
+ " /scan โ†’ scan ulang codebase project\n"
38
+ " /model [nama] โ†’ lihat / ganti model (cth: /model openai/gpt-4o)\n"
39
+ " /soul โ†’ tampilkan kepribadian agent yang aktif\n"
40
+ " /help โ†’ tampilkan pesan ini"
41
+ )
42
+
43
+ # Single source buat slash palette (nama + deskripsi singkat).
44
+ PALETTE_COMMANDS: tuple[tuple[str, str], ...] = (
45
+ ("/code", "Switch to Coding Agent mode"),
46
+ ("/research", "Switch to Research Agent mode"),
47
+ ("/personal", "Switch to Personal Agent mode"),
48
+ ("/clear", "Clear conversation history"),
49
+ ("/scan", "Re-scan codebase"),
50
+ ("/model", "Switch LLM model"),
51
+ ("/soul", "Show active soul.md"),
52
+ ("/help", "Show all commands"),
53
+ )
54
+
55
+ # Command yang butuh argumen lanjutan โ†’ Enter = autocomplete, bukan submit.
56
+ COMMANDS_WITH_ARGS = frozenset({"/model"})
57
+
58
+ MODE_PROMPTS = {
59
+ MODE_CODE: (
60
+ "Kamu sedang dalam Coding Agent mode. "
61
+ "Kamu punya akses ke: filesystem, shell, git, "
62
+ "run_python, lint_python, run_tests. "
63
+ "Fokus membantu user dengan kode dan development workflow. "
64
+ "Smart commit flow: user minta commit โ†’ jalankan git_status + git_diff dulu, "
65
+ "tulis pesan conventional commits (feat/fix/refactor/docs/chore), "
66
+ "lalu git_add + git_commit (dialog konfirmasi = persetujuan user). "
67
+ "Jangan push ke branch main/master/production/prod/release/stable "
68
+ "tanpa izin eksplisit โ€” kalau tool menolak, tawarkan ke user: "
69
+ "tetap push, buat branch baru, atau batal. "
70
+ "Kalau merge conflict: analisis ours-vs-theirs, suggest resolusi, "
71
+ "apply via edit_file + git_add hanya setelah user setuju. "
72
+ "Kalau run_python/lint_python/run_tests gagal atau error: baca output-nya, "
73
+ "jelaskan penyebab ke user, suggest fix โ€” jangan auto-fix tanpa izin."
74
+ ),
75
+ MODE_RESEARCH: (
76
+ "Kamu sedang dalam Research Agent mode. "
77
+ "Kamu punya akses ke: web_search, web_scrape, quick_research, "
78
+ "deep_research, export_research. "
79
+ "Panduan: untuk pertanyaan butuh jawaban cepat dengan sumber โ†’ "
80
+ "quick_research; untuk topik kompleks butuh laporan mendalam โ†’ "
81
+ "deep_research. Selalu kutip sumber dalam jawaban. "
82
+ "Kalau scraping gagal, gunakan snippet dan beritahu user. "
83
+ "Tawarkan export ke .md setelah research selesai. "
84
+ "Deteksi dan flag informasi yang kontradiktif antar sumber."
85
+ ),
86
+ MODE_PERSONAL: (
87
+ "Kamu sedang dalam Personal Agent mode. "
88
+ "Kamu punya akses ke: send_telegram, schedule_job, cancel_job, "
89
+ "get_jobs, daemon_status, user_manager, generate_briefing, "
90
+ "web_search, quick_research, read tools, git_status, remember, "
91
+ "recall. "
92
+ "Tugasmu: kelola bot Telegram (tambah/hapus user), kelola scheduled "
93
+ "jobs, cek status daemon, buat & kirim briefing, kirim pesan manual. "
94
+ "Aturan: aksi berdampak (tambah/hapus user, schedule/cancel, kirim "
95
+ "pesan/file, generate briefing) selalu konfirmasi dulu โ€” tool sudah "
96
+ "ASK, hormati jawaban user. "
97
+ "'Kirim briefing sekarang' = konfirmasi, lalu generate_briefing "
98
+ "(send_to_admin=True kalau user mau dikirim ke Telegram). "
99
+ "Jangan panggil tool yang tidak ada di daftar."
100
+ ),
101
+ }
102
+
103
+ # Tool aktif di mode /personal: personal tools + baca + riset ringan.
104
+ # Tulis/shell/eksekusi TIDAK ada โ€” mode ini dashboard manajemen, aman.
105
+ PERSONAL_MODE_TOOLS = [
106
+ "send_telegram",
107
+ "schedule_job",
108
+ "cancel_job",
109
+ "get_jobs",
110
+ "daemon_status",
111
+ "user_manager",
112
+ "generate_briefing",
113
+ "web_search",
114
+ "quick_research",
115
+ "read_file",
116
+ "read_many_files",
117
+ "glob",
118
+ "grep",
119
+ "list_dir",
120
+ "scan_codebase",
121
+ "git_status",
122
+ "remember",
123
+ "recall",
124
+ ]
125
+
126
+ # Tool aktif di mode /research: research tools + read tools Phase 1
127
+ # (web_fetch, read_file, dll tetap aktif โ€” lihat PLAN-phase3 ยง10).
128
+ RESEARCH_MODE_TOOLS = [
129
+ "web_search",
130
+ "web_scrape",
131
+ "quick_research",
132
+ "deep_research",
133
+ "export_research",
134
+ "read_file",
135
+ "read_many_files",
136
+ "glob",
137
+ "grep",
138
+ "list_dir",
139
+ "scan_codebase",
140
+ "web_fetch",
141
+ ]
142
+
143
+
144
+ @dataclass
145
+ class CommandResult:
146
+ """Hasil parse slash command.
147
+
148
+ is_command False โ†’ bukan command, teruskan ke LLM seperti biasa.
149
+ action: "clear" | "scan" | "mode" | None โ€” aksi UI yang harus
150
+ dilakukan caller (agent_loop handle clear; TUI sync mode/model).
151
+ """
152
+
153
+ is_command: bool
154
+ message: str = ""
155
+ action: str | None = None
156
+
157
+
158
+ class ModeManager:
159
+ """Pegang mode aktif + parse /command. Default: /code.
160
+
161
+ `rescan_fn`: callback () -> str pesan display; dipasang app TUI
162
+ agar /scan refresh project context + composer (lihat tui/app.py).
163
+ Tanpa itu, /scan cuma return sinyal tanpa eksekusi.
164
+ """
165
+
166
+ def __init__(self, config: Config | None = None, soul: str = "",
167
+ rescan_fn: Callable[[], str] | None = None) -> None:
168
+ self._mode = MODE_CODE
169
+ self._config = config
170
+ self._soul = soul
171
+ self._rescan_fn = rescan_fn
172
+ self._git_enabled = True
173
+
174
+ def set_git_enabled(self, enabled: bool) -> None:
175
+ """Matikan kalau folder bukan git repo โ†’ tool git disembunyikan."""
176
+ self._git_enabled = enabled
177
+
178
+ def get_mode(self) -> str:
179
+ return self._mode
180
+
181
+ def set_soul(self, soul: str) -> None:
182
+ self._soul = soul
183
+
184
+ def set_mode(self, mode: str) -> str:
185
+ if mode not in (MODE_CODE, MODE_RESEARCH, MODE_PERSONAL):
186
+ return f"โ“ Mode tidak dikenal: {mode}. Pilihan: /code /research /personal"
187
+ self._mode = mode
188
+ return f"โœ… Mode: {mode}"
189
+
190
+ def get_mode_prompt(self) -> str:
191
+ return MODE_PROMPTS[self._mode]
192
+
193
+ def get_active_tools(self) -> list[str] | None:
194
+ """None = semua tool aktif. Bukan git repo โ†’ list tanpa tool git.
195
+
196
+ Mode /research โ†’ list eksplisit (research + read tools).
197
+ """
198
+ if self._mode == MODE_RESEARCH:
199
+ return list(RESEARCH_MODE_TOOLS)
200
+ if self._mode == MODE_PERSONAL:
201
+ return list(PERSONAL_MODE_TOOLS)
202
+ if self._git_enabled:
203
+ return None
204
+ from core.permissions import GIT_TOOLS, KNOWN_TOOLS
205
+ return sorted(KNOWN_TOOLS - GIT_TOOLS)
206
+
207
+ @staticmethod
208
+ def is_command(text: str) -> bool:
209
+ return text.strip().startswith("/")
210
+
211
+ def handle_command(self, user_input: str) -> CommandResult:
212
+ text = user_input.strip()
213
+ if not text.startswith("/"):
214
+ return CommandResult(is_command=False)
215
+ parts = text.split(None, 1)
216
+ cmd = parts[0].lower()
217
+ arg = parts[1].strip() if len(parts) > 1 else ""
218
+
219
+ if cmd == "/code":
220
+ return CommandResult(True, self.set_mode(MODE_CODE), "mode")
221
+ if cmd == "/research":
222
+ msg = self.set_mode(MODE_RESEARCH)
223
+ return CommandResult(
224
+ True,
225
+ f"{msg} โ€” tanya apa saja, jawaban berkutipan sumber. "
226
+ "Ctrl+R panel sumber.",
227
+ "mode")
228
+ if cmd == "/personal":
229
+ msg = self.set_mode(MODE_PERSONAL)
230
+ try:
231
+ from tools.personal.daemon_status import daemon_status
232
+ snapshot = daemon_status()["result"]
233
+ except Exception as e:
234
+ snapshot = f"(status daemon gagal dibaca: {e})"
235
+ return CommandResult(
236
+ True,
237
+ f"{msg} ๐Ÿ  โ€” kelola bot, jadwal, briefing, daemon.\n"
238
+ f"{snapshot}",
239
+ "mode")
240
+ if cmd == "/clear":
241
+ return CommandResult(True, "๐Ÿงน History dibersihkan โ€” mulai sesi baru.", "clear")
242
+ if cmd == "/scan":
243
+ if self._rescan_fn is None:
244
+ return CommandResult(True, "๐Ÿ” Scan ulang belum wiring (jalan di TUI).", "scan")
245
+ try:
246
+ return CommandResult(True, self._rescan_fn(), "scan")
247
+ except Exception as e:
248
+ return CommandResult(True, f"โŒ Scan gagal ({type(e).__name__}): {e}", None)
249
+ if cmd == "/model":
250
+ if not arg:
251
+ current = self._config.model if self._config else "?"
252
+ return CommandResult(True, f"๐Ÿค– Model aktif: {current}", None)
253
+ if self._config is None:
254
+ return CommandResult(True, "โŒ Config tidak tersedia โ€” model tidak bisa diganti.", None)
255
+ if not arg.strip():
256
+ return CommandResult(True, "โŒ Nama model tidak boleh kosong.", None)
257
+ self._config.model = arg.strip()
258
+ return CommandResult(True, f"โœ… Model diganti ke: {self._config.model}", "model")
259
+ if cmd == "/help":
260
+ return CommandResult(True, HELP_TEXT, None)
261
+ if cmd == "/soul":
262
+ return CommandResult(True, self._soul or "(soul kosong)", None)
263
+ return CommandResult(True, f"โ“ Command tidak dikenal: {parts[0]}. Ketik /help.", None)
264
+
265
+
266
+ if __name__ == "__main__":
267
+ from core.config import Config as _Config
268
+
269
+ cfg = _Config(model="m", api_key="k")
270
+ mm = ModeManager(config=cfg, soul="soul-test")
271
+
272
+ # 1. Default mode code + prompt-nya
273
+ assert mm.get_mode() == "code"
274
+ assert "Coding Agent" in mm.get_mode_prompt()
275
+ assert "conventional" in mm.get_mode_prompt() # smart commit flow
276
+ assert mm.get_active_tools() is None # semua tool aktif
277
+ mm.set_git_enabled(False)
278
+ active = mm.get_active_tools()
279
+ assert active is not None and not any(t.startswith("git_") for t in active), active
280
+ assert "read_file" in active and "bash" in active
281
+ mm.set_git_enabled(True)
282
+ assert mm.get_active_tools() is None
283
+
284
+ # 2. Bukan command โ†’ teruskan ke LLM
285
+ r = mm.handle_command("halo, apa kabar?")
286
+ assert r.is_command is False, r
287
+
288
+ # 3. /code (case-insensitive) + /help + /soul
289
+ assert mm.handle_command("/CODE").message == "โœ… Mode: code"
290
+ assert "/scan" in mm.handle_command("/help").message
291
+ assert mm.handle_command("/soul").message == "soul-test"
292
+
293
+ # 4. /research aktif (Phase 3); /personal aktif (Phase 4)
294
+ r = mm.handle_command("/research")
295
+ assert mm.get_mode() == "research" and r.action == "mode", (r, mm.get_mode())
296
+ assert "quick_research" in mm.get_mode_prompt()
297
+ active = mm.get_active_tools()
298
+ assert active is not None and "quick_research" in active, active
299
+ assert "deep_research" in active and "web_search" in active
300
+ assert "read_file" in active and "web_fetch" in active
301
+ assert "git_commit" not in active and "bash" not in active
302
+ mm.handle_command("/code")
303
+ assert mm.get_mode() == "code" and mm.get_active_tools() is None
304
+ r = mm.handle_command("/personal")
305
+ assert mm.get_mode() == "personal" and r.action == "mode", (r, mm.get_mode())
306
+ assert "Daemon:" in r.message, r.message # snapshot status ikut
307
+ assert "Personal Agent" in mm.get_mode_prompt()
308
+ active = mm.get_active_tools()
309
+ assert active is not None and "daemon_status" in active, active
310
+ assert "user_manager" in active and "generate_briefing" in active
311
+ assert "get_jobs" in active and "send_telegram" in active
312
+ assert "bash" not in active and "write_file" not in active # aman
313
+ assert "delete_file" not in active and "run_python" not in active
314
+ mm.handle_command("/code")
315
+ assert mm.get_mode() == "code"
316
+
317
+ # 5. /clear dan /scan kasih action signal
318
+ assert mm.handle_command("/clear").action == "clear"
319
+ assert mm.handle_command("/scan").action == "scan"
320
+
321
+ # 6. /model lihat + ganti
322
+ assert "m" in mm.handle_command("/model").message
323
+ r = mm.handle_command("/model openai/gpt-4o")
324
+ assert cfg.model == "openai/gpt-4o" and r.action == "model", (r, cfg.model)
325
+
326
+ # 7. Unknown command + "/" kosong tidak crash
327
+ assert "tidak dikenal" in mm.handle_command("/ngawur").message
328
+ assert "tidak dikenal" in mm.handle_command("/").message
329
+
330
+ # 8. Tanpa config: /model X ditolak dengan sopan
331
+ mm2 = ModeManager()
332
+ assert "tidak tersedia" in mm2.handle_command("/model x").message
333
+
334
+ # 9. /scan tanpa callback โ†’ sinyal; dengan callback โ†’ pesan display
335
+ assert mm2.handle_command("/scan").action == "scan"
336
+ mm3 = ModeManager(rescan_fn=lambda: "๐Ÿ” demo ยท Python โ€” context diperbarui.")
337
+ r = mm3.handle_command("/scan")
338
+ assert r.action == "scan" and "demo" in r.message, r
339
+ mm4 = ModeManager(rescan_fn=lambda: (_ for _ in ()).throw(RuntimeError("disk")))
340
+ assert "gagal" in mm4.handle_command("/scan").message.lower()
341
+
342
+ print("โœ… mode_manager self-test OK (9 skenario + research aktif)")