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
app/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Remote AI Coder application package."""
2
+
3
+ __version__ = "0.4.1"
app/admin/__init__.py ADDED
File without changes
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from threading import Lock
6
+ from typing import Self
7
+
8
+ from pydantic import BaseModel, model_validator
9
+
10
+ from app.models import UiLanguage
11
+
12
+
13
+ class AdvancedSettings(BaseModel):
14
+ model_config = {"extra": "forbid"}
15
+
16
+ ui_language: UiLanguage = UiLanguage.ENGLISH
17
+ server_lifecycle_notify_enabled: bool = True
18
+ pull_projects_on_server_startup_enabled: bool = False
19
+ auto_merge_to_main_enabled: bool = False
20
+ delete_rebased_branch_enabled: bool = True
21
+ natural_job_confirmation_buttons_enabled: bool = False
22
+ conversation_memory_limit_enabled: bool = False
23
+ conversation_memory_max_rows: int | None = None
24
+ conversation_memory_max_bytes: int | None = None
25
+ status_recent_job_limit: int = 10
26
+ job_timeout_seconds: int | None = None
27
+
28
+ @model_validator(mode="after")
29
+ def _validate_memory_limits(self) -> Self:
30
+ if self.conversation_memory_limit_enabled:
31
+ has_rows = self.conversation_memory_max_rows is not None and self.conversation_memory_max_rows > 0
32
+ has_bytes = (
33
+ self.conversation_memory_max_bytes is not None and self.conversation_memory_max_bytes > 0
34
+ )
35
+ if not has_rows and not has_bytes:
36
+ raise ValueError(
37
+ "When conversation_memory_limit_enabled is set, at least one of "
38
+ "conversation_memory_max_rows or conversation_memory_max_bytes must be a positive value.",
39
+ )
40
+ if self.conversation_memory_max_rows is not None and self.conversation_memory_max_rows <= 0:
41
+ raise ValueError("conversation_memory_max_rows must be positive or blank.")
42
+ if self.conversation_memory_max_bytes is not None and self.conversation_memory_max_bytes <= 0:
43
+ raise ValueError("conversation_memory_max_bytes must be positive or blank.")
44
+ if self.status_recent_job_limit < 1:
45
+ raise ValueError("status_recent_job_limit must be at least 1.")
46
+ if self.job_timeout_seconds is not None and self.job_timeout_seconds <= 0:
47
+ raise ValueError("job_timeout_seconds must be positive or blank.")
48
+ return self
49
+
50
+
51
+ def advanced_settings_path_for_project_root(project_root: Path) -> Path:
52
+ return (project_root.expanduser().resolve() / ".remote-coder" / "advanced_settings.json").resolve()
53
+
54
+
55
+ class FileAdvancedSettingsStore:
56
+ def __init__(self, path: Path) -> None:
57
+ self._path = path
58
+ self._lock = Lock()
59
+ self._cached = AdvancedSettings()
60
+
61
+ @property
62
+ def path(self) -> Path:
63
+ return self._path
64
+
65
+ def load(self) -> AdvancedSettings:
66
+ with self._lock:
67
+ if not self._path.exists():
68
+ self._cached = AdvancedSettings()
69
+ return self._cached.model_copy(deep=True)
70
+ raw = self._path.read_text(encoding="utf-8")
71
+ data = json.loads(raw) if raw.strip() else {}
72
+ if isinstance(data, dict):
73
+ data.pop("auto_pull_on_project_switch", None)
74
+ self._cached = AdvancedSettings.model_validate(data)
75
+ return self._cached.model_copy(deep=True)
76
+
77
+ def get(self) -> AdvancedSettings:
78
+ with self._lock:
79
+ return self._cached.model_copy(deep=True)
80
+
81
+ def save(self, settings: AdvancedSettings) -> AdvancedSettings:
82
+ validated = AdvancedSettings.model_validate(settings.model_dump(mode="json"))
83
+ with self._lock:
84
+ self._path.parent.mkdir(parents=True, exist_ok=True)
85
+ text = json.dumps(validated.model_dump(mode="json"), indent=2, ensure_ascii=False)
86
+ self._path.write_text(text + "\n", encoding="utf-8")
87
+ self._cached = validated
88
+ return self._cached.model_copy(deep=True)
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ import sqlite3
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Final
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class _TableSpec:
14
+ name: str
15
+ label: str
16
+ columns: tuple[str, ...]
17
+ sortable: frozenset[str]
18
+ default_sort: str
19
+
20
+
21
+ _TABLES: Final[dict[str, _TableSpec]] = {
22
+ "conversation_entries": _TableSpec(
23
+ name="conversation_entries",
24
+ label="Conversation & job history",
25
+ columns=(
26
+ "id",
27
+ "project",
28
+ "chat_id",
29
+ "role",
30
+ "text",
31
+ "job_id",
32
+ "message_id",
33
+ "reply_to_message_id",
34
+ "created_at",
35
+ ),
36
+ sortable=frozenset({"id", "project", "chat_id", "role", "created_at", "job_id", "message_id"}),
37
+ default_sort="id",
38
+ ),
39
+ "message_branch_links": _TableSpec(
40
+ name="message_branch_links",
41
+ label="Message–branch links",
42
+ columns=(
43
+ "project",
44
+ "chat_id",
45
+ "message_id",
46
+ "branch",
47
+ "job_id",
48
+ "created_at",
49
+ "updated_at",
50
+ ),
51
+ sortable=frozenset(
52
+ {"project", "chat_id", "message_id", "created_at", "updated_at", "branch", "job_id"}
53
+ ),
54
+ default_sort="updated_at",
55
+ ),
56
+ }
57
+
58
+ _DEFAULT_EXPORT_MAX_ROWS: Final[int] = 50_000
59
+ _EXPORT_CHUNK: Final[int] = 2_000
60
+
61
+
62
+ def _open_readonly(db_path: Path) -> sqlite3.Connection:
63
+ uri = f"file:{db_path.resolve()}?mode=ro"
64
+ return sqlite3.connect(uri, uri=True)
65
+
66
+
67
+ def _build_where(
68
+ spec: _TableSpec,
69
+ *,
70
+ project: str | None,
71
+ chat_id: int | None,
72
+ role: str | None,
73
+ job_id: str | None,
74
+ q: str | None,
75
+ ) -> tuple[str, list[Any]]:
76
+ where_parts: list[str] = []
77
+ params: list[Any] = []
78
+
79
+ if project and project.strip():
80
+ where_parts.append("project = ?")
81
+ params.append(project.strip())
82
+ if chat_id is not None:
83
+ where_parts.append("chat_id = ?")
84
+ params.append(chat_id)
85
+ if role and role.strip() and spec.name == "conversation_entries":
86
+ where_parts.append("role = ?")
87
+ params.append(role.strip())
88
+ if job_id and job_id.strip():
89
+ where_parts.append("job_id = ?")
90
+ params.append(job_id.strip())
91
+
92
+ if q and q.strip():
93
+ qq = q.strip()
94
+ if spec.name == "conversation_entries":
95
+ where_parts.append(
96
+ "(instr(lower(COALESCE(text,'')), lower(?)) > 0 "
97
+ "OR instr(lower(COALESCE(job_id,'')), lower(?)) > 0 "
98
+ "OR instr(lower(COALESCE(project,'')), lower(?)) > 0)"
99
+ )
100
+ params.extend([qq, qq, qq])
101
+ else:
102
+ where_parts.append(
103
+ "(instr(lower(COALESCE(branch,'')), lower(?)) > 0 "
104
+ "OR instr(lower(COALESCE(job_id,'')), lower(?)) > 0 "
105
+ "OR instr(lower(COALESCE(project,'')), lower(?)) > 0)"
106
+ )
107
+ params.extend([qq, qq, qq])
108
+
109
+ where_sql = " AND ".join(where_parts) if where_parts else "1=1"
110
+ return where_sql, params
111
+
112
+
113
+ class ConversationDatabaseBrowser:
114
+ # SECURITY: Only tables in the `_TABLES` whitelist are exposed, and sqlite is opened read-only URI.
115
+ def __init__(self, db_path: Path) -> None:
116
+ self._db_path = db_path.resolve()
117
+
118
+ def tables_payload(self) -> dict[str, Any]:
119
+ exists = self._db_path.is_file()
120
+ tables = []
121
+ for spec in _TABLES.values():
122
+ tables.append(
123
+ {
124
+ "name": spec.name,
125
+ "label": spec.label,
126
+ "columns": list(spec.columns),
127
+ "default_sort": spec.default_sort,
128
+ "sortable": sorted(spec.sortable),
129
+ }
130
+ )
131
+ return {
132
+ "db_path": str(self._db_path),
133
+ "db_exists": exists,
134
+ "tables": tables,
135
+ }
136
+
137
+ def distinct_filter_options(self, table: str) -> dict[str, list[str]]:
138
+ spec = _TABLES.get(table)
139
+ if spec is None:
140
+ raise ValueError(f"unknown table: {table}")
141
+ if not self._db_path.is_file():
142
+ return {"projects": [], "roles": []}
143
+
144
+ conn = _open_readonly(self._db_path)
145
+ try:
146
+ cur = conn.execute(
147
+ f"SELECT DISTINCT project FROM {spec.name} ORDER BY project COLLATE NOCASE"
148
+ )
149
+ projects = [str(r[0]) for r in cur.fetchall() if r[0] is not None and str(r[0]).strip() != ""]
150
+ roles: list[str] = []
151
+ if spec.name == "conversation_entries":
152
+ cur2 = conn.execute(
153
+ "SELECT DISTINCT role FROM conversation_entries ORDER BY role COLLATE NOCASE"
154
+ )
155
+ roles = [str(r[0]) for r in cur2.fetchall() if r[0] is not None and str(r[0]).strip() != ""]
156
+ finally:
157
+ conn.close()
158
+ return {"projects": projects, "roles": roles}
159
+
160
+ def query_rows(
161
+ self,
162
+ table: str,
163
+ *,
164
+ project: str | None,
165
+ chat_id: int | None,
166
+ role: str | None,
167
+ job_id: str | None,
168
+ q: str | None,
169
+ sort: str | None,
170
+ order: str,
171
+ limit: int,
172
+ offset: int,
173
+ ) -> dict[str, Any]:
174
+ spec = _TABLES.get(table)
175
+ if spec is None:
176
+ raise ValueError(f"unknown table: {table}")
177
+ if order not in ("asc", "desc"):
178
+ raise ValueError("order must be asc or desc")
179
+
180
+ sort_col = (sort or "").strip() or spec.default_sort
181
+ if sort_col not in spec.sortable:
182
+ raise ValueError(f"invalid sort column: {sort_col}")
183
+
184
+ if not self._db_path.is_file():
185
+ return {
186
+ "table": spec.name,
187
+ "label": spec.label,
188
+ "columns": list(spec.columns),
189
+ "rows": [],
190
+ "total": 0,
191
+ "limit": limit,
192
+ "offset": offset,
193
+ "sort": sort_col,
194
+ "order": order,
195
+ }
196
+
197
+ where_sql, params = _build_where(
198
+ spec, project=project, chat_id=chat_id, role=role, job_id=job_id, q=q
199
+ )
200
+ cols_sql = ", ".join(spec.columns)
201
+ order_sql = "ASC" if order == "asc" else "DESC"
202
+
203
+ count_sql = f"SELECT COUNT(*) FROM {spec.name} WHERE {where_sql}"
204
+ data_sql = (
205
+ f"SELECT {cols_sql} FROM {spec.name} WHERE {where_sql} "
206
+ f"ORDER BY {sort_col} {order_sql} LIMIT ? OFFSET ?"
207
+ )
208
+
209
+ conn = _open_readonly(self._db_path)
210
+ try:
211
+ total = int(conn.execute(count_sql, params).fetchone()[0])
212
+ cur = conn.execute(data_sql, params + [limit, offset])
213
+ col_names = [d[0] for d in cur.description]
214
+ rows = [dict(zip(col_names, row)) for row in cur.fetchall()]
215
+ finally:
216
+ conn.close()
217
+
218
+ return {
219
+ "table": spec.name,
220
+ "label": spec.label,
221
+ "columns": list(spec.columns),
222
+ "rows": rows,
223
+ "total": total,
224
+ "limit": limit,
225
+ "offset": offset,
226
+ "sort": sort_col,
227
+ "order": order,
228
+ }
229
+
230
+ def iter_csv_rows(
231
+ self,
232
+ table: str,
233
+ *,
234
+ project: str | None,
235
+ chat_id: int | None,
236
+ role: str | None,
237
+ job_id: str | None,
238
+ q: str | None,
239
+ sort: str | None,
240
+ order: str,
241
+ max_rows: int = _DEFAULT_EXPORT_MAX_ROWS,
242
+ chunk_size: int = _EXPORT_CHUNK,
243
+ ) -> Iterator[bytes]:
244
+ # NOTE: Emit a BOM first so Excel and CJK locales detect the CSV as UTF-8.
245
+ spec = _TABLES.get(table)
246
+ if spec is None:
247
+ raise ValueError(f"unknown table: {table}")
248
+ if order not in ("asc", "desc"):
249
+ raise ValueError("order must be asc or desc")
250
+ sort_col = (sort or "").strip() or spec.default_sort
251
+ if sort_col not in spec.sortable:
252
+ raise ValueError(f"invalid sort column: {sort_col}")
253
+ if max_rows < 1:
254
+ raise ValueError("max_rows must be >= 1")
255
+ if chunk_size < 1:
256
+ raise ValueError("chunk_size must be >= 1")
257
+
258
+ enc = "utf-8"
259
+ yield "\ufeff".encode(enc)
260
+
261
+ if not self._db_path.is_file():
262
+ buf = io.StringIO()
263
+ csv.writer(buf, lineterminator="\n").writerow(list(spec.columns))
264
+ yield buf.getvalue().encode(enc)
265
+ return
266
+
267
+ where_sql, params = _build_where(
268
+ spec, project=project, chat_id=chat_id, role=role, job_id=job_id, q=q
269
+ )
270
+ cols_sql = ", ".join(spec.columns)
271
+ order_sql = "ASC" if order == "asc" else "DESC"
272
+ header_buf = io.StringIO()
273
+ csv.writer(header_buf, lineterminator="\n").writerow(list(spec.columns))
274
+ yield header_buf.getvalue().encode(enc)
275
+
276
+ conn = _open_readonly(self._db_path)
277
+ try:
278
+ emitted = 0
279
+ offset = 0
280
+ while emitted < max_rows:
281
+ take = min(chunk_size, max_rows - emitted)
282
+ data_sql = (
283
+ f"SELECT {cols_sql} FROM {spec.name} WHERE {where_sql} "
284
+ f"ORDER BY {sort_col} {order_sql} LIMIT ? OFFSET ?"
285
+ )
286
+ cur = conn.execute(data_sql, params + [take, offset])
287
+ batch = cur.fetchall()
288
+ if not batch:
289
+ break
290
+ for row in batch:
291
+ line_buf = io.StringIO()
292
+ csv.writer(line_buf, lineterminator="\n").writerow(
293
+ ["" if v is None else v for v in row]
294
+ )
295
+ yield line_buf.getvalue().encode(enc)
296
+ emitted += len(batch)
297
+ offset += len(batch)
298
+ if len(batch) < take:
299
+ break
300
+ finally:
301
+ conn.close()