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,789 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sqlite3
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from threading import Lock
8
+
9
+ from app.admin.advanced_settings import FileAdvancedSettingsStore
10
+ from app.models import UiLanguage
11
+ from app.telegram.i18n import instruction_frame_labels
12
+
13
+
14
+ _AMBIGUOUS_FOLLOWUP = re.compile(
15
+ r"^\s*(작업\s*시작해줘|진행해줘|그거\s*해줘|시작해줘)\s*$",
16
+ re.UNICODE | re.IGNORECASE,
17
+ )
18
+
19
+ _REPLY_SNIPPET_MAX = 800
20
+ # 순환 reply 체인 방지 상한.
21
+ _REPLY_CHAIN_MAX_DEPTH = 32
22
+
23
+
24
+ def is_ambiguous_followup(text: str) -> bool:
25
+ return bool(_AMBIGUOUS_FOLLOWUP.match(text.strip()))
26
+
27
+
28
+ def _truncate_snippet(text: str, limit: int = _REPLY_SNIPPET_MAX) -> str:
29
+ snippet = text.strip().replace("\r\n", "\n").replace("\r", "\n")
30
+ if len(snippet) > limit:
31
+ return snippet[:limit].rstrip() + "...(truncated)"
32
+ return snippet
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ConversationEntry:
37
+ id: int
38
+ project: str
39
+ chat_id: int
40
+ role: str
41
+ text: str
42
+ job_id: str | None
43
+ message_id: int | None = None
44
+ reply_to_message_id: int | None = None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ConversationRoleCount:
49
+ role: str
50
+ count: int
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ConversationDbChatStats:
55
+ db_path: Path
56
+ db_exists: bool
57
+ db_size_bytes: int
58
+ total_rows: int
59
+ rows_by_role: dict[str, int]
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ConversationReport:
64
+ project: str
65
+ chat_id: int
66
+ total_entries: int
67
+ role_counts: list[ConversationRoleCount]
68
+ latest_user_text: str | None
69
+ latest_job_id: str | None
70
+ latest_job_result: str | None
71
+ recent_entries: list[ConversationEntry]
72
+
73
+ def count_for(self, role: str) -> int:
74
+ for item in self.role_counts:
75
+ if item.role == role:
76
+ return item.count
77
+ return 0
78
+
79
+
80
+ def _ensure_entry_columns(conn: sqlite3.Connection) -> None:
81
+ cur = conn.execute("PRAGMA table_info(conversation_entries)")
82
+ names = {str(row[1]) for row in cur.fetchall()}
83
+ if "message_id" not in names:
84
+ conn.execute("ALTER TABLE conversation_entries ADD COLUMN message_id INTEGER")
85
+ if "reply_to_message_id" not in names:
86
+ conn.execute("ALTER TABLE conversation_entries ADD COLUMN reply_to_message_id INTEGER")
87
+
88
+
89
+ class SQLiteConversationStore:
90
+ def __init__(
91
+ self,
92
+ db_path: Path,
93
+ advanced_settings_store: FileAdvancedSettingsStore | None = None,
94
+ ) -> None:
95
+ self._db_path = db_path.resolve()
96
+ self._lock = Lock()
97
+ self._advanced_settings_store = advanced_settings_store
98
+ self.ensure_schema()
99
+
100
+ @property
101
+ def db_path(self) -> Path:
102
+ return self._db_path
103
+
104
+ def ensure_schema(self) -> None:
105
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
106
+ with self._lock:
107
+ conn = sqlite3.connect(self._db_path)
108
+ try:
109
+ conn.execute(
110
+ """
111
+ CREATE TABLE IF NOT EXISTS conversation_entries (
112
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
113
+ project TEXT NOT NULL,
114
+ chat_id INTEGER NOT NULL,
115
+ role TEXT NOT NULL,
116
+ text TEXT NOT NULL,
117
+ job_id TEXT,
118
+ message_id INTEGER,
119
+ reply_to_message_id INTEGER,
120
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
121
+ )
122
+ """
123
+ )
124
+ _ensure_entry_columns(conn)
125
+ conn.execute(
126
+ """
127
+ CREATE INDEX IF NOT EXISTS idx_conversation_project_chat_id
128
+ ON conversation_entries (project, chat_id, id)
129
+ """
130
+ )
131
+ conn.execute(
132
+ """
133
+ CREATE INDEX IF NOT EXISTS idx_conversation_user_message_id
134
+ ON conversation_entries (project, chat_id, message_id)
135
+ WHERE role = 'user' AND message_id IS NOT NULL
136
+ """
137
+ )
138
+ conn.execute(
139
+ """
140
+ CREATE INDEX IF NOT EXISTS idx_conversation_message_id
141
+ ON conversation_entries (project, chat_id, message_id)
142
+ WHERE message_id IS NOT NULL
143
+ """
144
+ )
145
+ conn.execute(
146
+ """
147
+ CREATE TABLE IF NOT EXISTS message_branch_links (
148
+ project TEXT NOT NULL,
149
+ chat_id INTEGER NOT NULL,
150
+ message_id INTEGER NOT NULL,
151
+ branch TEXT NOT NULL,
152
+ job_id TEXT,
153
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
154
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
155
+ PRIMARY KEY (project, chat_id, message_id)
156
+ )
157
+ """
158
+ )
159
+ conn.commit()
160
+ finally:
161
+ conn.close()
162
+
163
+ def delete_chat_memory(self, *, project: str, chat_id: int) -> tuple[int, int]:
164
+ with self._lock:
165
+ conn = sqlite3.connect(self._db_path)
166
+ try:
167
+ links_cur = conn.execute(
168
+ "DELETE FROM message_branch_links WHERE project = ? AND chat_id = ?",
169
+ (project, chat_id),
170
+ )
171
+ links_removed = links_cur.rowcount
172
+ entries_cur = conn.execute(
173
+ "DELETE FROM conversation_entries WHERE project = ? AND chat_id = ?",
174
+ (project, chat_id),
175
+ )
176
+ entries_removed = entries_cur.rowcount
177
+ conn.commit()
178
+ finally:
179
+ conn.close()
180
+ return int(entries_removed), int(links_removed)
181
+
182
+ @staticmethod
183
+ def _delete_oldest_entries(conn: sqlite3.Connection, limit: int) -> None:
184
+ if limit <= 0:
185
+ return
186
+ conn.execute(
187
+ """
188
+ DELETE FROM conversation_entries
189
+ WHERE id IN (
190
+ SELECT id FROM conversation_entries ORDER BY id ASC LIMIT ?
191
+ )
192
+ """,
193
+ (limit,),
194
+ )
195
+
196
+ @staticmethod
197
+ def _cleanup_orphan_branch_links(conn: sqlite3.Connection) -> None:
198
+ conn.execute(
199
+ """
200
+ DELETE FROM message_branch_links
201
+ WHERE message_id IS NOT NULL
202
+ AND message_id NOT IN (
203
+ SELECT message_id FROM conversation_entries
204
+ WHERE message_id IS NOT NULL
205
+ )
206
+ """
207
+ )
208
+
209
+ def _apply_memory_limits(self, conn: sqlite3.Connection) -> None:
210
+ if self._advanced_settings_store is None:
211
+ return
212
+ cfg = self._advanced_settings_store.get()
213
+ if not cfg.conversation_memory_limit_enabled:
214
+ return
215
+ max_rows = cfg.conversation_memory_max_rows
216
+ max_bytes = cfg.conversation_memory_max_bytes
217
+
218
+ for _ in range(500):
219
+ total = int(conn.execute("SELECT COUNT(*) FROM conversation_entries").fetchone()[0])
220
+ if max_rows is None or total <= max_rows:
221
+ break
222
+ to_delete = total - max_rows
223
+ self._delete_oldest_entries(conn, to_delete)
224
+ self._cleanup_orphan_branch_links(conn)
225
+ conn.commit()
226
+
227
+ for _ in range(500):
228
+ if max_bytes is None:
229
+ break
230
+ conn.commit()
231
+ size = self._db_path.stat().st_size if self._db_path.exists() else 0
232
+ if size <= max_bytes:
233
+ break
234
+ total = int(conn.execute("SELECT COUNT(*) FROM conversation_entries").fetchone()[0])
235
+ if total == 0:
236
+ break
237
+ batch = min(100, max(1, total // 5))
238
+ self._delete_oldest_entries(conn, batch)
239
+ self._cleanup_orphan_branch_links(conn)
240
+ conn.commit()
241
+ conn.execute("VACUUM")
242
+
243
+ def append(
244
+ self,
245
+ *,
246
+ project: str,
247
+ chat_id: int,
248
+ role: str,
249
+ text: str,
250
+ job_id: str | None = None,
251
+ message_id: int | None = None,
252
+ reply_to_message_id: int | None = None,
253
+ ) -> None:
254
+ with self._lock:
255
+ conn = sqlite3.connect(self._db_path)
256
+ try:
257
+ conn.execute(
258
+ """
259
+ INSERT INTO conversation_entries (
260
+ project, chat_id, role, text, job_id, message_id, reply_to_message_id
261
+ )
262
+ VALUES (?, ?, ?, ?, ?, ?, ?)
263
+ """,
264
+ (project, chat_id, role, text, job_id, message_id, reply_to_message_id),
265
+ )
266
+ conn.commit()
267
+ self._apply_memory_limits(conn)
268
+ finally:
269
+ conn.close()
270
+
271
+ def list_recent(self, project: str, chat_id: int, limit: int) -> list[ConversationEntry]:
272
+ if limit <= 0:
273
+ return []
274
+ with self._lock:
275
+ conn = sqlite3.connect(self._db_path)
276
+ try:
277
+ cur = conn.execute(
278
+ """
279
+ SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
280
+ FROM conversation_entries
281
+ WHERE project = ? AND chat_id = ?
282
+ ORDER BY id DESC
283
+ LIMIT ?
284
+ """,
285
+ (project, chat_id, limit),
286
+ )
287
+ rows = cur.fetchall()
288
+ finally:
289
+ conn.close()
290
+ rows.reverse()
291
+ return [_row_to_entry(r) for r in rows]
292
+
293
+ def get_user_entry_by_message_id(
294
+ self, project: str, chat_id: int, message_id: int
295
+ ) -> ConversationEntry | None:
296
+ entry = self.get_entry_by_message_id(project, chat_id, message_id)
297
+ return entry if entry is not None and entry.role == "user" else None
298
+
299
+ def get_entry_by_message_id(
300
+ self, project: str, chat_id: int, message_id: int
301
+ ) -> ConversationEntry | None:
302
+ with self._lock:
303
+ conn = sqlite3.connect(self._db_path)
304
+ try:
305
+ row = conn.execute(
306
+ """
307
+ SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
308
+ FROM conversation_entries
309
+ WHERE project = ? AND chat_id = ? AND message_id = ?
310
+ ORDER BY id DESC
311
+ LIMIT 1
312
+ """,
313
+ (project, chat_id, message_id),
314
+ ).fetchone()
315
+ finally:
316
+ conn.close()
317
+ return _row_to_entry(row) if row is not None else None
318
+
319
+ def get_job_id_for_message_id(self, project: str, chat_id: int, message_id: int) -> str | None:
320
+ entry = self.get_entry_by_message_id(project, chat_id, message_id)
321
+ if entry is not None and entry.job_id:
322
+ return entry.job_id
323
+ with self._lock:
324
+ conn = sqlite3.connect(self._db_path)
325
+ try:
326
+ link = conn.execute(
327
+ """
328
+ SELECT job_id
329
+ FROM message_branch_links
330
+ WHERE project = ? AND chat_id = ? AND message_id = ?
331
+ """,
332
+ (project, chat_id, message_id),
333
+ ).fetchone()
334
+ finally:
335
+ conn.close()
336
+ return str(link[0]) if link is not None and link[0] is not None else None
337
+
338
+ def get_latest_job_result_text_for_user_message(
339
+ self, project: str, chat_id: int, message_id: int
340
+ ) -> str | None:
341
+ with self._lock:
342
+ conn = sqlite3.connect(self._db_path)
343
+ try:
344
+ link = conn.execute(
345
+ """
346
+ SELECT job_id
347
+ FROM message_branch_links
348
+ WHERE project = ? AND chat_id = ? AND message_id = ?
349
+ """,
350
+ (project, chat_id, message_id),
351
+ ).fetchone()
352
+ job_id = str(link[0]) if link is not None and link[0] is not None else None
353
+ if job_id is None:
354
+ user_row = conn.execute(
355
+ """
356
+ SELECT job_id
357
+ FROM conversation_entries
358
+ WHERE project = ? AND chat_id = ? AND role = 'user' AND message_id = ?
359
+ ORDER BY id DESC
360
+ LIMIT 1
361
+ """,
362
+ (project, chat_id, message_id),
363
+ ).fetchone()
364
+ job_id = str(user_row[0]) if user_row is not None and user_row[0] is not None else None
365
+ if job_id is None:
366
+ return None
367
+ row = conn.execute(
368
+ """
369
+ SELECT text
370
+ FROM conversation_entries
371
+ WHERE project = ? AND chat_id = ? AND role = 'job_result' AND job_id = ?
372
+ ORDER BY id DESC
373
+ LIMIT 1
374
+ """,
375
+ (project, chat_id, job_id),
376
+ ).fetchone()
377
+ finally:
378
+ conn.close()
379
+ return str(row[0]) if row is not None else None
380
+
381
+ def bind_user_message_job(
382
+ self,
383
+ *,
384
+ project: str,
385
+ chat_id: int,
386
+ message_id: int,
387
+ job_id: str,
388
+ ) -> None:
389
+ with self._lock:
390
+ conn = sqlite3.connect(self._db_path)
391
+ try:
392
+ conn.execute(
393
+ """
394
+ UPDATE conversation_entries
395
+ SET job_id = ?
396
+ WHERE id = (
397
+ SELECT id
398
+ FROM conversation_entries
399
+ WHERE project = ? AND chat_id = ? AND role = 'user' AND message_id = ?
400
+ ORDER BY id DESC
401
+ LIMIT 1
402
+ )
403
+ """,
404
+ (job_id, project, chat_id, message_id),
405
+ )
406
+ conn.commit()
407
+ finally:
408
+ conn.close()
409
+
410
+ def format_job_context(
411
+ self, project: str, chat_id: int, job_id: str, language: UiLanguage = UiLanguage.ENGLISH
412
+ ) -> str:
413
+ with self._lock:
414
+ conn = sqlite3.connect(self._db_path)
415
+ try:
416
+ user_row = conn.execute(
417
+ """
418
+ SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
419
+ FROM conversation_entries
420
+ WHERE project = ? AND chat_id = ? AND role = 'user' AND job_id = ?
421
+ ORDER BY id ASC
422
+ LIMIT 1
423
+ """,
424
+ (project, chat_id, job_id),
425
+ ).fetchone()
426
+ result_row = conn.execute(
427
+ """
428
+ SELECT text
429
+ FROM conversation_entries
430
+ WHERE project = ? AND chat_id = ? AND role = 'job_result' AND job_id = ?
431
+ ORDER BY id DESC
432
+ LIMIT 1
433
+ """,
434
+ (project, chat_id, job_id),
435
+ ).fetchone()
436
+ history_rows = conn.execute(
437
+ """
438
+ SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
439
+ FROM conversation_entries
440
+ WHERE project = ? AND chat_id = ? AND job_id = ?
441
+ ORDER BY id ASC
442
+ LIMIT 20
443
+ """,
444
+ (project, chat_id, job_id),
445
+ ).fetchall()
446
+ finally:
447
+ conn.close()
448
+
449
+ if user_row is None and result_row is None and not history_rows:
450
+ return ""
451
+
452
+ labels = instruction_frame_labels(language)
453
+ lines = [labels.reply_job_open, f"job_id={job_id}:"]
454
+ if user_row is not None:
455
+ user_entry = _row_to_entry(user_row)
456
+ if user_entry.message_id is not None:
457
+ lines.append(f" original_message_id: {user_entry.message_id}")
458
+ lines.append(f" original_user: {_truncate_snippet(user_entry.text)}")
459
+ else:
460
+ lines.append(f" original_user: {labels.none_absent}")
461
+ if result_row is not None:
462
+ lines.append(f" job_result: {_truncate_snippet(str(result_row[0]))}")
463
+ else:
464
+ lines.append(f" job_result: {labels.none_absent}")
465
+ if history_rows:
466
+ lines.append(" job_history:")
467
+ for row in history_rows:
468
+ entry = _row_to_entry(row)
469
+ message_part = f" message_id={entry.message_id}" if entry.message_id is not None else ""
470
+ lines.append(f" - {entry.role}{message_part}: {_truncate_snippet(entry.text)}")
471
+ lines.append(labels.reply_job_close)
472
+ return "\n".join(lines)
473
+
474
+ def format_reply_context(
475
+ self, project: str, chat_id: int, reply_to_message_id: int, language: UiLanguage = UiLanguage.ENGLISH
476
+ ) -> str:
477
+ reply_entry = self.get_entry_by_message_id(project, chat_id, reply_to_message_id)
478
+ if reply_entry is not None and reply_entry.role != "user" and reply_entry.job_id:
479
+ return self.format_job_context(project, chat_id, reply_entry.job_id, language)
480
+ return self.format_reply_chain_context(project, chat_id, reply_to_message_id, language)
481
+
482
+ def collect_reply_chain_message_ids(
483
+ self, project: str, chat_id: int, reply_to_message_id: int
484
+ ) -> set[int]:
485
+ ids: set[int] = set()
486
+ cur: int | None = reply_to_message_id
487
+ depth = 0
488
+ seen: set[int] = set()
489
+ while cur is not None and depth < _REPLY_CHAIN_MAX_DEPTH:
490
+ if cur in seen:
491
+ break
492
+ seen.add(cur)
493
+ entry = self.get_user_entry_by_message_id(project, chat_id, cur)
494
+ if entry is None or entry.message_id is None:
495
+ break
496
+ ids.add(entry.message_id)
497
+ cur = entry.reply_to_message_id
498
+ depth += 1
499
+ return ids
500
+
501
+ def get_reply_chain_user_entries_newest_first(
502
+ self, project: str, chat_id: int, reply_to_message_id: int
503
+ ) -> list[ConversationEntry]:
504
+ chain: list[ConversationEntry] = []
505
+ cur: int | None = reply_to_message_id
506
+ depth = 0
507
+ seen: set[int] = set()
508
+ while cur is not None and depth < _REPLY_CHAIN_MAX_DEPTH:
509
+ if cur in seen:
510
+ break
511
+ seen.add(cur)
512
+ entry = self.get_user_entry_by_message_id(project, chat_id, cur)
513
+ if entry is None:
514
+ break
515
+ chain.append(entry)
516
+ cur = entry.reply_to_message_id
517
+ depth += 1
518
+ return chain
519
+
520
+ def format_reply_chain_context(
521
+ self, project: str, chat_id: int, reply_to_message_id: int, language: UiLanguage = UiLanguage.ENGLISH
522
+ ) -> str:
523
+ newest_first = self.get_reply_chain_user_entries_newest_first(project, chat_id, reply_to_message_id)
524
+ if not newest_first:
525
+ return ""
526
+ labels = instruction_frame_labels(language)
527
+ ordered = list(reversed(newest_first))
528
+ lines: list[str] = [labels.reply_chain_open]
529
+ for e in ordered:
530
+ mid = e.message_id
531
+ lines.append(f"message_id={mid}:")
532
+ lines.append(f" user: {_truncate_snippet(e.text)}")
533
+ job_text = self.get_latest_job_result_text_for_user_message(project, chat_id, mid) if mid else None
534
+ if job_text:
535
+ lines.append(f" job_result: {_truncate_snippet(job_text)}")
536
+ else:
537
+ lines.append(f" job_result: {labels.none_absent}")
538
+ lines.append(labels.reply_chain_close)
539
+ return "\n".join(lines)
540
+
541
+ def bind_message_branch(
542
+ self,
543
+ *,
544
+ project: str,
545
+ chat_id: int,
546
+ message_id: int,
547
+ branch: str,
548
+ job_id: str | None = None,
549
+ ) -> None:
550
+ with self._lock:
551
+ conn = sqlite3.connect(self._db_path)
552
+ try:
553
+ conn.execute(
554
+ """
555
+ INSERT INTO message_branch_links (project, chat_id, message_id, branch, job_id)
556
+ VALUES (?, ?, ?, ?, ?)
557
+ ON CONFLICT(project, chat_id, message_id)
558
+ DO UPDATE SET
559
+ branch = excluded.branch,
560
+ job_id = excluded.job_id,
561
+ updated_at = datetime('now')
562
+ """,
563
+ (project, chat_id, message_id, branch, job_id),
564
+ )
565
+ conn.commit()
566
+ finally:
567
+ conn.close()
568
+
569
+ def get_bound_branch(self, project: str, chat_id: int, message_id: int) -> str | None:
570
+ with self._lock:
571
+ conn = sqlite3.connect(self._db_path)
572
+ try:
573
+ row = conn.execute(
574
+ """
575
+ SELECT branch
576
+ FROM message_branch_links
577
+ WHERE project = ? AND chat_id = ? AND message_id = ?
578
+ """,
579
+ (project, chat_id, message_id),
580
+ ).fetchone()
581
+ finally:
582
+ conn.close()
583
+ return str(row[0]) if row is not None and row[0] is not None else None
584
+
585
+ def get_entries_for_branch(
586
+ self, project: str, chat_id: int, branch: str
587
+ ) -> list[tuple[str, str | None]]:
588
+ # 반환: (user_text, job_result_text or None) 시간순 목록.
589
+ with self._lock:
590
+ conn = sqlite3.connect(self._db_path)
591
+ try:
592
+ links = conn.execute(
593
+ """
594
+ SELECT message_id, job_id
595
+ FROM message_branch_links
596
+ WHERE project = ? AND chat_id = ? AND branch = ?
597
+ ORDER BY created_at ASC
598
+ """,
599
+ (project, chat_id, branch),
600
+ ).fetchall()
601
+ finally:
602
+ conn.close()
603
+
604
+ result: list[tuple[str, str | None]] = []
605
+ for message_id, job_id in links:
606
+ user_entry = self.get_user_entry_by_message_id(project, chat_id, message_id)
607
+ if user_entry is None:
608
+ continue
609
+ job_result: str | None = None
610
+ if job_id:
611
+ with self._lock:
612
+ conn = sqlite3.connect(self._db_path)
613
+ try:
614
+ row = conn.execute(
615
+ """
616
+ SELECT text FROM conversation_entries
617
+ WHERE project = ? AND chat_id = ? AND role = 'job_result' AND job_id = ?
618
+ ORDER BY id DESC LIMIT 1
619
+ """,
620
+ (project, chat_id, str(job_id)),
621
+ ).fetchone()
622
+ finally:
623
+ conn.close()
624
+ job_result = str(row[0]) if row is not None else None
625
+ else:
626
+ job_result = self.get_latest_job_result_text_for_user_message(
627
+ project, chat_id, message_id
628
+ )
629
+ result.append((user_entry.text, job_result))
630
+ return result
631
+
632
+ def generate_report(
633
+ self,
634
+ project: str,
635
+ chat_id: int,
636
+ recent_limit: int = 5,
637
+ ) -> ConversationReport | None:
638
+ safe_limit = max(0, recent_limit)
639
+ with self._lock:
640
+ conn = sqlite3.connect(self._db_path)
641
+ try:
642
+ total_entries = int(
643
+ conn.execute(
644
+ """
645
+ SELECT COUNT(*)
646
+ FROM conversation_entries
647
+ WHERE project = ? AND chat_id = ?
648
+ """,
649
+ (project, chat_id),
650
+ ).fetchone()[0]
651
+ )
652
+ if total_entries == 0:
653
+ return None
654
+
655
+ role_rows = conn.execute(
656
+ """
657
+ SELECT role, COUNT(*)
658
+ FROM conversation_entries
659
+ WHERE project = ? AND chat_id = ?
660
+ GROUP BY role
661
+ ORDER BY role
662
+ """,
663
+ (project, chat_id),
664
+ ).fetchall()
665
+ latest_user_row = conn.execute(
666
+ """
667
+ SELECT text
668
+ FROM conversation_entries
669
+ WHERE project = ? AND chat_id = ? AND role = 'user'
670
+ ORDER BY id DESC
671
+ LIMIT 1
672
+ """,
673
+ (project, chat_id),
674
+ ).fetchone()
675
+ latest_job_row = conn.execute(
676
+ """
677
+ SELECT job_id, text
678
+ FROM conversation_entries
679
+ WHERE project = ? AND chat_id = ? AND role = 'job_result'
680
+ ORDER BY id DESC
681
+ LIMIT 1
682
+ """,
683
+ (project, chat_id),
684
+ ).fetchone()
685
+ recent_rows: list[tuple[object, ...]] = []
686
+ if safe_limit > 0:
687
+ recent_rows = conn.execute(
688
+ """
689
+ SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
690
+ FROM conversation_entries
691
+ WHERE project = ? AND chat_id = ?
692
+ ORDER BY id DESC
693
+ LIMIT ?
694
+ """,
695
+ (project, chat_id, safe_limit),
696
+ ).fetchall()
697
+ finally:
698
+ conn.close()
699
+
700
+ recent_rows.reverse()
701
+ return ConversationReport(
702
+ project=project,
703
+ chat_id=chat_id,
704
+ total_entries=total_entries,
705
+ role_counts=[
706
+ ConversationRoleCount(role=str(role), count=int(count)) for role, count in role_rows
707
+ ],
708
+ latest_user_text=str(latest_user_row[0]) if latest_user_row is not None else None,
709
+ latest_job_id=str(latest_job_row[0]) if latest_job_row and latest_job_row[0] else None,
710
+ latest_job_result=str(latest_job_row[1]) if latest_job_row is not None else None,
711
+ recent_entries=[_row_to_entry(r) for r in recent_rows],
712
+ )
713
+
714
+ def get_chat_stats(self, project: str, chat_id: int) -> ConversationDbChatStats:
715
+ db_exists = self._db_path.exists()
716
+ size_bytes = self._db_path.stat().st_size if db_exists else 0
717
+ rows_by_role: dict[str, int] = {}
718
+ total_rows = 0
719
+ with self._lock:
720
+ conn = sqlite3.connect(self._db_path)
721
+ try:
722
+ total_rows = int(
723
+ conn.execute(
724
+ """
725
+ SELECT COUNT(*)
726
+ FROM conversation_entries
727
+ WHERE project = ? AND chat_id = ?
728
+ """,
729
+ (project, chat_id),
730
+ ).fetchone()[0]
731
+ )
732
+ for role, cnt in conn.execute(
733
+ """
734
+ SELECT role, COUNT(*)
735
+ FROM conversation_entries
736
+ WHERE project = ? AND chat_id = ?
737
+ GROUP BY role
738
+ ORDER BY role
739
+ """,
740
+ (project, chat_id),
741
+ ).fetchall():
742
+ rows_by_role[str(role)] = int(cnt)
743
+ finally:
744
+ conn.close()
745
+ return ConversationDbChatStats(
746
+ db_path=self._db_path,
747
+ db_exists=db_exists,
748
+ db_size_bytes=size_bytes,
749
+ total_rows=total_rows,
750
+ rows_by_role=rows_by_role,
751
+ )
752
+
753
+
754
+ def _row_to_entry(r: tuple[object, ...]) -> ConversationEntry:
755
+ return ConversationEntry(
756
+ id=int(r[0]),
757
+ project=str(r[1]),
758
+ chat_id=int(r[2]),
759
+ role=str(r[3]),
760
+ text=str(r[4]),
761
+ job_id=str(r[5]) if r[5] is not None else None,
762
+ message_id=int(r[6]) if len(r) > 6 and r[6] is not None else None,
763
+ reply_to_message_id=int(r[7]) if len(r) > 7 and r[7] is not None else None,
764
+ )
765
+
766
+
767
+ class ConversationContextBuilder:
768
+ @staticmethod
769
+ def build(entries: list[ConversationEntry], current_user_line: str, language: UiLanguage = UiLanguage.ENGLISH) -> str:
770
+ labels = instruction_frame_labels(language)
771
+ lines: list[str] = [
772
+ labels.prev_context_open,
773
+ ]
774
+ for e in entries:
775
+ label = e.role
776
+ if e.job_id:
777
+ label = f"{e.role} (job_id={e.job_id})"
778
+ snippet = _truncate_snippet(e.text, 800)
779
+ lines.append(f"{label}: {snippet}")
780
+ lines.extend(
781
+ [
782
+ labels.prev_context_close,
783
+ "",
784
+ labels.current_request_open,
785
+ current_user_line.strip(),
786
+ labels.current_request_close,
787
+ ]
788
+ )
789
+ return "\n".join(lines)