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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/ai/__init__.py
ADDED
|
File without changes
|
app/ai/base.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from app.jobs.schemas import JobMode
|
|
11
|
+
from app.monitoring.events import EventLogger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def instruction_for_runner_mode(instruction: str, mode: JobMode) -> str:
|
|
15
|
+
if mode == JobMode.PLAN:
|
|
16
|
+
return (
|
|
17
|
+
"You are in PLAN mode. Read the codebase and produce a concrete change plan. "
|
|
18
|
+
"Do not modify files.\n\n"
|
|
19
|
+
f"User request:\n{instruction}"
|
|
20
|
+
)
|
|
21
|
+
if mode == JobMode.ASK:
|
|
22
|
+
return (
|
|
23
|
+
"You are in ASK mode. Analyze the codebase and answer the user's question. "
|
|
24
|
+
"Do not modify files.\n\n"
|
|
25
|
+
f"User question:\n{instruction}"
|
|
26
|
+
)
|
|
27
|
+
return instruction
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunnerInput:
|
|
32
|
+
instruction: str
|
|
33
|
+
cwd: Path
|
|
34
|
+
timeout_seconds: int
|
|
35
|
+
model_id: str | None = None
|
|
36
|
+
env: dict[str, str] | None = None
|
|
37
|
+
cancel_event: threading.Event | None = field(default=None, compare=False)
|
|
38
|
+
mode: JobMode = JobMode.AGENT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RunnerResult:
|
|
43
|
+
exit_code: int
|
|
44
|
+
stdout: str
|
|
45
|
+
stderr: str
|
|
46
|
+
started_at: datetime
|
|
47
|
+
finished_at: datetime
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AiRunner(ABC):
|
|
51
|
+
name: str
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def run(self, runner_input: RunnerInput) -> RunnerResult:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BaseCliRunner(AiRunner):
|
|
59
|
+
_log: EventLogger
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def build_argv(self, runner_input: RunnerInput) -> list[str]:
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
def _start_log_detail(self, runner_input: RunnerInput) -> str:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
def run(self, runner_input: RunnerInput) -> RunnerResult:
|
|
69
|
+
cwd_name = runner_input.cwd.name
|
|
70
|
+
self._log.info(
|
|
71
|
+
"start cwd=%s timeout=%d%s instruction_len=%d",
|
|
72
|
+
cwd_name,
|
|
73
|
+
runner_input.timeout_seconds,
|
|
74
|
+
self._start_log_detail(runner_input),
|
|
75
|
+
len(runner_input.instruction),
|
|
76
|
+
)
|
|
77
|
+
started_at = datetime.now(UTC)
|
|
78
|
+
argv = self.build_argv(runner_input)
|
|
79
|
+
proc = subprocess.Popen(
|
|
80
|
+
argv,
|
|
81
|
+
cwd=runner_input.cwd,
|
|
82
|
+
env=runner_input.env,
|
|
83
|
+
stdout=subprocess.PIPE,
|
|
84
|
+
stderr=subprocess.PIPE,
|
|
85
|
+
text=True,
|
|
86
|
+
)
|
|
87
|
+
self._log.info("process spawned pid=%s cwd=%s", proc.pid, cwd_name)
|
|
88
|
+
cancelled = threading.Event()
|
|
89
|
+
if runner_input.cancel_event is not None:
|
|
90
|
+
cancel_event = runner_input.cancel_event
|
|
91
|
+
|
|
92
|
+
def _watch() -> None:
|
|
93
|
+
cancel_event.wait()
|
|
94
|
+
if proc.poll() is None:
|
|
95
|
+
self._log.warning("cancel requested pid=%s", proc.pid)
|
|
96
|
+
proc.terminate()
|
|
97
|
+
cancelled.set()
|
|
98
|
+
|
|
99
|
+
threading.Thread(target=_watch, daemon=True).start()
|
|
100
|
+
try:
|
|
101
|
+
stdout_data, stderr_data = proc.communicate(timeout=runner_input.timeout_seconds)
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
proc.kill()
|
|
104
|
+
stdout_data, stderr_data = proc.communicate()
|
|
105
|
+
self._log.warning(
|
|
106
|
+
"timeout after %ds stdout_len=%d stderr_len=%d",
|
|
107
|
+
runner_input.timeout_seconds,
|
|
108
|
+
len(stdout_data),
|
|
109
|
+
len(stderr_data),
|
|
110
|
+
)
|
|
111
|
+
raise
|
|
112
|
+
finished_at = datetime.now(UTC)
|
|
113
|
+
if cancelled.is_set():
|
|
114
|
+
raise RuntimeError("The job was cancelled.")
|
|
115
|
+
dur_ms = int((finished_at - started_at).total_seconds() * 1000)
|
|
116
|
+
self._log.info(
|
|
117
|
+
"done exit=%d dur_ms=%d stdout_len=%d stderr_len=%d",
|
|
118
|
+
proc.returncode,
|
|
119
|
+
dur_ms,
|
|
120
|
+
len(stdout_data),
|
|
121
|
+
len(stderr_data),
|
|
122
|
+
)
|
|
123
|
+
return RunnerResult(
|
|
124
|
+
exit_code=proc.returncode,
|
|
125
|
+
stdout=stdout_data,
|
|
126
|
+
stderr=stderr_data,
|
|
127
|
+
started_at=started_at,
|
|
128
|
+
finished_at=finished_at,
|
|
129
|
+
)
|
app/ai/claude.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
|
|
4
|
+
from app.jobs.schemas import JobMode
|
|
5
|
+
from app.monitoring.events import EventLogger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClaudeRunner(BaseCliRunner):
|
|
9
|
+
name = "claude"
|
|
10
|
+
_log = EventLogger("app.ai.claude", "ai.runner")
|
|
11
|
+
|
|
12
|
+
def build_argv(self, runner_input: RunnerInput) -> list[str]:
|
|
13
|
+
if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
|
|
14
|
+
prompt = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
|
|
15
|
+
argv = ["claude", "-p", prompt, "--permission-mode", "plan"]
|
|
16
|
+
else:
|
|
17
|
+
argv = ["claude", "-p", runner_input.instruction, "--dangerously-skip-permissions"]
|
|
18
|
+
if runner_input.model_id:
|
|
19
|
+
argv.extend(["--model", runner_input.model_id])
|
|
20
|
+
return argv
|
app/ai/codex.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
|
|
4
|
+
from app.jobs.schemas import JobMode
|
|
5
|
+
from app.models import CodexSandboxMode
|
|
6
|
+
from app.monitoring.events import EventLogger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CodexRunner(BaseCliRunner):
|
|
10
|
+
name = "codex"
|
|
11
|
+
_log = EventLogger("app.ai.codex", "ai.runner")
|
|
12
|
+
|
|
13
|
+
def __init__(self, sandbox: CodexSandboxMode = CodexSandboxMode.WORKSPACE_WRITE) -> None:
|
|
14
|
+
self._sandbox = sandbox
|
|
15
|
+
|
|
16
|
+
def _resolve_sandbox(self, runner_input: RunnerInput) -> CodexSandboxMode:
|
|
17
|
+
if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
|
|
18
|
+
return CodexSandboxMode.READ_ONLY
|
|
19
|
+
return self._sandbox
|
|
20
|
+
|
|
21
|
+
def _start_log_detail(self, runner_input: RunnerInput) -> str:
|
|
22
|
+
return f" sandbox={self._resolve_sandbox(runner_input).value}"
|
|
23
|
+
|
|
24
|
+
def build_argv(self, runner_input: RunnerInput) -> list[str]:
|
|
25
|
+
sandbox = self._resolve_sandbox(runner_input)
|
|
26
|
+
if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
|
|
27
|
+
instruction = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
|
|
28
|
+
else:
|
|
29
|
+
instruction = runner_input.instruction
|
|
30
|
+
argv = ["codex", "exec"]
|
|
31
|
+
if runner_input.model_id:
|
|
32
|
+
argv.extend(["--model", runner_input.model_id])
|
|
33
|
+
argv.extend(["--sandbox", sandbox.value, instruction])
|
|
34
|
+
return argv
|
app/ai/factory.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from app.ai.base import AiRunner
|
|
4
|
+
from app.ai.claude import ClaudeRunner
|
|
5
|
+
from app.ai.codex import CodexRunner
|
|
6
|
+
from app.ai.gemini import GeminiRunner
|
|
7
|
+
from app.models import CodexSandboxMode, ModelName
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnknownModelError(ValueError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AiRunnerFactory:
|
|
15
|
+
def __init__(self, codex_sandbox: CodexSandboxMode = CodexSandboxMode.WORKSPACE_WRITE) -> None:
|
|
16
|
+
self._codex_sandbox = codex_sandbox
|
|
17
|
+
|
|
18
|
+
def create(self, model_name: ModelName) -> AiRunner:
|
|
19
|
+
builders: dict[ModelName, Callable[[], AiRunner]] = {
|
|
20
|
+
ModelName.CLAUDE: ClaudeRunner,
|
|
21
|
+
ModelName.CODEX: lambda: CodexRunner(sandbox=self._codex_sandbox),
|
|
22
|
+
ModelName.GEMINI: GeminiRunner,
|
|
23
|
+
}
|
|
24
|
+
builder = builders.get(model_name)
|
|
25
|
+
if builder is None:
|
|
26
|
+
raise UnknownModelError(f"Unsupported model: {model_name}")
|
|
27
|
+
return builder()
|
app/ai/gemini.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
|
|
4
|
+
from app.jobs.schemas import JobMode
|
|
5
|
+
from app.monitoring.events import EventLogger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GeminiRunner(BaseCliRunner):
|
|
9
|
+
name = "gemini"
|
|
10
|
+
_log = EventLogger("app.ai.gemini", "ai.runner")
|
|
11
|
+
|
|
12
|
+
def build_argv(self, runner_input: RunnerInput) -> list[str]:
|
|
13
|
+
if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
|
|
14
|
+
prompt = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
|
|
15
|
+
argv = ["gemini", "--skip-trust", "-p", prompt]
|
|
16
|
+
else:
|
|
17
|
+
argv = ["gemini", "--approval-mode", "yolo", "--skip-trust", "-p", runner_input.instruction]
|
|
18
|
+
if runner_input.model_id:
|
|
19
|
+
argv.extend(["--model", runner_input.model_id])
|
|
20
|
+
return argv
|
app/ai/model_catalog.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from app.models import ModelName
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ModelOption:
|
|
10
|
+
label: str
|
|
11
|
+
value: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MODEL_CATALOG: dict[ModelName, tuple[ModelOption, ...]] = {
|
|
15
|
+
ModelName.CLAUDE: (
|
|
16
|
+
ModelOption("opus", "opus"),
|
|
17
|
+
ModelOption("sonnet", "sonnet"),
|
|
18
|
+
ModelOption("claude-opus-4-7", "claude-opus-4-7"),
|
|
19
|
+
ModelOption("claude-sonnet-4-6", "claude-sonnet-4-6"),
|
|
20
|
+
),
|
|
21
|
+
ModelName.CODEX: (
|
|
22
|
+
ModelOption("gpt-5.3-codex", "gpt-5.3-codex"),
|
|
23
|
+
ModelOption("gpt-5.5", "gpt-5.5"),
|
|
24
|
+
ModelOption("gpt-5.4", "gpt-5.4"),
|
|
25
|
+
ModelOption("gpt-5.4-mini", "gpt-5.4-mini"),
|
|
26
|
+
ModelOption("gpt-5", "gpt-5"),
|
|
27
|
+
),
|
|
28
|
+
ModelName.GEMINI: (
|
|
29
|
+
ModelOption("auto", "auto"),
|
|
30
|
+
ModelOption("gemini-3.1-pro-preview", "gemini-3.1-pro-preview"),
|
|
31
|
+
ModelOption("gemini-3-pro-preview", "gemini-3-pro-preview"),
|
|
32
|
+
ModelOption("gemini-3-flash-preview", "gemini-3-flash-preview"),
|
|
33
|
+
ModelOption("gemini-2.5-pro", "gemini-2.5-pro"),
|
|
34
|
+
),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_model_options(provider: ModelName) -> tuple[ModelOption, ...]:
|
|
39
|
+
return MODEL_CATALOG.get(provider, ())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_valid_model_id(provider: ModelName, model_id: str) -> bool:
|
|
43
|
+
return any(option.value == model_id for option in get_model_options(provider))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_model_selection(provider: ModelName, model_id: str | None = None) -> str:
|
|
47
|
+
return f"{provider.value} / {model_id}" if model_id else provider.value
|
app/ai/usage.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
_MODEL_VALUE_LIMIT: Final[int] = 80
|
|
8
|
+
|
|
9
|
+
_MODEL_FIELD_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
|
|
10
|
+
re.compile(
|
|
11
|
+
r"(?im)^\s*(?:actual\s+model|selected\s+model|current\s+model|model|์ฌ์ฉ\s*๋ชจ๋ธ)\s*[:=]\s*([^\n,;]+)"
|
|
12
|
+
),
|
|
13
|
+
re.compile(r"(?im)\b(?:using|selected)\s+(?:model\s+)?([A-Za-z][\w .:/-]{1,70}\d(?:\.\d+)?)"),
|
|
14
|
+
)
|
|
15
|
+
_TOKEN_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
|
|
16
|
+
re.compile(
|
|
17
|
+
r"(?i)\b(input|prompt|output|completion|cached|cache\s+read|cache\s+write|total)\s*"
|
|
18
|
+
r"(?:tokens?|ํ ํฐ)\s*[:=]\s*([0-9][0-9,._]*)"
|
|
19
|
+
),
|
|
20
|
+
re.compile(
|
|
21
|
+
r"(?i)\b(input_tokens|prompt_tokens|output_tokens|completion_tokens|cached_tokens|total_tokens)"
|
|
22
|
+
r'["\']?\s*[:=]\s*([0-9][0-9,._]*)'
|
|
23
|
+
),
|
|
24
|
+
re.compile(
|
|
25
|
+
r"(?i)\b(input|prompt|output|completion|cached|total)\s*[:=]\s*([0-9][0-9,._]*)\s*(?:tokens?|ํ ํฐ)"
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RunnerUsage:
|
|
32
|
+
actual_model: str | None = None
|
|
33
|
+
token_usage: dict[str, int] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def total_tokens(self) -> int | None:
|
|
37
|
+
if "total" in self.token_usage:
|
|
38
|
+
return self.token_usage["total"]
|
|
39
|
+
values = [
|
|
40
|
+
value
|
|
41
|
+
for label, value in self.token_usage.items()
|
|
42
|
+
if label not in {"total", "cache read", "cache write"}
|
|
43
|
+
]
|
|
44
|
+
if not values:
|
|
45
|
+
return None
|
|
46
|
+
return sum(values)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extract_runner_usage(text: str) -> RunnerUsage:
|
|
50
|
+
return RunnerUsage(
|
|
51
|
+
actual_model=_extract_actual_model(text),
|
|
52
|
+
token_usage=_extract_token_metrics(text),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def merge_token_usage(target: dict[str, int], source: dict[str, int]) -> None:
|
|
57
|
+
for label, value in source.items():
|
|
58
|
+
target[label] = target.get(label, 0) + value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_token_usage(token_usage: dict[str, int]) -> str | None:
|
|
62
|
+
total = RunnerUsage(token_usage=token_usage).total_tokens
|
|
63
|
+
if total is None:
|
|
64
|
+
return None
|
|
65
|
+
details = _format_token_usage_details(token_usage)
|
|
66
|
+
if details:
|
|
67
|
+
return f"{total:,} ({details})"
|
|
68
|
+
return f"{total:,}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_token_usage_details(token_usage: dict[str, int]) -> str | None:
|
|
72
|
+
labels = ("input", "output", "cached", "total", "cache read", "cache write")
|
|
73
|
+
rendered = [
|
|
74
|
+
f"{label}={token_usage[label]:,}"
|
|
75
|
+
for label in labels
|
|
76
|
+
if label in token_usage
|
|
77
|
+
]
|
|
78
|
+
rendered.extend(
|
|
79
|
+
f"{label}={value:,}"
|
|
80
|
+
for label, value in sorted(token_usage.items())
|
|
81
|
+
if label not in labels
|
|
82
|
+
)
|
|
83
|
+
return ", ".join(rendered) if rendered else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_actual_model(text: str) -> str | None:
|
|
87
|
+
for pattern in _MODEL_FIELD_PATTERNS:
|
|
88
|
+
match = pattern.search(text)
|
|
89
|
+
if not match:
|
|
90
|
+
continue
|
|
91
|
+
value = _sanitize_metric_value(match.group(1))
|
|
92
|
+
if value:
|
|
93
|
+
return value[:_MODEL_VALUE_LIMIT]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _extract_token_metrics(text: str) -> dict[str, int]:
|
|
98
|
+
metrics: dict[str, int] = {}
|
|
99
|
+
for pattern in _TOKEN_PATTERNS:
|
|
100
|
+
for match in pattern.finditer(text):
|
|
101
|
+
label = _normalize_token_label(match.group(1))
|
|
102
|
+
value = _parse_int(match.group(2))
|
|
103
|
+
if value is None:
|
|
104
|
+
continue
|
|
105
|
+
metrics[label] = metrics.get(label, 0) + value
|
|
106
|
+
return metrics
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _normalize_token_label(raw: str) -> str:
|
|
110
|
+
key = raw.lower().replace("_", " ").strip()
|
|
111
|
+
if key in {"prompt", "prompt tokens"}:
|
|
112
|
+
return "input"
|
|
113
|
+
if key in {"completion", "completion tokens"}:
|
|
114
|
+
return "output"
|
|
115
|
+
if key in {"input tokens"}:
|
|
116
|
+
return "input"
|
|
117
|
+
if key in {"output tokens"}:
|
|
118
|
+
return "output"
|
|
119
|
+
if key in {"cached tokens"}:
|
|
120
|
+
return "cached"
|
|
121
|
+
if key in {"total tokens"}:
|
|
122
|
+
return "total"
|
|
123
|
+
return key
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_int(raw: str) -> int | None:
|
|
127
|
+
normalized = raw.replace(",", "").replace("_", "").strip()
|
|
128
|
+
if not normalized.isdigit():
|
|
129
|
+
return None
|
|
130
|
+
return int(normalized)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _sanitize_metric_value(raw: str) -> str:
|
|
134
|
+
return re.sub(r"\s+", " ", raw).strip().strip("`'\"")
|
app/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from app import __version__
|
|
12
|
+
|
|
13
|
+
_AI_CLI_TOOLS = ("claude", "codex", "gemini")
|
|
14
|
+
_VALID_MODELS = ("claude", "codex", "gemini")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _add_server_args(subparser: argparse.ArgumentParser) -> None:
|
|
18
|
+
subparser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
|
|
19
|
+
subparser.add_argument("--port", default=8000, type=int, help="Port to bind")
|
|
20
|
+
subparser.add_argument("--reload", action="store_true", help="Enable Uvicorn reload mode")
|
|
21
|
+
subparser.add_argument("--log-level", default="info", help="Uvicorn log level")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="remote-coder")
|
|
26
|
+
parser.add_argument("--version", action="version", version=f"remote-coder {__version__}")
|
|
27
|
+
|
|
28
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
29
|
+
|
|
30
|
+
init = subparsers.add_parser("init", help="Interactive first-time setup (writes global config)")
|
|
31
|
+
init.add_argument("--force", action="store_true", help="Overwrite existing config without asking")
|
|
32
|
+
|
|
33
|
+
up = subparsers.add_parser(
|
|
34
|
+
"up", help="Start ngrok tunnel, register Telegram webhooks, and run the server"
|
|
35
|
+
)
|
|
36
|
+
_add_server_args(up)
|
|
37
|
+
up.add_argument(
|
|
38
|
+
"--no-tunnel",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Run the server only (skip ngrok and webhook registration)",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
subparsers.add_parser("doctor", help="Check prerequisites (ngrok, AI CLIs)")
|
|
44
|
+
return parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
48
|
+
parser = build_parser()
|
|
49
|
+
args = parser.parse_args(argv)
|
|
50
|
+
if args.command is None:
|
|
51
|
+
args = parser.parse_args(["up"])
|
|
52
|
+
|
|
53
|
+
if args.command == "init":
|
|
54
|
+
run_init(force=args.force)
|
|
55
|
+
elif args.command == "doctor":
|
|
56
|
+
run_doctor()
|
|
57
|
+
else:
|
|
58
|
+
run_up(
|
|
59
|
+
host=args.host,
|
|
60
|
+
port=args.port,
|
|
61
|
+
reload=args.reload,
|
|
62
|
+
log_level=args.log_level,
|
|
63
|
+
tunnel=not args.no_tunnel,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run_server(*, host: str, port: int, reload: bool, log_level: str) -> None:
|
|
68
|
+
print("๐ ์๋ฒ๋ฅผ ์์ํฉ๋๋ค... (์ข
๋ฃ: Ctrl+C)")
|
|
69
|
+
uvicorn.run("app.main:app", host=host, port=port, reload=reload, log_level=log_level)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_up(*, host: str, port: int, reload: bool, log_level: str, tunnel: bool = True) -> None:
|
|
73
|
+
if not tunnel:
|
|
74
|
+
_run_server(host=host, port=port, reload=reload, log_level=log_level)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
from app.config import get_settings
|
|
78
|
+
from app.telegram.webhook_registration import register_all_enabled_projects
|
|
79
|
+
from app.tunnel import NgrokTunnel, TunnelError
|
|
80
|
+
|
|
81
|
+
ngrok = NgrokTunnel(port)
|
|
82
|
+
try:
|
|
83
|
+
print("๐ ngrok ํฐ๋์ ์์ํฉ๋๋ค...")
|
|
84
|
+
public_url = ngrok.start()
|
|
85
|
+
except TunnelError as exc:
|
|
86
|
+
print(f"โ {exc}")
|
|
87
|
+
raise SystemExit(1) from exc
|
|
88
|
+
|
|
89
|
+
print(f"๐ ๊ณต๊ฐ HTTPS ์ฃผ์: {public_url}")
|
|
90
|
+
os.environ["TELEGRAM_WEBHOOK_PUBLIC_BASE_URL"] = public_url
|
|
91
|
+
get_settings.cache_clear()
|
|
92
|
+
settings = get_settings()
|
|
93
|
+
|
|
94
|
+
print("๐จ Telegram webhook/๋ช
๋ น์ด ๋ฉ๋ด๋ฅผ ๋ฑ๋กํฉ๋๋ค...")
|
|
95
|
+
if not register_all_enabled_projects(public_url, settings):
|
|
96
|
+
print("โ ๏ธ ์ผ๋ถ ํ๋ก์ ํธ webhook ๋ฑ๋ก์ ์คํจํ์ต๋๋ค. ๊ธฐ์กด ๋ฑ๋ก์ด ์ ํจํ๋ฉด ๊ณ์ ๋์ํ ์ ์์ต๋๋ค.")
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
_run_server(host=host, port=port, reload=reload, log_level=log_level)
|
|
100
|
+
finally:
|
|
101
|
+
ngrok.stop()
|
|
102
|
+
print("๐ ngrok ํฐ๋์ ์ข
๋ฃํ์ต๋๋ค.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_init(*, force: bool) -> None:
|
|
106
|
+
from app.config import Settings, remote_coder_home
|
|
107
|
+
from app.projects.registry import ProjectRegistry, projects_config_path_for_settings
|
|
108
|
+
|
|
109
|
+
home = remote_coder_home()
|
|
110
|
+
env_path = home / ".env"
|
|
111
|
+
if env_path.exists() and not force:
|
|
112
|
+
answer = input(f"์ด๋ฏธ ์ค์ ํ์ผ์ด ์์ต๋๋ค ({env_path}). ๋ฎ์ด์ธ๊น์? [y/N]: ").strip().lower()
|
|
113
|
+
if answer not in ("y", "yes"):
|
|
114
|
+
print("์ค์ ์ ์ทจ์ํ์ต๋๋ค.")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
print("Remote AI Coder ์ค์ ์ ์์ํฉ๋๋ค. (Ctrl+C๋ก ์ทจ์)\n")
|
|
118
|
+
|
|
119
|
+
project_root = _prompt_existing_dir("AI ์์
๋์ Git ์ ์ฅ์ ๊ฒฝ๋ก")
|
|
120
|
+
worktree_dir = Path(
|
|
121
|
+
_prompt("worktree ๋๋ ํฐ๋ฆฌ", default=str(home / "worktrees"))
|
|
122
|
+
).expanduser()
|
|
123
|
+
default_project = _prompt("ํ๋ก์ ํธ ์ด๋ฆ", default=project_root.name)
|
|
124
|
+
bot_token = _prompt_required("Telegram Bot Token (BotFather)")
|
|
125
|
+
chat_ids = _prompt_chat_ids("ํ์ฉํ Telegram Chat ID (์ผํ๋ก ๊ตฌ๋ถ)")
|
|
126
|
+
model = _prompt_choice("๊ธฐ๋ณธ ๋ชจ๋ธ", _VALID_MODELS, default="claude")
|
|
127
|
+
|
|
128
|
+
env_values = {
|
|
129
|
+
"TELEGRAM_BOT_TOKEN": bot_token,
|
|
130
|
+
"TELEGRAM_ALLOWED_CHAT_IDS": ",".join(str(cid) for cid in chat_ids),
|
|
131
|
+
"DEFAULT_MODEL": model,
|
|
132
|
+
"DEFAULT_PROJECT": default_project,
|
|
133
|
+
"PROJECT_ROOT": str(project_root),
|
|
134
|
+
"WORKTREE_BASE_DIR": str(worktree_dir),
|
|
135
|
+
}
|
|
136
|
+
_write_env_file(env_path, env_values)
|
|
137
|
+
print(f"\nโ
์ค์ ์ ์ ์ฅํ์ต๋๋ค: {env_path}")
|
|
138
|
+
|
|
139
|
+
settings = Settings(
|
|
140
|
+
telegram_bot_token=bot_token,
|
|
141
|
+
telegram_allowed_chat_ids=chat_ids,
|
|
142
|
+
default_model=model,
|
|
143
|
+
default_project=default_project,
|
|
144
|
+
project_root=project_root,
|
|
145
|
+
worktree_base_dir=worktree_dir,
|
|
146
|
+
_env_file=None,
|
|
147
|
+
)
|
|
148
|
+
config_path = projects_config_path_for_settings(
|
|
149
|
+
settings.project_root, settings.projects_config_path
|
|
150
|
+
)
|
|
151
|
+
registry = ProjectRegistry(config_path)
|
|
152
|
+
registry.ensure_seeded_from_settings(settings)
|
|
153
|
+
if registry.list_projects():
|
|
154
|
+
print(f"โ
ํ๋ก์ ํธ ๋ ์ง์คํธ๋ฆฌ: {config_path}")
|
|
155
|
+
else:
|
|
156
|
+
print(f"โน๏ธ ๊ธฐ์กด ํ๋ก์ ํธ ๋ ์ง์คํธ๋ฆฌ๋ฅผ ์ ์งํ์ต๋๋ค: {config_path}")
|
|
157
|
+
|
|
158
|
+
print()
|
|
159
|
+
run_doctor()
|
|
160
|
+
print("\n๋ค์ ๋จ๊ณ: `remote-coder up` ์ผ๋ก ์๋ฒ๋ฅผ ์คํํ์ธ์.")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_doctor() -> None:
|
|
164
|
+
from app.tunnel import TunnelError, ensure_ngrok_available, ensure_ngrok_configured
|
|
165
|
+
|
|
166
|
+
print("์ ์ ์กฐ๊ฑด ์ ๊ฒ:")
|
|
167
|
+
try:
|
|
168
|
+
ensure_ngrok_available()
|
|
169
|
+
ensure_ngrok_configured()
|
|
170
|
+
print(" โ
ngrok: ์ค์น ๋ฐ AuthToken ์ค์ ์๋ฃ")
|
|
171
|
+
except TunnelError as exc:
|
|
172
|
+
print(f" โ ๏ธ ngrok: {exc}")
|
|
173
|
+
|
|
174
|
+
available = [tool for tool in _AI_CLI_TOOLS if shutil.which(tool)]
|
|
175
|
+
if available:
|
|
176
|
+
print(f" โ
AI CLI: {', '.join(available)}")
|
|
177
|
+
else:
|
|
178
|
+
print(
|
|
179
|
+
" โ ๏ธ AI CLI(claude/codex/gemini)๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค. ์ต์ 1๊ฐ๋ฅผ ์ค์นํ์ธ์. "
|
|
180
|
+
"(์: npm install -g @anthropic-ai/claude-code)"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _prompt(label: str, *, default: str | None = None) -> str:
|
|
185
|
+
suffix = f" [{default}]" if default else ""
|
|
186
|
+
value = input(f"{label}{suffix}: ").strip()
|
|
187
|
+
if not value and default is not None:
|
|
188
|
+
return default
|
|
189
|
+
return value
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _prompt_required(label: str) -> str:
|
|
193
|
+
while True:
|
|
194
|
+
value = input(f"{label}: ").strip()
|
|
195
|
+
if value:
|
|
196
|
+
return value
|
|
197
|
+
print(" ๊ฐ์ ์
๋ ฅํด์ฃผ์ธ์.")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _prompt_existing_dir(label: str) -> Path:
|
|
201
|
+
while True:
|
|
202
|
+
raw = _prompt_required(label)
|
|
203
|
+
path = Path(raw).expanduser().resolve()
|
|
204
|
+
if path.is_dir():
|
|
205
|
+
if not (path / ".git").exists():
|
|
206
|
+
print(f" โ ๏ธ {path} ๋ Git ์ ์ฅ์๊ฐ ์๋ ๊ฒ ๊ฐ์ต๋๋ค. ๊ณ์ ์งํํฉ๋๋ค.")
|
|
207
|
+
return path
|
|
208
|
+
print(f" ๋๋ ํฐ๋ฆฌ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค: {path}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _prompt_chat_ids(label: str) -> list[int]:
|
|
212
|
+
while True:
|
|
213
|
+
raw = _prompt_required(label)
|
|
214
|
+
try:
|
|
215
|
+
return [int(item.strip()) for item in raw.split(",") if item.strip()]
|
|
216
|
+
except ValueError:
|
|
217
|
+
print(" ์ซ์ Chat ID๋ง ์ผํ๋ก ๊ตฌ๋ถํด ์
๋ ฅํด์ฃผ์ธ์. (์: 123456789,987654321)")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _prompt_choice(label: str, choices: Sequence[str], *, default: str) -> str:
|
|
221
|
+
options = "/".join(choices)
|
|
222
|
+
while True:
|
|
223
|
+
value = _prompt(f"{label} ({options})", default=default).lower()
|
|
224
|
+
if value in choices:
|
|
225
|
+
return value
|
|
226
|
+
print(f" ๋ค์ ์ค ํ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์: {options}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _write_env_file(env_path: Path, values: dict[str, str]) -> None:
|
|
230
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
lines = [f"{key}={value}" for key, value in values.items()]
|
|
232
|
+
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
233
|
+
# SECURITY: the global .env stores the plaintext bot token; restrict to the owner.
|
|
234
|
+
os.chmod(env_path, 0o600)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
main()
|