remote-coder 0.4.1__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 (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from threading import Lock
5
+
6
+ from app.models import ModelName
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ModelPreference:
11
+ provider: ModelName
12
+ model_id: str | None = None
13
+
14
+
15
+ class InMemoryModelPreferenceStore:
16
+ def __init__(self, default_model: ModelName) -> None:
17
+ self._default_model = default_model
18
+ self._values: dict[tuple[str | None, int], ModelPreference] = {}
19
+ self._lock = Lock()
20
+
21
+ def get(self, project_name: str | None, chat_id: int) -> ModelName:
22
+ with self._lock:
23
+ selection = self._values.get((project_name, chat_id))
24
+ return selection.provider if selection is not None else self._default_model
25
+
26
+ def get_explicit(self, project_name: str | None, chat_id: int) -> ModelName | None:
27
+ with self._lock:
28
+ selection = self._values.get((project_name, chat_id))
29
+ return selection.provider if selection is not None else None
30
+
31
+ def set(self, project_name: str | None, chat_id: int, model: ModelName) -> None:
32
+ self.set_selection(project_name, chat_id, ModelPreference(model))
33
+
34
+ def get_explicit_selection(
35
+ self,
36
+ project_name: str | None,
37
+ chat_id: int,
38
+ ) -> ModelPreference | None:
39
+ with self._lock:
40
+ return self._values.get((project_name, chat_id))
41
+
42
+ def set_selection(
43
+ self,
44
+ project_name: str | None,
45
+ chat_id: int,
46
+ selection: ModelPreference,
47
+ ) -> None:
48
+ with self._lock:
49
+ self._values[(project_name, chat_id)] = selection
50
+
51
+ def clear(self, project_name: str | None, chat_id: int) -> None:
52
+ with self._lock:
53
+ self._values.pop((project_name, chat_id), None)
@@ -0,0 +1,387 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Protocol
6
+
7
+ import httpx
8
+
9
+ from app.ai.model_catalog import format_model_selection
10
+ from app.ai.usage import format_token_usage
11
+ from app.jobs.schemas import Job, JobMode
12
+ from app.monitoring.events import EventLogger
13
+ from app.telegram.i18n import language_from_settings_store, translate_button_label, translate_text, ui_message
14
+
15
+ _outbound = EventLogger("app.telegram.outbound", "telegram.outbound")
16
+
17
+
18
+ class Notifier(Protocol):
19
+ def send_text(self, chat_id: int, text: str, *, skip_body_i18n: bool = False) -> int | None: ...
20
+
21
+ def send_with_buttons(
22
+ self,
23
+ chat_id: int,
24
+ text: str,
25
+ inline_buttons: list,
26
+ *,
27
+ skip_body_i18n: bool = False,
28
+ ) -> int | None: ...
29
+
30
+ def answer_callback_query(self, callback_query_id: str) -> None: ...
31
+
32
+ def send_job_accepted(self, job: Job) -> int | None: ...
33
+
34
+ def send_job_result(self, job: Job) -> list[int]: ...
35
+
36
+ def send_long_text(self, chat_id: int, text: str) -> list[int]: ...
37
+
38
+
39
+ @dataclass
40
+ class _OutboundButton:
41
+ label: str
42
+ callback_data: str
43
+
44
+
45
+ def build_job_accepted_message(job: Job) -> tuple[str, list[list[_OutboundButton]]]:
46
+ mode_line = ""
47
+ if job.request.mode is JobMode.PLAN:
48
+ mode_line = ui_message("job.mode_line", "\n- Mode: {mode}", mode="plan")
49
+ elif job.request.mode is JobMode.ASK:
50
+ mode_line = ui_message("job.mode_line", "\n- Mode: {mode}", mode="ask")
51
+ text = ui_message(
52
+ "job.accepted",
53
+ "✅ Job accepted\n\n"
54
+ "- Job ID: {job_id}\n"
55
+ "- Project: {project}\n"
56
+ "- Model: {model}{mode_line}",
57
+ job_id=job.id,
58
+ project=job.request.project,
59
+ model=format_model_selection(job.request.model, job.request.model_id),
60
+ mode_line=mode_line,
61
+ )
62
+ buttons = [[_OutboundButton(ui_message("job.stop_button", "Stop job"), f"/stop {job.id}")]]
63
+ return text, buttons
64
+
65
+
66
+ def _ui_response_block(summary: str | None) -> str:
67
+ if not summary:
68
+ return ""
69
+ return ui_message("job.response_block", "\n\nAI response:\n{summary}", summary=summary)
70
+
71
+
72
+ def _ui_failure_details(job: Job) -> str:
73
+ details: list[str] = []
74
+ if job.error_stage:
75
+ details.append(
76
+ ui_message("job.failure_detail_stage", "\n- Failure stage: {stage}", stage=job.error_stage)
77
+ )
78
+ if job.log_path:
79
+ details.append(
80
+ ui_message("job.failure_detail_log_path", "\n- Log path: {log_path}", log_path=job.log_path)
81
+ )
82
+ return "".join(details)
83
+
84
+
85
+ def _ui_failure_block(summary: str | None) -> str:
86
+ if not summary:
87
+ return ""
88
+ return ui_message("job.failure_block", "\n\nFailure output summary:\n{summary}", summary=summary)
89
+
90
+
91
+ def _ui_token_usage(job: Job) -> str:
92
+ return format_token_usage(job.runner_token_usage) or ui_message(
93
+ "common.unavailable",
94
+ "unavailable",
95
+ )
96
+
97
+
98
+ def build_job_result_message(job: Job) -> str:
99
+ mode_prefix = ""
100
+ if job.request.mode is JobMode.PLAN:
101
+ mode_prefix = "[plan] "
102
+ elif job.request.mode is JobMode.ASK:
103
+ mode_prefix = "[ask] "
104
+
105
+ if job.status.value == "cancelled":
106
+ return ui_message(
107
+ "job.cancelled",
108
+ "{mode_prefix}⛔ Job cancelled\n\n- Job ID: {job_id}\n- Project: {project}",
109
+ mode_prefix=mode_prefix,
110
+ job_id=job.id,
111
+ project=job.request.project,
112
+ )
113
+
114
+ if job.status.value == "succeeded":
115
+ if job.request.mode in (JobMode.PLAN, JobMode.ASK):
116
+ label = "plan" if job.request.mode is JobMode.PLAN else "ask"
117
+ model_label = job.runner_actual_model or format_model_selection(
118
+ job.request.model,
119
+ job.request.model_id,
120
+ )
121
+ return ui_message(
122
+ "job.readonly_completed",
123
+ "[{mode}] Completed\n\n"
124
+ "- Job ID: {job_id}\n"
125
+ "- Project: {project}\n"
126
+ "- Model used: {model}\n"
127
+ "- Token usage: {token_usage}{response_block}",
128
+ mode=label,
129
+ job_id=job.id,
130
+ project=job.request.project,
131
+ model=model_label,
132
+ token_usage=_ui_token_usage(job),
133
+ response_block=_ui_response_block(job.runner_stdout_summary),
134
+ )
135
+
136
+ changed = ", ".join(job.changed_files) if job.changed_files else ui_message(
137
+ "job.no_changes",
138
+ "No changes",
139
+ )
140
+ branch_line = job.branch if job.branch else ui_message(
141
+ "job.branch_none_no_changes",
142
+ "(none - no branch; no changes)",
143
+ )
144
+ commit_line = job.commit_hash or "-"
145
+ if job.changed_files and not job.request.commit:
146
+ commit_line = ui_message("job.no_commit_skipped", "(no commit - commit/push skipped)")
147
+ elif job.changed_files and job.request.commit and not job.commit_hash:
148
+ commit_line = ui_message("job.nothing_staged_skipped", "(nothing staged - push skipped)")
149
+ model_label = job.runner_actual_model or format_model_selection(
150
+ job.request.model,
151
+ job.request.model_id,
152
+ )
153
+ return ui_message(
154
+ "job.completed",
155
+ "✅ Job completed\n\n"
156
+ "- Job ID: {job_id}\n"
157
+ "- Project: {project}\n"
158
+ "- Branch: {branch}\n"
159
+ "- Commit: {commit}\n"
160
+ "- Changed files: {changed}\n"
161
+ "- Model used: {model}\n"
162
+ "- Token usage: {token_usage}{response_block}",
163
+ job_id=job.id,
164
+ project=job.request.project,
165
+ branch=branch_line,
166
+ commit=commit_line,
167
+ changed=changed,
168
+ model=model_label,
169
+ token_usage=_ui_token_usage(job),
170
+ response_block=_ui_response_block(job.runner_stdout_summary),
171
+ )
172
+
173
+ failure_summary = job.runner_stderr_summary or job.runner_stdout_summary
174
+ return ui_message(
175
+ "job.failed",
176
+ "{mode_prefix}❌ Job failed\n\n"
177
+ "- Job ID: {job_id}\n"
178
+ "- Project: {project}\n"
179
+ "- Error: {error}{details}{failure_block}",
180
+ mode_prefix=mode_prefix,
181
+ job_id=job.id,
182
+ project=job.request.project,
183
+ error=job.error or "unknown error",
184
+ details=_ui_failure_details(job),
185
+ failure_block=_ui_failure_block(failure_summary),
186
+ )
187
+
188
+
189
+ class TelegramNotifier:
190
+ _TELEGRAM_TEXT_LIMIT = 4096
191
+ _MAX_ATTEMPTS = 3
192
+
193
+ def __init__(self, bot_token: str, advanced_settings_store=None) -> None:
194
+ self._api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
195
+ self._callback_answer_url = f"https://api.telegram.org/bot{bot_token}/answerCallbackQuery"
196
+ self._advanced_settings_store = advanced_settings_store
197
+
198
+ @property
199
+ def _language(self):
200
+ return language_from_settings_store(self._advanced_settings_store)
201
+
202
+ @staticmethod
203
+ def _extract_message_id(response: httpx.Response) -> int | None:
204
+ try:
205
+ data = response.json()
206
+ except ValueError:
207
+ return None
208
+ result = data.get("result") if isinstance(data, dict) else None
209
+ message_id = result.get("message_id") if isinstance(result, dict) else None
210
+ return int(message_id) if message_id is not None else None
211
+
212
+ def _post_with_retry(
213
+ self,
214
+ url: str,
215
+ payload: dict,
216
+ *,
217
+ log_label: str,
218
+ chat_id: int | None = None,
219
+ ) -> httpx.Response | None:
220
+ for attempt in range(1, self._MAX_ATTEMPTS + 1):
221
+ try:
222
+ response = httpx.post(url, json=payload, timeout=httpx.Timeout(10.0, connect=5.0))
223
+ response.raise_for_status()
224
+ return response
225
+ except httpx.HTTPError as exc:
226
+ _outbound.warning(
227
+ "%s attempt failed attempt=%d/%d err=%s",
228
+ log_label,
229
+ attempt,
230
+ self._MAX_ATTEMPTS,
231
+ type(exc).__name__,
232
+ chat_id=chat_id,
233
+ )
234
+ if attempt == self._MAX_ATTEMPTS:
235
+ _outbound.warning(
236
+ "%s failed after %d attempts: %s",
237
+ log_label,
238
+ self._MAX_ATTEMPTS,
239
+ type(exc).__name__,
240
+ chat_id=chat_id,
241
+ )
242
+ return None
243
+ time.sleep(attempt)
244
+ return None
245
+
246
+ def _post_message(self, chat_id: int, text: str) -> int | None:
247
+ _outbound.info("sendMessage start len=%d", len(text), chat_id=chat_id)
248
+ response = self._post_with_retry(
249
+ self._api_url,
250
+ {"chat_id": chat_id, "text": text},
251
+ log_label="sendMessage",
252
+ chat_id=chat_id,
253
+ )
254
+ if response is None:
255
+ return None
256
+ _outbound.info("sent text len=%d status=%d", len(text), response.status_code, chat_id=chat_id)
257
+ return self._extract_message_id(response)
258
+
259
+ def send_text(self, chat_id: int, text: str, *, skip_body_i18n: bool = False) -> int | None:
260
+ out = text if skip_body_i18n else translate_text(text, self._language)
261
+ return self._post_message(chat_id, out)
262
+
263
+ def send_with_buttons(
264
+ self,
265
+ chat_id: int,
266
+ text: str,
267
+ inline_buttons: list,
268
+ *,
269
+ skip_body_i18n: bool = False,
270
+ ) -> int | None:
271
+ language = self._language
272
+ out_text = text if skip_body_i18n else translate_text(text, language)
273
+ keyboard = [
274
+ [
275
+ {"text": translate_button_label(btn.label, language), "callback_data": btn.callback_data}
276
+ for btn in row
277
+ ]
278
+ for row in inline_buttons
279
+ ]
280
+ payload = {
281
+ "chat_id": chat_id,
282
+ "text": out_text,
283
+ "reply_markup": {"inline_keyboard": keyboard},
284
+ }
285
+ button_count = sum(len(row) for row in inline_buttons)
286
+ _outbound.info(
287
+ "sendMessage buttons start len=%d rows=%d buttons=%d",
288
+ len(out_text),
289
+ len(inline_buttons),
290
+ button_count,
291
+ chat_id=chat_id,
292
+ )
293
+ response = self._post_with_retry(
294
+ self._api_url,
295
+ payload,
296
+ log_label="sendMessage (buttons)",
297
+ chat_id=chat_id,
298
+ )
299
+ if response is None:
300
+ return None
301
+ _outbound.info(
302
+ "sent message with buttons len=%d status=%d",
303
+ len(out_text),
304
+ response.status_code,
305
+ chat_id=chat_id,
306
+ )
307
+ return self._extract_message_id(response)
308
+
309
+ def answer_callback_query(self, callback_query_id: str) -> None:
310
+ _outbound.info("answerCallbackQuery start")
311
+ response = self._post_with_retry(
312
+ self._callback_answer_url,
313
+ {"callback_query_id": callback_query_id},
314
+ log_label="answerCallbackQuery",
315
+ )
316
+ if response is not None:
317
+ _outbound.info("answerCallbackQuery sent status=%d", response.status_code)
318
+
319
+ def send_job_accepted(self, job: Job) -> int | None:
320
+ _outbound.info(
321
+ "notify job accepted",
322
+ chat_id=job.request.chat_id,
323
+ job_id=job.id,
324
+ project=job.request.project,
325
+ )
326
+ text, buttons = build_job_accepted_message(job)
327
+ return self.send_with_buttons(job.request.chat_id, text, buttons)
328
+
329
+ def send_job_result(self, job: Job) -> list[int]:
330
+ _outbound.info(
331
+ "notify job result status=%s changed_files=%d",
332
+ job.status.value,
333
+ len(job.changed_files),
334
+ chat_id=job.request.chat_id,
335
+ job_id=job.id,
336
+ project=job.request.project,
337
+ )
338
+ return self.send_long_text(job.request.chat_id, build_job_result_message(job))
339
+
340
+ def send_long_text(self, chat_id: int, text: str) -> list[int]:
341
+ """Split text across Telegram messages when it exceeds the 4096-character limit."""
342
+ outgoing = translate_text(text, self._language)
343
+ chunks = self._chunk_text(outgoing, self._TELEGRAM_TEXT_LIMIT)
344
+ _outbound.info(
345
+ "send_long_text chunks=%d total_len=%d",
346
+ len(chunks),
347
+ len(outgoing),
348
+ chat_id=chat_id,
349
+ )
350
+ message_ids: list[int] = []
351
+ for idx, chunk in enumerate(chunks, 1):
352
+ _outbound.info(
353
+ "send_long_text chunk=%d/%d len=%d",
354
+ idx,
355
+ len(chunks),
356
+ len(chunk),
357
+ chat_id=chat_id,
358
+ )
359
+ message_id = self._post_message(chat_id, chunk)
360
+ if message_id is not None:
361
+ message_ids.append(message_id)
362
+ return message_ids
363
+
364
+ @staticmethod
365
+ def _chunk_text(text: str, max_len: int) -> list[str]:
366
+ if max_len <= 0:
367
+ raise ValueError("max_len must be positive")
368
+ if len(text) <= max_len:
369
+ return [text]
370
+ chunks: list[str] = []
371
+ i = 0
372
+ n = len(text)
373
+ min_break = max_len // 2
374
+ while i < n:
375
+ j = min(i + max_len, n)
376
+ if j < n:
377
+ segment = text[i:j]
378
+ cut = segment.rfind("\n")
379
+ if cut >= min_break:
380
+ j = i + cut + 1
381
+ else:
382
+ cut = segment.rfind(" ")
383
+ if cut >= min_break:
384
+ j = i + cut + 1
385
+ chunks.append(text[i:j])
386
+ i = j
387
+ return chunks
app/telegram/parser.py ADDED
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from app.git.service import GitWorktreeService
6
+ from app.jobs.schemas import JobMode, JobRequest
7
+ from app.models import ModelName
8
+ from app.projects.registry import ProjectRegistry
9
+ from app.telegram.conversation import (
10
+ ConversationContextBuilder,
11
+ SQLiteConversationStore,
12
+ is_ambiguous_followup,
13
+ )
14
+ from app.telegram.i18n import (
15
+ command_parse_error_disabled_project,
16
+ command_parse_error_empty_instruction,
17
+ command_parse_error_empty_instruction_plan_ask,
18
+ command_parse_error_no_previous_job_context,
19
+ command_parse_error_unknown_project,
20
+ instruction_frame_labels,
21
+ language_from_settings_store,
22
+ localize_git_branch_validation_message,
23
+ )
24
+ from app.telegram.model_preferences import InMemoryModelPreferenceStore, ModelPreference
25
+
26
+
27
+ class CommandParseError(ValueError):
28
+ pass
29
+
30
+
31
+ _MODEL_OPTION_PATTERN = "|".join(model.value for model in ModelName)
32
+
33
+ _SLASH_PLAN_ASK = re.compile(r"^/(plan|ask)\b\s*", re.IGNORECASE)
34
+ _PREFIX_PLAN_ASK = re.compile(
35
+ r"^(plan|ask|계획|질문)\s*[::]\s*",
36
+ re.IGNORECASE,
37
+ )
38
+ _REPLY_JOB_ID_PATTERN = re.compile(
39
+ r"\bJob ID:\s*`?([A-Za-z0-9_.:-]+)`?",
40
+ re.IGNORECASE,
41
+ )
42
+
43
+
44
+ def _job_mode_from_plan_ask_keyword(key: str) -> JobMode:
45
+ lowered = key.lower()
46
+ if lowered in ("plan", "계획"):
47
+ return JobMode.PLAN
48
+ if lowered in ("ask", "질문"):
49
+ return JobMode.ASK
50
+ raise AssertionError(key)
51
+
52
+
53
+ def _extract_reply_job_id(text: str) -> str | None:
54
+ match = _REPLY_JOB_ID_PATTERN.search(text)
55
+ return match.group(1) if match else None
56
+
57
+
58
+ class CommandParser:
59
+ def __init__(
60
+ self,
61
+ project_registry: ProjectRegistry,
62
+ default_model: ModelName,
63
+ model_preferences: InMemoryModelPreferenceStore | None = None,
64
+ conversation_store: SQLiteConversationStore | None = None,
65
+ conversation_recent_limit: int = 10,
66
+ advanced_settings_store=None,
67
+ ) -> None:
68
+ self._project_registry = project_registry
69
+ self._default_model = default_model
70
+ self._model_preferences = model_preferences
71
+ self._conversation_store = conversation_store
72
+ self._conversation_recent_limit = conversation_recent_limit
73
+ self._advanced_settings_store = advanced_settings_store
74
+
75
+ @staticmethod
76
+ def _extract_options(
77
+ text: str,
78
+ ) -> tuple[ModelName | None, str | None, bool | None, str]:
79
+ remaining = text
80
+ model: ModelName | None = None
81
+ branch: str | None = None
82
+ commit: bool | None = None
83
+
84
+ model_match = re.search(
85
+ rf"\bmodel:\s*({_MODEL_OPTION_PATTERN})\b",
86
+ remaining,
87
+ flags=re.IGNORECASE,
88
+ )
89
+ if model_match:
90
+ model = ModelName(model_match.group(1).lower())
91
+ remaining = re.sub(
92
+ rf"\bmodel:\s*({_MODEL_OPTION_PATTERN})\b",
93
+ "",
94
+ remaining,
95
+ flags=re.IGNORECASE,
96
+ ).strip()
97
+
98
+ branch_match = re.search(r"\bbranch:\s*([A-Za-z0-9._/\-]+)", remaining, flags=re.IGNORECASE)
99
+ if branch_match:
100
+ branch = branch_match.group(1)
101
+ remaining = re.sub(r"\bbranch:\s*([A-Za-z0-9._/\-]+)", "", remaining, flags=re.IGNORECASE).strip()
102
+
103
+ no_commit_match = re.search(r"\bno\s+commit\b", remaining, flags=re.IGNORECASE)
104
+ if no_commit_match:
105
+ commit = False
106
+ remaining = re.sub(r"\bno\s+commit\b", "", remaining, flags=re.IGNORECASE).strip()
107
+
108
+ return model, branch, commit, remaining
109
+
110
+ @staticmethod
111
+ def _strip_leading_job_mode(text: str) -> tuple[JobMode, str]:
112
+ stripped = text.strip()
113
+ slash = _SLASH_PLAN_ASK.match(stripped)
114
+ if slash:
115
+ key = slash.group(1).lower()
116
+ mode = JobMode.PLAN if key == "plan" else JobMode.ASK
117
+ return mode, stripped[slash.end() :].strip()
118
+ prefix = _PREFIX_PLAN_ASK.match(stripped)
119
+ if prefix:
120
+ mode = _job_mode_from_plan_ask_keyword(prefix.group(1))
121
+ return mode, stripped[prefix.end() :].strip()
122
+ return JobMode.AGENT, stripped
123
+
124
+ def parse_natural(
125
+ self,
126
+ text: str,
127
+ project_name: str,
128
+ chat_id: int,
129
+ user_id: int | None,
130
+ message_id: int | None = None,
131
+ reply_to_message_id: int | None = None,
132
+ reply_to_text: str | None = None,
133
+ ) -> JobRequest:
134
+ lang = language_from_settings_store(self._advanced_settings_store)
135
+ mode, stripped = self._strip_leading_job_mode(text)
136
+
137
+ model, branch, commit, remaining = self._extract_options(stripped)
138
+ if mode in (JobMode.PLAN, JobMode.ASK):
139
+ branch = None
140
+ commit = False
141
+
142
+ if not remaining:
143
+ if mode in (JobMode.PLAN, JobMode.ASK):
144
+ raise CommandParseError(command_parse_error_empty_instruction_plan_ask(lang))
145
+ raise CommandParseError(command_parse_error_empty_instruction(lang))
146
+
147
+ entry = self._project_registry.get(project_name)
148
+ if not entry:
149
+ raise CommandParseError(command_parse_error_unknown_project(project_name, lang))
150
+ if not entry.enabled:
151
+ raise CommandParseError(command_parse_error_disabled_project(project_name, lang))
152
+
153
+ selected_model: ModelName
154
+ selected_model_id: str | None = None
155
+ if model is not None:
156
+ selected_model = model
157
+ elif self._model_preferences is not None:
158
+ selection = self._model_preferences.get_explicit_selection(project_name, chat_id)
159
+ if selection is None:
160
+ selection = ModelPreference(entry.default_model)
161
+ selected_model = selection.provider
162
+ selected_model_id = selection.model_id
163
+ else:
164
+ selected_model = entry.default_model
165
+
166
+ if (
167
+ mode == JobMode.AGENT
168
+ and branch is None
169
+ and reply_to_message_id is not None
170
+ and self._conversation_store is not None
171
+ ):
172
+ branch = self._conversation_store.get_bound_branch(
173
+ project_name,
174
+ chat_id,
175
+ reply_to_message_id,
176
+ )
177
+
178
+ if branch is not None:
179
+ branch_err = GitWorktreeService.validate_branch_token(branch)
180
+ if branch_err:
181
+ raise CommandParseError(localize_git_branch_validation_message(branch_err, lang))
182
+
183
+ instruction_body = remaining.strip()
184
+ reply_job_id: str | None = None
185
+ if reply_to_message_id is not None and self._conversation_store is not None:
186
+ reply_job_id = self._conversation_store.get_job_id_for_message_id(
187
+ project_name,
188
+ chat_id,
189
+ reply_to_message_id,
190
+ )
191
+ reply_prefix = ""
192
+ if reply_to_message_id is not None and self._conversation_store is not None:
193
+ reply_prefix = self._conversation_store.format_reply_context(
194
+ project_name,
195
+ chat_id,
196
+ reply_to_message_id,
197
+ lang,
198
+ ).strip()
199
+ if not reply_prefix and reply_to_message_id is not None and reply_to_text:
200
+ frame = instruction_frame_labels(lang)
201
+ if self._conversation_store is not None:
202
+ extracted_reply_job_id = _extract_reply_job_id(reply_to_text)
203
+ if extracted_reply_job_id:
204
+ reply_job_id = extracted_reply_job_id
205
+ reply_prefix = self._conversation_store.format_job_context(
206
+ project_name,
207
+ chat_id,
208
+ extracted_reply_job_id,
209
+ lang,
210
+ ).strip()
211
+ if not reply_prefix:
212
+ reply_prefix = "\n".join(
213
+ [
214
+ frame.reply_message_open,
215
+ f"message_id={reply_to_message_id}:",
216
+ f" text: {reply_to_text.strip()}",
217
+ frame.reply_message_close,
218
+ ]
219
+ )
220
+
221
+ chain_message_ids: set[int] = set()
222
+ if reply_to_message_id is not None and self._conversation_store is not None:
223
+ chain_message_ids = self._conversation_store.collect_reply_chain_message_ids(
224
+ project_name,
225
+ chat_id,
226
+ reply_to_message_id,
227
+ )
228
+
229
+ if is_ambiguous_followup(instruction_body) and self._conversation_store is not None:
230
+ entries = self._conversation_store.list_recent(
231
+ project_name,
232
+ chat_id,
233
+ self._conversation_recent_limit,
234
+ )
235
+ filtered = [
236
+ e
237
+ for e in entries
238
+ if e.message_id is None or e.message_id not in chain_message_ids
239
+ ]
240
+ if not filtered:
241
+ if not reply_prefix:
242
+ raise CommandParseError(command_parse_error_no_previous_job_context(lang))
243
+ instruction = f"{reply_prefix}\n\n{instruction_body}".strip()
244
+ else:
245
+ inner = ConversationContextBuilder.build(filtered, instruction_body, lang)
246
+ instruction = f"{reply_prefix}\n\n{inner}".strip() if reply_prefix else inner
247
+ elif reply_prefix:
248
+ instruction = f"{reply_prefix}\n\n{instruction_body}".strip()
249
+ else:
250
+ instruction = instruction_body
251
+
252
+ effective_commit = False if mode in (JobMode.PLAN, JobMode.ASK) else (True if commit is None else commit)
253
+
254
+ return JobRequest(
255
+ project=project_name,
256
+ model=selected_model,
257
+ model_id=selected_model_id,
258
+ instruction=instruction,
259
+ mode=mode,
260
+ branch=branch,
261
+ commit=effective_commit,
262
+ chat_id=chat_id,
263
+ requested_by=user_id,
264
+ message_id=message_id,
265
+ reply_to_message_id=reply_to_message_id,
266
+ job_id=reply_job_id,
267
+ )