emdash-cli 0.1.46__py3-none-any.whl → 0.1.70__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.
- emdash_cli/client.py +12 -28
- emdash_cli/commands/__init__.py +2 -2
- emdash_cli/commands/agent/constants.py +78 -0
- emdash_cli/commands/agent/handlers/__init__.py +10 -0
- emdash_cli/commands/agent/handlers/agents.py +67 -39
- emdash_cli/commands/agent/handlers/index.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +119 -0
- emdash_cli/commands/agent/handlers/registry.py +72 -0
- emdash_cli/commands/agent/handlers/rules.py +48 -31
- emdash_cli/commands/agent/handlers/sessions.py +1 -1
- emdash_cli/commands/agent/handlers/setup.py +187 -54
- emdash_cli/commands/agent/handlers/skills.py +42 -4
- emdash_cli/commands/agent/handlers/telegram.py +523 -0
- emdash_cli/commands/agent/handlers/todos.py +55 -34
- emdash_cli/commands/agent/handlers/verify.py +10 -5
- emdash_cli/commands/agent/help.py +236 -0
- emdash_cli/commands/agent/interactive.py +278 -47
- emdash_cli/commands/agent/menus.py +116 -84
- emdash_cli/commands/agent/onboarding.py +619 -0
- emdash_cli/commands/agent/session_restore.py +210 -0
- emdash_cli/commands/index.py +111 -13
- emdash_cli/commands/registry.py +635 -0
- emdash_cli/commands/skills.py +72 -6
- emdash_cli/design.py +328 -0
- emdash_cli/diff_renderer.py +438 -0
- emdash_cli/integrations/__init__.py +1 -0
- emdash_cli/integrations/telegram/__init__.py +15 -0
- emdash_cli/integrations/telegram/bot.py +402 -0
- emdash_cli/integrations/telegram/bridge.py +980 -0
- emdash_cli/integrations/telegram/config.py +155 -0
- emdash_cli/integrations/telegram/formatter.py +392 -0
- emdash_cli/main.py +52 -2
- emdash_cli/sse_renderer.py +632 -171
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/METADATA +2 -2
- emdash_cli-0.1.70.dist-info/RECORD +63 -0
- emdash_cli/commands/swarm.py +0 -86
- emdash_cli-0.1.46.dist-info/RECORD +0 -49
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Telegram configuration management.
|
|
2
|
+
|
|
3
|
+
Handles storage and retrieval of Telegram bot configuration including
|
|
4
|
+
bot token, authorized chat IDs, and user preferences.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field, asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Config file location
|
|
14
|
+
CONFIG_DIR = Path.home() / ".emdash"
|
|
15
|
+
CONFIG_FILE = CONFIG_DIR / "telegram.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TelegramSettings:
|
|
20
|
+
"""User settings for Telegram integration."""
|
|
21
|
+
|
|
22
|
+
# How to display streaming responses: "edit" updates a single message,
|
|
23
|
+
# "append" sends new messages for each update
|
|
24
|
+
streaming_mode: str = "edit"
|
|
25
|
+
|
|
26
|
+
# Minimum interval between message edits (ms) to avoid rate limits
|
|
27
|
+
update_interval_ms: int = 500
|
|
28
|
+
|
|
29
|
+
# Whether to show agent thinking/reasoning
|
|
30
|
+
show_thinking: bool = False
|
|
31
|
+
|
|
32
|
+
# Whether to show tool calls and results
|
|
33
|
+
show_tool_calls: bool = True
|
|
34
|
+
|
|
35
|
+
# Use compact formatting for responses
|
|
36
|
+
compact_mode: bool = False
|
|
37
|
+
|
|
38
|
+
# Maximum message length before splitting (Telegram limit is 4096)
|
|
39
|
+
max_message_length: int = 4000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TelegramState:
|
|
44
|
+
"""Runtime state for Telegram integration."""
|
|
45
|
+
|
|
46
|
+
# Whether the integration is enabled
|
|
47
|
+
enabled: bool = False
|
|
48
|
+
|
|
49
|
+
# Last successful connection timestamp (ISO format)
|
|
50
|
+
last_connected: str | None = None
|
|
51
|
+
|
|
52
|
+
# Last update_id processed (for long-polling offset)
|
|
53
|
+
last_update_id: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class TelegramConfig:
|
|
58
|
+
"""Complete Telegram configuration."""
|
|
59
|
+
|
|
60
|
+
# Bot token from @BotFather
|
|
61
|
+
bot_token: str | None = None
|
|
62
|
+
|
|
63
|
+
# List of authorized chat IDs that can interact with the bot
|
|
64
|
+
authorized_chats: list[int] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
# User settings
|
|
67
|
+
settings: TelegramSettings = field(default_factory=TelegramSettings)
|
|
68
|
+
|
|
69
|
+
# Runtime state
|
|
70
|
+
state: TelegramState = field(default_factory=TelegramState)
|
|
71
|
+
|
|
72
|
+
def is_configured(self) -> bool:
|
|
73
|
+
"""Check if the bot token is configured."""
|
|
74
|
+
return bool(self.bot_token)
|
|
75
|
+
|
|
76
|
+
def is_chat_authorized(self, chat_id: int) -> bool:
|
|
77
|
+
"""Check if a chat ID is authorized to use the bot."""
|
|
78
|
+
# If no chats are configured, allow all (during setup)
|
|
79
|
+
if not self.authorized_chats:
|
|
80
|
+
return True
|
|
81
|
+
return chat_id in self.authorized_chats
|
|
82
|
+
|
|
83
|
+
def add_authorized_chat(self, chat_id: int) -> None:
|
|
84
|
+
"""Add a chat ID to the authorized list."""
|
|
85
|
+
if chat_id not in self.authorized_chats:
|
|
86
|
+
self.authorized_chats.append(chat_id)
|
|
87
|
+
|
|
88
|
+
def remove_authorized_chat(self, chat_id: int) -> None:
|
|
89
|
+
"""Remove a chat ID from the authorized list."""
|
|
90
|
+
if chat_id in self.authorized_chats:
|
|
91
|
+
self.authorized_chats.remove(chat_id)
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
"""Convert config to dictionary for JSON serialization."""
|
|
95
|
+
return {
|
|
96
|
+
"bot_token": self.bot_token,
|
|
97
|
+
"authorized_chats": self.authorized_chats,
|
|
98
|
+
"settings": asdict(self.settings),
|
|
99
|
+
"state": asdict(self.state),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls, data: dict[str, Any]) -> "TelegramConfig":
|
|
104
|
+
"""Create config from dictionary."""
|
|
105
|
+
settings_data = data.get("settings", {})
|
|
106
|
+
state_data = data.get("state", {})
|
|
107
|
+
|
|
108
|
+
return cls(
|
|
109
|
+
bot_token=data.get("bot_token"),
|
|
110
|
+
authorized_chats=data.get("authorized_chats", []),
|
|
111
|
+
settings=TelegramSettings(**settings_data),
|
|
112
|
+
state=TelegramState(**state_data),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_config() -> TelegramConfig:
|
|
117
|
+
"""Load Telegram configuration from disk.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
TelegramConfig instance (empty config if file doesn't exist)
|
|
121
|
+
"""
|
|
122
|
+
if not CONFIG_FILE.exists():
|
|
123
|
+
return TelegramConfig()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
with open(CONFIG_FILE, "r") as f:
|
|
127
|
+
data = json.load(f)
|
|
128
|
+
return TelegramConfig.from_dict(data)
|
|
129
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
130
|
+
# Return empty config if file is corrupted
|
|
131
|
+
return TelegramConfig()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def save_config(config: TelegramConfig) -> None:
|
|
135
|
+
"""Save Telegram configuration to disk.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
config: TelegramConfig instance to save
|
|
139
|
+
"""
|
|
140
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
with open(CONFIG_FILE, "w") as f:
|
|
143
|
+
json.dump(config.to_dict(), f, indent=2)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def delete_config() -> bool:
|
|
147
|
+
"""Delete the Telegram configuration file.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if file was deleted, False if it didn't exist
|
|
151
|
+
"""
|
|
152
|
+
if CONFIG_FILE.exists():
|
|
153
|
+
CONFIG_FILE.unlink()
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Format SSE events for Telegram messages.
|
|
2
|
+
|
|
3
|
+
Converts EmDash agent SSE events into Telegram-friendly formatted messages.
|
|
4
|
+
Handles markdown escaping, message length limits, and visual formatting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Telegram message length limit
|
|
12
|
+
MAX_MESSAGE_LENGTH = 4096
|
|
13
|
+
|
|
14
|
+
# Status icons for Telegram
|
|
15
|
+
ICON_SESSION = "🚀"
|
|
16
|
+
ICON_THINKING = "💭"
|
|
17
|
+
ICON_TOOL = "🔧"
|
|
18
|
+
ICON_TOOL_SUCCESS = "✅"
|
|
19
|
+
ICON_TOOL_ERROR = "❌"
|
|
20
|
+
ICON_RESPONSE = "💬"
|
|
21
|
+
ICON_ERROR = "⚠️"
|
|
22
|
+
ICON_COMPLETE = "✨"
|
|
23
|
+
ICON_PROGRESS = "⏳"
|
|
24
|
+
ICON_CLARIFICATION = "❓"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def escape_markdown(text: str) -> str:
|
|
28
|
+
"""Escape special characters for Telegram Markdown.
|
|
29
|
+
|
|
30
|
+
Telegram uses a subset of Markdown. Characters that need escaping:
|
|
31
|
+
_ * [ ] ( ) ~ ` > # + - = | { } . !
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
text: Raw text to escape
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Escaped text safe for Telegram Markdown
|
|
38
|
+
"""
|
|
39
|
+
# Characters to escape for MarkdownV2
|
|
40
|
+
# For regular Markdown mode, we only need to escape a few
|
|
41
|
+
escape_chars = ["_", "*", "[", "`"]
|
|
42
|
+
for char in escape_chars:
|
|
43
|
+
text = text.replace(char, f"\\{char}")
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def truncate_text(text: str, max_length: int = MAX_MESSAGE_LENGTH) -> str:
|
|
48
|
+
"""Truncate text to fit Telegram's message limit.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
text: Text to truncate
|
|
52
|
+
max_length: Maximum length (default: Telegram's 4096 limit)
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Truncated text with ellipsis if needed
|
|
56
|
+
"""
|
|
57
|
+
if len(text) <= max_length:
|
|
58
|
+
return text
|
|
59
|
+
return text[: max_length - 3] + "..."
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class TelegramMessage:
|
|
64
|
+
"""A formatted message ready for Telegram."""
|
|
65
|
+
|
|
66
|
+
text: str
|
|
67
|
+
parse_mode: str | None = "Markdown"
|
|
68
|
+
is_update: bool = False # If True, update previous message instead of sending new
|
|
69
|
+
|
|
70
|
+
def __post_init__(self):
|
|
71
|
+
# Ensure text fits in Telegram's limit
|
|
72
|
+
self.text = truncate_text(self.text)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MessageAggregator:
|
|
77
|
+
"""Aggregates partial responses into complete messages.
|
|
78
|
+
|
|
79
|
+
Handles rate limiting by batching updates and only sending
|
|
80
|
+
when enough content has accumulated or time has passed.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Current accumulated content
|
|
84
|
+
content: str = ""
|
|
85
|
+
|
|
86
|
+
# Whether we have unsent content
|
|
87
|
+
dirty: bool = False
|
|
88
|
+
|
|
89
|
+
# Message ID of the message being updated (for edit mode)
|
|
90
|
+
message_id: int | None = None
|
|
91
|
+
|
|
92
|
+
# Minimum characters before sending an update
|
|
93
|
+
min_update_chars: int = 50
|
|
94
|
+
|
|
95
|
+
# Current tool being executed (for status display)
|
|
96
|
+
current_tool: str | None = None
|
|
97
|
+
|
|
98
|
+
# Session info
|
|
99
|
+
session_id: str | None = None
|
|
100
|
+
|
|
101
|
+
# Completed tools for summary
|
|
102
|
+
completed_tools: list = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
def add_partial(self, content: str) -> bool:
|
|
105
|
+
"""Add partial content.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
content: Partial response content to add
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if enough content has accumulated to send an update
|
|
112
|
+
"""
|
|
113
|
+
self.content += content
|
|
114
|
+
self.dirty = True
|
|
115
|
+
return len(self.content) >= self.min_update_chars
|
|
116
|
+
|
|
117
|
+
def get_update_message(self) -> TelegramMessage | None:
|
|
118
|
+
"""Get the current content as an update message.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
TelegramMessage if there's content to send, None otherwise
|
|
122
|
+
"""
|
|
123
|
+
if not self.content:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
self.dirty = False
|
|
127
|
+
return TelegramMessage(
|
|
128
|
+
text=self.content,
|
|
129
|
+
is_update=self.message_id is not None,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def reset(self) -> None:
|
|
133
|
+
"""Reset the aggregator for a new response."""
|
|
134
|
+
self.content = ""
|
|
135
|
+
self.dirty = False
|
|
136
|
+
self.message_id = None
|
|
137
|
+
self.current_tool = None
|
|
138
|
+
self.completed_tools = []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SSEEventFormatter:
|
|
142
|
+
"""Formats SSE events into Telegram messages."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, show_thinking: bool = False, show_tools: bool = True, compact: bool = False):
|
|
145
|
+
"""Initialize the formatter.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
show_thinking: Whether to show agent thinking/reasoning
|
|
149
|
+
show_tools: Whether to show tool calls
|
|
150
|
+
compact: Use compact formatting
|
|
151
|
+
"""
|
|
152
|
+
self.show_thinking = show_thinking
|
|
153
|
+
self.show_tools = show_tools
|
|
154
|
+
self.compact = compact
|
|
155
|
+
self.aggregator = MessageAggregator()
|
|
156
|
+
|
|
157
|
+
def format_event(self, event_type: str, data: dict) -> TelegramMessage | None:
|
|
158
|
+
"""Format an SSE event into a Telegram message.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
event_type: Type of SSE event
|
|
162
|
+
data: Event data dict
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
TelegramMessage if the event should be sent, None to skip
|
|
166
|
+
"""
|
|
167
|
+
if event_type == "session_start":
|
|
168
|
+
return self._format_session_start(data)
|
|
169
|
+
elif event_type == "thinking":
|
|
170
|
+
return self._format_thinking(data)
|
|
171
|
+
elif event_type == "tool_start":
|
|
172
|
+
return self._format_tool_start(data)
|
|
173
|
+
elif event_type == "tool_result":
|
|
174
|
+
return self._format_tool_result(data)
|
|
175
|
+
elif event_type == "partial_response":
|
|
176
|
+
return self._format_partial(data)
|
|
177
|
+
elif event_type == "response":
|
|
178
|
+
return self._format_response(data)
|
|
179
|
+
elif event_type == "error":
|
|
180
|
+
return self._format_error(data)
|
|
181
|
+
elif event_type == "clarification":
|
|
182
|
+
return self._format_clarification(data)
|
|
183
|
+
elif event_type == "session_end":
|
|
184
|
+
return self._format_session_end(data)
|
|
185
|
+
elif event_type == "progress":
|
|
186
|
+
return self._format_progress(data)
|
|
187
|
+
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def _format_session_start(self, data: dict) -> TelegramMessage:
|
|
191
|
+
"""Format session start event."""
|
|
192
|
+
self.aggregator.reset()
|
|
193
|
+
self.aggregator.session_id = data.get("session_id")
|
|
194
|
+
|
|
195
|
+
agent = data.get("agent_name", "Agent")
|
|
196
|
+
if self.compact:
|
|
197
|
+
return TelegramMessage(text=f"{ICON_SESSION} *{agent}* started")
|
|
198
|
+
return TelegramMessage(
|
|
199
|
+
text=f"{ICON_SESSION} *{agent}* session started\n_{self.aggregator.session_id}_"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _format_thinking(self, data: dict) -> TelegramMessage | None:
|
|
203
|
+
"""Format thinking event."""
|
|
204
|
+
if not self.show_thinking:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
content = data.get("message", data.get("content", ""))
|
|
208
|
+
if not content:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
# Truncate long thinking
|
|
212
|
+
if len(content) > 200:
|
|
213
|
+
content = content[:197] + "..."
|
|
214
|
+
|
|
215
|
+
return TelegramMessage(text=f"{ICON_THINKING} _{escape_markdown(content)}_")
|
|
216
|
+
|
|
217
|
+
def _format_tool_start(self, data: dict) -> TelegramMessage | None:
|
|
218
|
+
"""Format tool start event."""
|
|
219
|
+
if not self.show_tools:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
name = data.get("name", "unknown")
|
|
223
|
+
self.aggregator.current_tool = name
|
|
224
|
+
|
|
225
|
+
# Skip sub-agent tool events
|
|
226
|
+
if data.get("subagent_id"):
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
# Format tool args summary
|
|
230
|
+
args = data.get("args", {})
|
|
231
|
+
summary = self._format_tool_args(name, args)
|
|
232
|
+
|
|
233
|
+
if self.compact:
|
|
234
|
+
return TelegramMessage(text=f"{ICON_TOOL} `{name}`")
|
|
235
|
+
|
|
236
|
+
if summary:
|
|
237
|
+
return TelegramMessage(text=f"{ICON_TOOL} `{name}` {summary}")
|
|
238
|
+
return TelegramMessage(text=f"{ICON_TOOL} `{name}`")
|
|
239
|
+
|
|
240
|
+
def _format_tool_result(self, data: dict) -> TelegramMessage | None:
|
|
241
|
+
"""Format tool result event."""
|
|
242
|
+
if not self.show_tools:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
name = data.get("name", "unknown")
|
|
246
|
+
success = data.get("success", True)
|
|
247
|
+
|
|
248
|
+
# Skip sub-agent tool events
|
|
249
|
+
if data.get("subagent_id"):
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
# Track completed tools
|
|
253
|
+
self.aggregator.completed_tools.append({"name": name, "success": success})
|
|
254
|
+
self.aggregator.current_tool = None
|
|
255
|
+
|
|
256
|
+
# In compact mode, don't show individual tool results
|
|
257
|
+
if self.compact:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
icon = ICON_TOOL_SUCCESS if success else ICON_TOOL_ERROR
|
|
261
|
+
return TelegramMessage(text=f"{icon} `{name}` completed")
|
|
262
|
+
|
|
263
|
+
def _format_partial(self, data: dict) -> TelegramMessage | None:
|
|
264
|
+
"""Format partial response event.
|
|
265
|
+
|
|
266
|
+
Accumulates content and returns update when threshold is reached.
|
|
267
|
+
"""
|
|
268
|
+
content = data.get("content", "")
|
|
269
|
+
if not content:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
should_update = self.aggregator.add_partial(content)
|
|
273
|
+
if should_update:
|
|
274
|
+
return self.aggregator.get_update_message()
|
|
275
|
+
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def _format_response(self, data: dict) -> TelegramMessage:
|
|
279
|
+
"""Format final response event."""
|
|
280
|
+
content = data.get("content", "")
|
|
281
|
+
|
|
282
|
+
# Check if we were streaming partial content (should update existing message)
|
|
283
|
+
was_streaming = bool(self.aggregator.content)
|
|
284
|
+
|
|
285
|
+
# Reset aggregator
|
|
286
|
+
self.aggregator.reset()
|
|
287
|
+
|
|
288
|
+
# Format response with icon
|
|
289
|
+
if self.compact:
|
|
290
|
+
return TelegramMessage(text=content, parse_mode="Markdown", is_update=was_streaming)
|
|
291
|
+
|
|
292
|
+
return TelegramMessage(
|
|
293
|
+
text=f"{ICON_RESPONSE}\n\n{content}",
|
|
294
|
+
parse_mode="Markdown",
|
|
295
|
+
is_update=was_streaming,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _format_error(self, data: dict) -> TelegramMessage:
|
|
299
|
+
"""Format error event."""
|
|
300
|
+
message = data.get("message", "Unknown error")
|
|
301
|
+
details = data.get("details", "")
|
|
302
|
+
|
|
303
|
+
text = f"{ICON_ERROR} *Error:* {escape_markdown(message)}"
|
|
304
|
+
if details and not self.compact:
|
|
305
|
+
text += f"\n_{escape_markdown(details)}_"
|
|
306
|
+
|
|
307
|
+
return TelegramMessage(text=text)
|
|
308
|
+
|
|
309
|
+
def _format_clarification(self, data: dict) -> TelegramMessage:
|
|
310
|
+
"""Format clarification request."""
|
|
311
|
+
question = data.get("question", "")
|
|
312
|
+
options = data.get("options", [])
|
|
313
|
+
|
|
314
|
+
text = f"{ICON_CLARIFICATION} *Question:*\n{question}"
|
|
315
|
+
|
|
316
|
+
if options and isinstance(options, list):
|
|
317
|
+
text += "\n\n*Options:*"
|
|
318
|
+
for i, opt in enumerate(options, 1):
|
|
319
|
+
text += f"\n{i}. {opt}"
|
|
320
|
+
|
|
321
|
+
return TelegramMessage(text=text)
|
|
322
|
+
|
|
323
|
+
def _format_session_end(self, data: dict) -> TelegramMessage | None:
|
|
324
|
+
"""Format session end event."""
|
|
325
|
+
success = data.get("success", True)
|
|
326
|
+
|
|
327
|
+
if not success:
|
|
328
|
+
error = data.get("error", "Unknown error")
|
|
329
|
+
return TelegramMessage(text=f"{ICON_ERROR} Session ended with error: {error}")
|
|
330
|
+
|
|
331
|
+
if self.compact:
|
|
332
|
+
tools_count = len(self.aggregator.completed_tools)
|
|
333
|
+
if tools_count > 0:
|
|
334
|
+
return TelegramMessage(text=f"{ICON_COMPLETE} Done ({tools_count} tools)")
|
|
335
|
+
return TelegramMessage(text=f"{ICON_COMPLETE} Done")
|
|
336
|
+
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def _format_progress(self, data: dict) -> TelegramMessage | None:
|
|
340
|
+
"""Format progress event."""
|
|
341
|
+
if self.compact:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
message = data.get("message", "")
|
|
345
|
+
percent = data.get("percent")
|
|
346
|
+
|
|
347
|
+
if percent is not None:
|
|
348
|
+
return TelegramMessage(text=f"{ICON_PROGRESS} {message} ({percent}%)")
|
|
349
|
+
|
|
350
|
+
return TelegramMessage(text=f"{ICON_PROGRESS} {message}")
|
|
351
|
+
|
|
352
|
+
def _format_tool_args(self, tool_name: str, args: dict) -> str:
|
|
353
|
+
"""Format tool args into a short summary."""
|
|
354
|
+
if not args:
|
|
355
|
+
return ""
|
|
356
|
+
|
|
357
|
+
# Tool-specific formatting
|
|
358
|
+
if tool_name in ("glob", "grep", "semantic_search"):
|
|
359
|
+
pattern = args.get("pattern", args.get("query", ""))
|
|
360
|
+
if pattern:
|
|
361
|
+
if len(pattern) > 40:
|
|
362
|
+
pattern = pattern[:37] + "..."
|
|
363
|
+
return f'`"{pattern}"`'
|
|
364
|
+
|
|
365
|
+
elif tool_name in ("read_file", "write_to_file", "write_file", "edit"):
|
|
366
|
+
path = args.get("path", args.get("file_path", ""))
|
|
367
|
+
if path:
|
|
368
|
+
# Show just filename
|
|
369
|
+
if "/" in path:
|
|
370
|
+
path = path.split("/")[-1]
|
|
371
|
+
return f"`{path}`"
|
|
372
|
+
|
|
373
|
+
elif tool_name == "bash":
|
|
374
|
+
cmd = args.get("command", "")
|
|
375
|
+
if cmd:
|
|
376
|
+
if len(cmd) > 40:
|
|
377
|
+
cmd = cmd[:37] + "..."
|
|
378
|
+
return f"`{cmd}`"
|
|
379
|
+
|
|
380
|
+
return ""
|
|
381
|
+
|
|
382
|
+
def get_pending_content(self) -> TelegramMessage | None:
|
|
383
|
+
"""Get any pending accumulated content.
|
|
384
|
+
|
|
385
|
+
Call this when the stream ends to flush remaining content.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
TelegramMessage with remaining content, or None
|
|
389
|
+
"""
|
|
390
|
+
if self.aggregator.dirty and self.aggregator.content:
|
|
391
|
+
return self.aggregator.get_update_message()
|
|
392
|
+
return None
|
emdash_cli/main.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Main CLI entry point for emdash-cli."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
4
6
|
|
|
5
7
|
import click
|
|
6
8
|
|
|
@@ -12,12 +14,12 @@ from .commands import (
|
|
|
12
14
|
embed,
|
|
13
15
|
index,
|
|
14
16
|
plan,
|
|
17
|
+
registry,
|
|
15
18
|
rules,
|
|
16
19
|
search,
|
|
17
20
|
server,
|
|
18
21
|
skills,
|
|
19
22
|
team,
|
|
20
|
-
swarm,
|
|
21
23
|
projectmd,
|
|
22
24
|
research,
|
|
23
25
|
spec,
|
|
@@ -43,11 +45,11 @@ cli.add_command(analyze)
|
|
|
43
45
|
cli.add_command(embed)
|
|
44
46
|
cli.add_command(index)
|
|
45
47
|
cli.add_command(plan)
|
|
48
|
+
cli.add_command(registry)
|
|
46
49
|
cli.add_command(rules)
|
|
47
50
|
cli.add_command(server)
|
|
48
51
|
cli.add_command(skills)
|
|
49
52
|
cli.add_command(team)
|
|
50
|
-
cli.add_command(swarm)
|
|
51
53
|
|
|
52
54
|
# Register standalone commands
|
|
53
55
|
cli.add_command(search)
|
|
@@ -61,6 +63,54 @@ from .commands.server import server_killall
|
|
|
61
63
|
cli.add_command(server_killall, name="killall")
|
|
62
64
|
|
|
63
65
|
|
|
66
|
+
# Update command - runs install.sh to update emdash
|
|
67
|
+
@click.command()
|
|
68
|
+
@click.option("--with-graph", is_flag=True, help="Install with graph database support")
|
|
69
|
+
@click.option("--reinstall", is_flag=True, help="Force reinstall (removes existing installation)")
|
|
70
|
+
def update(with_graph: bool, reinstall: bool):
|
|
71
|
+
"""Update emdash to the latest version.
|
|
72
|
+
|
|
73
|
+
Downloads and runs the official install script from GitHub.
|
|
74
|
+
|
|
75
|
+
Examples:
|
|
76
|
+
emdash update # Update to latest
|
|
77
|
+
emdash update --with-graph # Update with graph support
|
|
78
|
+
emdash update --reinstall # Force reinstall
|
|
79
|
+
"""
|
|
80
|
+
install_url = "https://raw.githubusercontent.com/mendyEdri/emdash.dev/main/scripts/install.sh"
|
|
81
|
+
|
|
82
|
+
# Build command
|
|
83
|
+
cmd = f"curl -sSL {install_url} | bash"
|
|
84
|
+
if with_graph or reinstall:
|
|
85
|
+
args = []
|
|
86
|
+
if with_graph:
|
|
87
|
+
args.append("--with-graph")
|
|
88
|
+
if reinstall:
|
|
89
|
+
args.append("--reinstall")
|
|
90
|
+
cmd = f"curl -sSL {install_url} | bash -s -- {' '.join(args)}"
|
|
91
|
+
|
|
92
|
+
click.echo("Updating emdash...")
|
|
93
|
+
click.echo()
|
|
94
|
+
|
|
95
|
+
# Run the install script
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
cmd,
|
|
99
|
+
shell=True,
|
|
100
|
+
executable="/bin/bash",
|
|
101
|
+
)
|
|
102
|
+
sys.exit(result.returncode)
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
click.echo("\nUpdate cancelled.")
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
click.echo(f"Update failed: {e}", err=True)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
cli.add_command(update)
|
|
112
|
+
|
|
113
|
+
|
|
64
114
|
# Direct entry point for `em` command - wraps agent_code with click
|
|
65
115
|
@click.command()
|
|
66
116
|
@click.argument("task", required=False)
|