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
|
@@ -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() + "..."
|