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,185 @@
1
+ from __future__ import annotations
2
+
3
+ from app.monitoring.code import count_project_code, format_code_monitor
4
+ from app.monitoring.git import format_branch_monitor, format_worktree_monitor
5
+ from app.monitoring.memory import format_memory_monitor
6
+ from app.monitoring.model import format_model_monitor
7
+ from app.projects.registry import ProjectRecord
8
+ from app.telegram.commands.base import (
9
+ CommandContext,
10
+ InlineButton,
11
+ TelegramCommand,
12
+ TelegramMessage,
13
+ _cmd_evt,
14
+ effective_model_for_chat,
15
+ effective_project_name_for_chat,
16
+ )
17
+
18
+ _VALID_SUBCOMMANDS = {"model", "memory", "branch", "worktrees", "code", "project"}
19
+
20
+
21
+ class MonitorCommand(TelegramCommand):
22
+ name = "/monitor"
23
+ description = "Check model, memory, branch, and worktree status"
24
+
25
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
26
+ tokens = message.text.strip().split()
27
+ if len(tokens) < 2:
28
+ _cmd_evt.info("monitor usage requested", chat_id=message.chat_id, user_id=message.user_id)
29
+ return "Choose a monitoring view."
30
+
31
+ sub = tokens[1].lower()
32
+ if sub not in _VALID_SUBCOMMANDS:
33
+ _cmd_evt.warning(
34
+ "monitor invalid subcommand sub=%s",
35
+ sub,
36
+ chat_id=message.chat_id,
37
+ user_id=message.user_id,
38
+ )
39
+ return "Usage\n\n- /monitor <model|memory|branch|worktrees|code|project>\n- Example: /monitor memory"
40
+
41
+ if sub == "project":
42
+ return self._view_project(message, ctx)
43
+
44
+ entry = self._resolve_enabled_project(message, ctx, sub)
45
+ if isinstance(entry, str):
46
+ return entry
47
+ project_name = entry.name
48
+
49
+ _cmd_evt.info(
50
+ "monitor requested sub=%s",
51
+ sub,
52
+ chat_id=message.chat_id,
53
+ user_id=message.user_id,
54
+ project=project_name,
55
+ )
56
+ if sub == "model":
57
+ return self._view_model(message, ctx, project_name)
58
+ if sub == "memory":
59
+ return self._view_memory(message, ctx, project_name)
60
+ if sub == "branch":
61
+ return format_branch_monitor(
62
+ ctx.git_service, entry.root_path, ctx.git_remote_name, project_name
63
+ )
64
+ if sub == "worktrees":
65
+ return format_worktree_monitor(
66
+ ctx.git_service, entry.root_path, entry.worktree_base_dir, project_name
67
+ )
68
+ return self._view_code(message, ctx, entry, project_name)
69
+
70
+ def _resolve_enabled_project(
71
+ self, message: TelegramMessage, ctx: CommandContext, sub: str
72
+ ) -> ProjectRecord | str:
73
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
74
+ if not project_name:
75
+ _cmd_evt.warning(
76
+ "monitor requested with no project sub=%s",
77
+ sub,
78
+ chat_id=message.chat_id,
79
+ user_id=message.user_id,
80
+ )
81
+ return "No projects are registered. Register one at http://127.0.0.1:8000/projects."
82
+
83
+ entry = ctx.project_registry.get(project_name)
84
+ if not entry:
85
+ _cmd_evt.warning(
86
+ "monitor unknown project sub=%s",
87
+ sub,
88
+ chat_id=message.chat_id,
89
+ user_id=message.user_id,
90
+ project=project_name,
91
+ )
92
+ return f"Unknown project: {project_name}"
93
+ if not entry.enabled:
94
+ _cmd_evt.warning(
95
+ "monitor disabled project sub=%s",
96
+ sub,
97
+ chat_id=message.chat_id,
98
+ user_id=message.user_id,
99
+ project=project_name,
100
+ )
101
+ return f"Disabled project: {project_name}"
102
+ return entry
103
+
104
+ def _view_project(self, message: TelegramMessage, ctx: CommandContext) -> str:
105
+ effective = effective_project_name_for_chat(ctx, message.chat_id)
106
+ if not effective:
107
+ return "Could not find the project context for this bot."
108
+ entry = ctx.project_registry.get(effective)
109
+ if entry is None:
110
+ return f"Unknown project: {effective}"
111
+ _cmd_evt.info(
112
+ "monitor project requested effective=%s",
113
+ effective or "-",
114
+ chat_id=message.chat_id,
115
+ user_id=message.user_id,
116
+ project=effective,
117
+ )
118
+ state = "on" if entry.enabled else "off"
119
+ return "\n".join(
120
+ [
121
+ f"This bot project: {entry.name}",
122
+ f"Status: {state}",
123
+ f"root_path: {entry.root_path}",
124
+ f"default_model: {entry.default_model.value}",
125
+ f"worktree_base_dir: {entry.worktree_base_dir}",
126
+ ]
127
+ )
128
+
129
+ def _view_model(self, message: TelegramMessage, ctx: CommandContext, project_name: str) -> str:
130
+ current = effective_model_for_chat(ctx, message.chat_id, project_name)
131
+ body = format_model_monitor(
132
+ current,
133
+ recent_jobs=ctx.job_store.list_recent_for_project_chat(
134
+ project_name, message.chat_id, 50
135
+ ),
136
+ chat_id=message.chat_id,
137
+ project=project_name,
138
+ )
139
+ return f"Current chat default model: {current.value}\n\n{body}"
140
+
141
+ def _view_memory(self, message: TelegramMessage, ctx: CommandContext, project_name: str) -> str:
142
+ if ctx.conversation_store is None:
143
+ return "Conversation memory store is not configured."
144
+ stats = ctx.conversation_store.get_chat_stats(project_name, message.chat_id)
145
+ return format_memory_monitor(stats, project_name, message.chat_id)
146
+
147
+ def _view_code(
148
+ self, message: TelegramMessage, ctx: CommandContext, entry: ProjectRecord, project_name: str
149
+ ) -> str:
150
+ stats = count_project_code(
151
+ entry.root_path,
152
+ worktree_base_dir=entry.worktree_base_dir,
153
+ )
154
+ _cmd_evt.info(
155
+ "monitor code counted files=%d lines=%d skipped=%d",
156
+ stats.files_scanned,
157
+ stats.total_lines,
158
+ stats.skipped_binary_or_error,
159
+ chat_id=message.chat_id,
160
+ user_id=message.user_id,
161
+ project=project_name,
162
+ )
163
+ return format_code_monitor(stats, project_name, entry.root_path)
164
+
165
+ def get_inline_buttons(
166
+ self,
167
+ message: TelegramMessage | None = None,
168
+ ctx: CommandContext | None = None,
169
+ ) -> list[list[InlineButton]] | None:
170
+ _ = ctx
171
+ tokens = message.text.strip().split() if message is not None else []
172
+ if len(tokens) != 1:
173
+ return None
174
+ return [
175
+ [
176
+ InlineButton("model", "/monitor model"),
177
+ InlineButton("memory", "/monitor memory"),
178
+ InlineButton("branch", "/monitor branch"),
179
+ ],
180
+ [
181
+ InlineButton("worktrees", "/monitor worktrees"),
182
+ InlineButton("code", "/monitor code"),
183
+ InlineButton("project", "/monitor project"),
184
+ ],
185
+ ]
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from app.models import UiLanguage
4
+ from app.telegram.commands.base import (
5
+ CommandContext,
6
+ CommandResponse,
7
+ ConfirmableCommand,
8
+ TelegramCommand,
9
+ TelegramMessage,
10
+ _help_response_skips_notifier_body_i18n,
11
+ )
12
+ from app.telegram.commands.branch import BranchCommand, PrCommand, PullCommand, RebaseCommand
13
+ from app.telegram.commands.clear_stop import ClearCommand, StopCommand
14
+ from app.telegram.commands.fix import FixCommand
15
+ from app.telegram.commands.model import ModelCommand
16
+ from app.telegram.commands.monitor import MonitorCommand
17
+ from app.telegram.commands.status import ReportsCommand, StatusCommand
18
+ from app.telegram.commands.system import HelpCommand, InitCommand, StartCommand
19
+ from app.telegram.i18n import translate_text
20
+
21
+
22
+ class CommandRegistry:
23
+ def __init__(self, commands: list[TelegramCommand]) -> None:
24
+ self._commands = {command.name: command for command in commands}
25
+ help_cmd = self._commands.get("/help")
26
+ if isinstance(help_cmd, HelpCommand):
27
+ help_cmd._registry = self._commands
28
+
29
+ def dispatch(self, message: TelegramMessage, ctx: CommandContext) -> str | None:
30
+ tokens = message.text.strip().split()
31
+ head = tokens[0] if tokens else ""
32
+ scope_project = ctx.project_name
33
+
34
+ if head == "/init":
35
+ init_cmd = self._commands.get("/init")
36
+ if init_cmd is not None:
37
+ ctx.confirmation_store.pop(scope_project, message.chat_id)
38
+ return init_cmd.execute(message, ctx)
39
+
40
+ if head in {"/plan", "/ask"}:
41
+ return None
42
+
43
+ pending = ctx.confirmation_store.get(scope_project, message.chat_id)
44
+ if pending is not None:
45
+ command = self._commands.get(pending.command_name)
46
+ confirmed = ctx.confirmation_store.pop(scope_project, message.chat_id)
47
+ if isinstance(command, ConfirmableCommand) and confirmed is not None:
48
+ return command.confirm(message, ctx, confirmed)
49
+ return "Could not process the pending confirmation."
50
+
51
+ if not head.startswith("/"):
52
+ return None
53
+ command = self._commands.get(head)
54
+ if not command:
55
+ return "Unknown command. See /help."
56
+ return command.execute(message, ctx)
57
+
58
+ def dispatch_rich(self, message: TelegramMessage, ctx: CommandContext) -> CommandResponse | None:
59
+ text = self.dispatch(message, ctx)
60
+ if text is None:
61
+ return None
62
+ tokens = message.text.strip().split()
63
+ head = tokens[0] if tokens else ""
64
+ command = self._commands.get(head)
65
+ buttons = command.get_inline_buttons(message, ctx) if command is not None else None
66
+ skip_body = _help_response_skips_notifier_body_i18n(message.text)
67
+ return CommandResponse(text=text, inline_buttons=buttons, skip_notifier_body_i18n=skip_body)
68
+
69
+ def bot_commands(self, language: UiLanguage = UiLanguage.ENGLISH) -> list[dict[str, str]]:
70
+ base = [
71
+ {
72
+ "command": command.name.removeprefix("/"),
73
+ "description": translate_text(command.description, language),
74
+ }
75
+ for command in self._commands.values()
76
+ if command.description
77
+ ]
78
+ return base + [
79
+ {
80
+ "command": "plan",
81
+ "description": translate_text("plan mode message (example: /plan review login flow)", language),
82
+ },
83
+ {
84
+ "command": "ask",
85
+ "description": translate_text("ask mode message (example: /ask explain the JobManager role)", language),
86
+ },
87
+ ]
88
+
89
+
90
+ def build_default_commands() -> list[TelegramCommand]:
91
+ return [
92
+ StartCommand(),
93
+ HelpCommand(),
94
+ ModelCommand(),
95
+ StatusCommand(),
96
+ InitCommand(),
97
+ ReportsCommand(),
98
+ BranchCommand(),
99
+ PullCommand(),
100
+ RebaseCommand(),
101
+ PrCommand(),
102
+ MonitorCommand(),
103
+ ClearCommand(),
104
+ StopCommand(),
105
+ FixCommand(),
106
+ ]
107
+
108
+
109
+ def default_telegram_bot_commands() -> list[dict[str, str]]:
110
+ return CommandRegistry(build_default_commands()).bot_commands()
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from app.ai.model_catalog import format_model_selection
6
+ from app.ai.usage import format_token_usage
7
+ from app.jobs.schemas import Job
8
+ from app.telegram.commands.base import (
9
+ CommandContext,
10
+ InlineButton,
11
+ TelegramCommand,
12
+ TelegramMessage,
13
+ _button_rows,
14
+ _job_button_label,
15
+ effective_project_name_for_chat,
16
+ format_usage,
17
+ )
18
+
19
+ _STATUS_EMOJI: dict[str, str] = {
20
+ "queued": "⏳",
21
+ "running": "🔄",
22
+ "succeeded": "✅",
23
+ "failed": "❌",
24
+ "cancelled": "⛔",
25
+ }
26
+ _STDOUT_TAIL = 1500
27
+ _STDERR_TAIL = 800
28
+ _MAX_CHANGED_FILES = 10
29
+
30
+
31
+ class StatusCommand(TelegramCommand):
32
+ name = "/status"
33
+ description = "Show recent jobs and job status"
34
+
35
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
36
+ tokens = message.text.strip().split()
37
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
38
+ if len(tokens) == 1:
39
+ if not project_name:
40
+ return (
41
+ "No project is registered. "
42
+ "Register a project at http://127.0.0.1:8000/projects."
43
+ )
44
+ limit = self._job_limit(ctx)
45
+ jobs = ctx.job_store.list_recent_for_project_chat(
46
+ project_name, message.chat_id, limit
47
+ )
48
+ if not jobs:
49
+ return "No jobs are available."
50
+ return "Choose a job to inspect."
51
+ if len(tokens) != 2:
52
+ return format_usage("/status <job_id>")
53
+ job = ctx.job_store.get(tokens[1])
54
+ if not job:
55
+ return "Job ID not found."
56
+ if project_name and job.request.project != project_name:
57
+ return "Job ID not found."
58
+ return self._format_job_detail(job)
59
+
60
+ @staticmethod
61
+ def _fmt_time(dt: datetime) -> str:
62
+ return dt.astimezone().strftime("%H:%M:%S")
63
+
64
+ @staticmethod
65
+ def _duration_str(seconds: int) -> str:
66
+ mins, secs = divmod(seconds, 60)
67
+ return f"{mins}m {secs}s" if mins > 0 else f"{secs}s"
68
+
69
+ @classmethod
70
+ def _format_job_detail(cls, job: Job) -> str:
71
+ lines: list[str] = []
72
+ emoji = _STATUS_EMOJI.get(job.status.value, "")
73
+ lines.append(f"Job {job.id}")
74
+ lines.append("")
75
+ lines.append(f"- Status: {job.status.value} {emoji}")
76
+ lines.append(f"- Project: {job.request.project}")
77
+ requested_model = format_model_selection(job.request.model, job.request.model_id)
78
+ lines.append(f"- Requested model: {requested_model}")
79
+ lines.append(f"- Model used: {job.runner_actual_model or requested_model}")
80
+ lines.append(f"- Token usage: {format_token_usage(job.runner_token_usage) or 'unavailable'}")
81
+
82
+ instr = job.request.instruction.strip().replace("\n", " ")
83
+ if len(instr) > 80:
84
+ instr = instr[:80].rstrip() + "..."
85
+ lines.append(f"- Instruction: {instr}")
86
+
87
+ now = datetime.now(UTC)
88
+ started = job.started_at
89
+ finished = job.finished_at
90
+ if started:
91
+ if finished:
92
+ elapsed = int((finished - started).total_seconds())
93
+ lines.append(
94
+ f"- Started: {cls._fmt_time(started)} → Finished: {cls._fmt_time(finished)}"
95
+ f" (duration: {cls._duration_str(elapsed)})"
96
+ )
97
+ else:
98
+ elapsed = int((now - started).total_seconds())
99
+ lines.append(
100
+ f"- Started: {cls._fmt_time(started)} (elapsed: {cls._duration_str(elapsed)})"
101
+ )
102
+ else:
103
+ lines.append(f"- Created: {cls._fmt_time(job.created_at)}")
104
+
105
+ if job.status.value == "succeeded":
106
+ if job.branch:
107
+ lines.append(f"- Branch: {job.branch}")
108
+ if job.commit_hash:
109
+ lines.append(f"- Commit: {job.commit_hash[:8]}")
110
+ if job.changed_files:
111
+ lines.append("")
112
+ lines.append(f"Changed files ({len(job.changed_files)} files)")
113
+ for f in job.changed_files[:_MAX_CHANGED_FILES]:
114
+ lines.append(f"- {f}")
115
+ if len(job.changed_files) > _MAX_CHANGED_FILES:
116
+ lines.append(f"- ... and {len(job.changed_files) - _MAX_CHANGED_FILES} more")
117
+ else:
118
+ lines.append("- Changed files: none (no-op)")
119
+ if job.runner_stdout_summary:
120
+ lines.append("")
121
+ lines.append("[AI output summary]")
122
+ summary = job.runner_stdout_summary
123
+ if len(summary) > _STDOUT_TAIL:
124
+ summary = "...(truncated)\n" + summary[-_STDOUT_TAIL:]
125
+ lines.append(summary)
126
+
127
+ elif job.status.value == "failed":
128
+ if job.error_stage:
129
+ lines.append(f"- Error stage: {job.error_stage}")
130
+ if job.error:
131
+ lines.append(f"- Error: {job.error[:300]}")
132
+ if job.runner_stderr_summary:
133
+ lines.append("")
134
+ lines.append("[stderr]")
135
+ lines.append(job.runner_stderr_summary[-_STDERR_TAIL:])
136
+
137
+ elif job.status.value == "running" and job.runner_stdout_summary:
138
+ lines.append("")
139
+ lines.append("[Current output]")
140
+ lines.append(job.runner_stdout_summary[-_STDOUT_TAIL:])
141
+
142
+ return "\n".join(lines)
143
+
144
+ @staticmethod
145
+ def _job_limit(ctx: CommandContext) -> int:
146
+ if ctx.advanced_settings_store is None:
147
+ return 10
148
+ return ctx.advanced_settings_store.get().status_recent_job_limit
149
+
150
+ def get_inline_buttons(
151
+ self,
152
+ message: TelegramMessage | None = None,
153
+ ctx: CommandContext | None = None,
154
+ ) -> list[list[InlineButton]] | None:
155
+ if message is None or ctx is None:
156
+ return None
157
+ if len(message.text.strip().split()) != 1:
158
+ return None
159
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
160
+ if not project_name:
161
+ return None
162
+ limit = self._job_limit(ctx)
163
+ jobs = ctx.job_store.list_recent_for_project_chat(
164
+ project_name, message.chat_id, limit
165
+ )
166
+ if not jobs:
167
+ return None
168
+ return _button_rows(
169
+ [InlineButton(_job_button_label(job), f"/status {job.id}") for job in jobs],
170
+ per_row=1,
171
+ )
172
+
173
+
174
+ class ReportsCommand(TelegramCommand):
175
+ name = "/reports"
176
+ description = "Show this chat's conversation memory summary"
177
+
178
+ _DEFAULT_RECENT_LIMIT = 5
179
+ _MAX_RECENT_LIMIT = 10
180
+
181
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
182
+ tokens = message.text.strip().split()
183
+ if len(tokens) > 2:
184
+ return "Usage: /reports or /reports <recent_limit>"
185
+
186
+ recent_limit = self._DEFAULT_RECENT_LIMIT
187
+ if len(tokens) == 2:
188
+ try:
189
+ recent_limit = int(tokens[1])
190
+ except ValueError:
191
+ return "Usage: /reports or /reports <recent_limit>"
192
+ if recent_limit < 1 or recent_limit > self._MAX_RECENT_LIMIT:
193
+ return f"recent_limit must be a number between 1 and {self._MAX_RECENT_LIMIT}."
194
+
195
+ if ctx.conversation_store is None:
196
+ return "Conversation memory storage is not configured."
197
+
198
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
199
+ if not project_name:
200
+ return (
201
+ "No project is registered. "
202
+ "Register a project at http://127.0.0.1:8000/projects."
203
+ )
204
+
205
+ entry = ctx.project_registry.get(project_name)
206
+ if not entry:
207
+ return f"Unknown project: {project_name}"
208
+ if not entry.enabled:
209
+ return f"Disabled project: {project_name}"
210
+
211
+ report = ctx.conversation_store.generate_report(project_name, message.chat_id, recent_limit)
212
+ if report is None:
213
+ return f"No conversation memory is stored. (project={project_name})"
214
+
215
+ lines = [
216
+ "Memory report",
217
+ f"Project: {project_name}",
218
+ f"Total entries: {report.total_entries}",
219
+ f"User requests: {report.count_for('user')}",
220
+ f"Jobs accepted: {report.count_for('job_accepted')}",
221
+ f"Job results: {report.count_for('job_result')}",
222
+ ]
223
+ if report.latest_user_text:
224
+ lines.append(f"Latest user request: {self._truncate(report.latest_user_text)}")
225
+ if report.latest_job_result:
226
+ job_label = report.latest_job_id or "(no job_id)"
227
+ lines.append(f"Latest job result: {job_label} {self._truncate(report.latest_job_result)}")
228
+ if report.recent_entries:
229
+ lines.append("")
230
+ lines.append("Recent memory")
231
+ for item in report.recent_entries:
232
+ label = item.role
233
+ if item.job_id:
234
+ label = f"{label}:{item.job_id}"
235
+ lines.append(f"- [{label}] {self._truncate(item.text, limit=90)}")
236
+ return "\n".join(lines)
237
+
238
+ @staticmethod
239
+ def _truncate(text: str, limit: int = 120) -> str:
240
+ normalized = text.strip().replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
241
+ if len(normalized) <= limit:
242
+ return normalized
243
+ return normalized[:limit].rstrip() + "..."