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.
Files changed (39) hide show
  1. emdash_cli/client.py +12 -28
  2. emdash_cli/commands/__init__.py +2 -2
  3. emdash_cli/commands/agent/constants.py +78 -0
  4. emdash_cli/commands/agent/handlers/__init__.py +10 -0
  5. emdash_cli/commands/agent/handlers/agents.py +67 -39
  6. emdash_cli/commands/agent/handlers/index.py +183 -0
  7. emdash_cli/commands/agent/handlers/misc.py +119 -0
  8. emdash_cli/commands/agent/handlers/registry.py +72 -0
  9. emdash_cli/commands/agent/handlers/rules.py +48 -31
  10. emdash_cli/commands/agent/handlers/sessions.py +1 -1
  11. emdash_cli/commands/agent/handlers/setup.py +187 -54
  12. emdash_cli/commands/agent/handlers/skills.py +42 -4
  13. emdash_cli/commands/agent/handlers/telegram.py +523 -0
  14. emdash_cli/commands/agent/handlers/todos.py +55 -34
  15. emdash_cli/commands/agent/handlers/verify.py +10 -5
  16. emdash_cli/commands/agent/help.py +236 -0
  17. emdash_cli/commands/agent/interactive.py +278 -47
  18. emdash_cli/commands/agent/menus.py +116 -84
  19. emdash_cli/commands/agent/onboarding.py +619 -0
  20. emdash_cli/commands/agent/session_restore.py +210 -0
  21. emdash_cli/commands/index.py +111 -13
  22. emdash_cli/commands/registry.py +635 -0
  23. emdash_cli/commands/skills.py +72 -6
  24. emdash_cli/design.py +328 -0
  25. emdash_cli/diff_renderer.py +438 -0
  26. emdash_cli/integrations/__init__.py +1 -0
  27. emdash_cli/integrations/telegram/__init__.py +15 -0
  28. emdash_cli/integrations/telegram/bot.py +402 -0
  29. emdash_cli/integrations/telegram/bridge.py +980 -0
  30. emdash_cli/integrations/telegram/config.py +155 -0
  31. emdash_cli/integrations/telegram/formatter.py +392 -0
  32. emdash_cli/main.py +52 -2
  33. emdash_cli/sse_renderer.py +632 -171
  34. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/METADATA +2 -2
  35. emdash_cli-0.1.70.dist-info/RECORD +63 -0
  36. emdash_cli/commands/swarm.py +0 -86
  37. emdash_cli-0.1.46.dist-info/RECORD +0 -49
  38. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/WHEEL +0 -0
  39. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,980 @@
1
+ """Telegram-EmDash bridge for SSE streaming.
2
+
3
+ Connects Telegram messages to the EmDash agent and streams
4
+ SSE responses back to Telegram as message updates.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, AsyncIterator
13
+
14
+ import httpx
15
+
16
+ from .bot import TelegramBot, TelegramMessage, TelegramUpdate
17
+ from .config import TelegramConfig, TelegramSettings
18
+ from .formatter import SSEEventFormatter, TelegramMessage as FormattedMessage
19
+
20
+
21
+ # Default EmDash server URL
22
+ DEFAULT_SERVER_URL = "http://localhost:8765"
23
+
24
+ # Telegram-only commands (not forwarded to agent)
25
+ # All other /commands are forwarded to the EmDash agent
26
+ TELEGRAM_COMMANDS = {
27
+ "/start", # Telegram welcome message
28
+ "/stop", # Cancel current operation
29
+ "/cancel", # Cancel pending interaction
30
+ "/tgstatus", # Telegram bot status
31
+ "/tgsettings", # Telegram display settings
32
+ "/tghelp", # Telegram help
33
+ "/thinking", # Toggle thinking display
34
+ "/tools", # Toggle tool calls display
35
+ "/plan", # Switch to plan mode
36
+ "/code", # Switch to code mode
37
+ "/mode", # Show current mode
38
+ "/reset", # Reset session
39
+ }
40
+
41
+ # Map BotFather command format (underscores) to EmDash format (hyphens)
42
+ # BotFather doesn't allow hyphens in command names
43
+ COMMAND_ALIASES = {
44
+ "/todo_add": "/todo-add",
45
+ "/verify_loop": "/verify-loop",
46
+ }
47
+
48
+
49
+ @dataclass
50
+ class PendingInteraction:
51
+ """Tracks a pending interaction requiring user response."""
52
+
53
+ type: str # "clarification", "plan_approval", "planmode_request"
54
+ data: dict = field(default_factory=dict)
55
+ options: list = field(default_factory=list)
56
+
57
+
58
+ @dataclass
59
+ class BridgeState:
60
+ """Tracks the state of the bridge."""
61
+
62
+ # Current session ID (one per chat)
63
+ sessions: dict[int, str] = field(default_factory=dict)
64
+
65
+ # Last message ID sent to each chat (for editing)
66
+ last_message_ids: dict[int, int] = field(default_factory=dict)
67
+
68
+ # Timestamp of last message sent to each chat (for rate limiting)
69
+ last_message_times: dict[int, float] = field(default_factory=dict)
70
+
71
+ # Whether we're currently processing a request for each chat
72
+ processing: dict[int, bool] = field(default_factory=dict)
73
+
74
+ # Pending interactions requiring user response (per chat)
75
+ pending: dict[int, PendingInteraction] = field(default_factory=dict)
76
+
77
+ # Current mode per chat (code or plan)
78
+ modes: dict[int, str] = field(default_factory=dict)
79
+
80
+
81
+ class TelegramBridge:
82
+ """Bridge between Telegram and EmDash agent.
83
+
84
+ Receives messages from Telegram, sends them to the EmDash agent,
85
+ and streams SSE responses back as Telegram messages.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ config: TelegramConfig,
91
+ server_url: str | None = None,
92
+ on_message: Any = None,
93
+ ):
94
+ """Initialize the bridge.
95
+
96
+ Args:
97
+ config: Telegram configuration
98
+ server_url: EmDash server URL (default: localhost:8765)
99
+ on_message: Optional callback for logging/debugging
100
+ """
101
+ self.config = config
102
+ self.server_url = server_url or os.getenv("EMDASH_SERVER_URL", DEFAULT_SERVER_URL)
103
+ self.on_message = on_message
104
+
105
+ self.state = BridgeState()
106
+ self._running = False
107
+ self._bot: TelegramBot | None = None
108
+ self._http_client: httpx.AsyncClient | None = None
109
+
110
+ async def start(self) -> None:
111
+ """Start the bridge.
112
+
113
+ Begins listening for Telegram messages and processing them.
114
+ """
115
+ if not self.config.bot_token:
116
+ raise ValueError("Bot token not configured")
117
+
118
+ self._running = True
119
+ self._bot = TelegramBot(self.config.bot_token)
120
+ self._http_client = httpx.AsyncClient(timeout=None)
121
+
122
+ await self._bot.__aenter__()
123
+
124
+ # Get bot info
125
+ bot_info = await self._bot.get_me()
126
+ if self.on_message:
127
+ self.on_message("bridge_started", {"bot": bot_info.username})
128
+
129
+ # Start polling loop
130
+ try:
131
+ async for update in self._bot.poll_updates(
132
+ offset=self.config.state.last_update_id
133
+ ):
134
+ if not self._running:
135
+ break
136
+
137
+ # Update offset
138
+ self.config.state.last_update_id = update.update_id
139
+
140
+ # Process the update
141
+ await self._handle_update(update)
142
+
143
+ finally:
144
+ await self.stop()
145
+
146
+ async def stop(self) -> None:
147
+ """Stop the bridge."""
148
+ self._running = False
149
+
150
+ if self._bot:
151
+ await self._bot.__aexit__(None, None, None)
152
+ self._bot = None
153
+
154
+ if self._http_client:
155
+ await self._http_client.aclose()
156
+ self._http_client = None
157
+
158
+ async def _handle_update(self, update: TelegramUpdate) -> None:
159
+ """Handle an incoming Telegram update.
160
+
161
+ Args:
162
+ update: The Telegram update to process
163
+ """
164
+ if not update.message or not update.message.text:
165
+ return
166
+
167
+ chat_id = update.message.chat.id
168
+ text = update.message.text.strip()
169
+ user = update.message.from_user
170
+
171
+ # Log the incoming message
172
+ if self.on_message:
173
+ user_name = user.display_name if user else "Unknown"
174
+ self.on_message("message_received", {
175
+ "chat_id": chat_id,
176
+ "user": user_name,
177
+ "text": text[:100],
178
+ })
179
+
180
+ # Check authorization
181
+ if not self.config.is_chat_authorized(chat_id):
182
+ await self._send_message(chat_id, "Sorry, this chat is not authorized.")
183
+ return
184
+
185
+ # Check if already processing
186
+ if self.state.processing.get(chat_id):
187
+ await self._send_message(chat_id, "Please wait, still processing previous request...")
188
+ return
189
+
190
+ # Check if there's a pending interaction waiting for response
191
+ pending = self.state.pending.get(chat_id)
192
+ if pending:
193
+ await self._handle_pending_response(chat_id, text, pending)
194
+ return
195
+
196
+ # Handle special commands
197
+ if text.startswith("/"):
198
+ await self._handle_command(chat_id, text)
199
+ return
200
+
201
+ # Process as agent message
202
+ await self._process_agent_message(chat_id, text)
203
+
204
+ async def _handle_command(self, chat_id: int, text: str) -> None:
205
+ """Handle slash commands.
206
+
207
+ Telegram-specific commands (in TELEGRAM_COMMANDS) are handled locally.
208
+ All other slash commands are forwarded to the EmDash agent.
209
+
210
+ Args:
211
+ chat_id: Chat ID
212
+ text: Command text (e.g., "/plan" or "/todo_add Fix tests")
213
+ """
214
+ parts = text.split(maxsplit=1)
215
+ command = parts[0].lower()
216
+ args = parts[1] if len(parts) > 1 else ""
217
+
218
+ # Handle Telegram-specific commands locally
219
+ if command in TELEGRAM_COMMANDS:
220
+ await self._handle_telegram_command(chat_id, command, args)
221
+ return
222
+
223
+ # Apply command aliases (BotFather format -> EmDash format)
224
+ if command in COMMAND_ALIASES:
225
+ command = COMMAND_ALIASES[command]
226
+
227
+ # Forward to agent as a slash command
228
+ message = f"{command} {args}".strip() if args else command
229
+ await self._process_agent_message(chat_id, message)
230
+
231
+ async def _handle_telegram_command(self, chat_id: int, command: str, args: str) -> None:
232
+ """Handle Telegram-specific commands.
233
+
234
+ Args:
235
+ chat_id: Chat ID
236
+ command: Command name (e.g., "/start")
237
+ args: Command arguments
238
+ """
239
+ if command == "/start":
240
+ await self._send_message(
241
+ chat_id,
242
+ "*EmDash Bot*\n\n"
243
+ "Send me a message and I'll process it with the EmDash agent.\n\n"
244
+ "*Mode commands:*\n"
245
+ "/plan - Switch to plan mode\n"
246
+ "/code - Switch to code mode\n"
247
+ "/mode - Show current mode\n"
248
+ "/reset - Reset session\n\n"
249
+ "*Telegram-only commands:*\n"
250
+ "/stop - Cancel current operation\n"
251
+ "/cancel - Cancel pending interaction\n"
252
+ "/tgstatus - Show bot connection status\n"
253
+ "/tgsettings - Show display settings\n"
254
+ "/thinking - Toggle showing agent thinking\n"
255
+ "/tools - Toggle showing tool calls\n"
256
+ "/tghelp - Show this help",
257
+ )
258
+
259
+ elif command == "/tgstatus":
260
+ session_id = self.state.sessions.get(chat_id)
261
+ pending = self.state.pending.get(chat_id)
262
+ current_mode = self.state.modes.get(chat_id, "code")
263
+ status = "Connected" if session_id else "No active session"
264
+ pending_status = f"\n*Pending:* {pending.type}" if pending else ""
265
+ mode_emoji = "📋" if current_mode == "plan" else "💻"
266
+ await self._send_message(
267
+ chat_id,
268
+ f"*Status:* {status}\n"
269
+ f"*Mode:* {mode_emoji} {current_mode}\n"
270
+ f"*Server:* `{self.server_url}`"
271
+ f"{pending_status}",
272
+ )
273
+
274
+ elif command == "/stop":
275
+ if self.state.processing.get(chat_id):
276
+ self.state.processing[chat_id] = False
277
+ await self._send_message(chat_id, "Operation cancelled.")
278
+ else:
279
+ await self._send_message(chat_id, "No operation in progress.")
280
+
281
+ elif command == "/cancel":
282
+ if chat_id in self.state.pending:
283
+ del self.state.pending[chat_id]
284
+ await self._send_message(chat_id, "Pending interaction cancelled.")
285
+ else:
286
+ await self._send_message(chat_id, "No pending interaction.")
287
+
288
+ elif command == "/thinking":
289
+ self.config.settings.show_thinking = not self.config.settings.show_thinking
290
+ status = "enabled" if self.config.settings.show_thinking else "disabled"
291
+ await self._send_message(chat_id, f"Show thinking {status}.")
292
+
293
+ elif command == "/tools":
294
+ self.config.settings.show_tool_calls = not self.config.settings.show_tool_calls
295
+ status = "enabled" if self.config.settings.show_tool_calls else "disabled"
296
+ await self._send_message(chat_id, f"Show tool calls {status}.")
297
+
298
+ elif command == "/tgsettings":
299
+ await self._send_message(
300
+ chat_id,
301
+ "*Telegram Display Settings:*\n\n"
302
+ f"Show thinking: `{self.config.settings.show_thinking}`\n"
303
+ f"Show tools: `{self.config.settings.show_tool_calls}`\n"
304
+ f"Compact mode: `{self.config.settings.compact_mode}`\n"
305
+ f"Update interval: `{self.config.settings.update_interval_ms}ms`",
306
+ )
307
+
308
+ elif command == "/tghelp":
309
+ await self._send_message(
310
+ chat_id,
311
+ "*EmDash Telegram Bot*\n\n"
312
+ "Send any message or slash command to interact with the EmDash agent.\n\n"
313
+ "*Mode commands:*\n"
314
+ "/plan - Switch to plan mode (read-only exploration)\n"
315
+ "/code - Switch to code mode (execute changes)\n"
316
+ "/mode - Show current mode\n"
317
+ "/reset - Reset session\n\n"
318
+ "*Agent commands (forwarded):*\n"
319
+ "/todos - Show todo list\n"
320
+ "/status - Show project status\n"
321
+ "/help - Show all agent commands\n\n"
322
+ "*Telegram-only commands:*\n"
323
+ "/stop - Cancel current operation\n"
324
+ "/cancel - Cancel pending interaction\n"
325
+ "/tgstatus - Bot connection status\n"
326
+ "/tgsettings - Display settings\n"
327
+ "/thinking - Toggle thinking display\n"
328
+ "/tools - Toggle tool calls display\n"
329
+ "/tghelp - This help message\n\n"
330
+ "*Responding to questions:*\n"
331
+ "Reply with option number (1, 2, 3...) or type your answer.\n\n"
332
+ "*Plan approval:*\n"
333
+ 'Reply "yes" to approve, "no" to reject, or type feedback.',
334
+ )
335
+
336
+ elif command == "/plan":
337
+ # Switch to plan mode and reset session
338
+ self.state.modes[chat_id] = "plan"
339
+ if chat_id in self.state.sessions:
340
+ del self.state.sessions[chat_id]
341
+ await self._send_message(
342
+ chat_id,
343
+ "✅ Switched to *plan mode* (session reset)\n\n"
344
+ "_Plan mode explores the codebase and creates plans without making changes._",
345
+ )
346
+ else:
347
+ await self._send_message(
348
+ chat_id,
349
+ "✅ Switched to *plan mode*\n\n"
350
+ "_Plan mode explores the codebase and creates plans without making changes._",
351
+ )
352
+
353
+ elif command == "/code":
354
+ # Switch to code mode and reset session
355
+ self.state.modes[chat_id] = "code"
356
+ if chat_id in self.state.sessions:
357
+ del self.state.sessions[chat_id]
358
+ await self._send_message(
359
+ chat_id,
360
+ "✅ Switched to *code mode* (session reset)\n\n"
361
+ "_Code mode can execute changes and modify files._",
362
+ )
363
+ else:
364
+ await self._send_message(
365
+ chat_id,
366
+ "✅ Switched to *code mode*\n\n"
367
+ "_Code mode can execute changes and modify files._",
368
+ )
369
+
370
+ elif command == "/mode":
371
+ current_mode = self.state.modes.get(chat_id, "code")
372
+ if current_mode == "plan":
373
+ await self._send_message(
374
+ chat_id,
375
+ "📋 Current mode: *plan*\n\n"
376
+ "_Use /code to switch to code mode._",
377
+ )
378
+ else:
379
+ await self._send_message(
380
+ chat_id,
381
+ "💻 Current mode: *code*\n\n"
382
+ "_Use /plan to switch to plan mode._",
383
+ )
384
+
385
+ elif command == "/reset":
386
+ # Reset session for this chat
387
+ if chat_id in self.state.sessions:
388
+ del self.state.sessions[chat_id]
389
+ await self._send_message(chat_id, "🔄 Session reset.")
390
+
391
+ async def _process_agent_message(self, chat_id: int, message: str) -> None:
392
+ """Process a message through the EmDash agent.
393
+
394
+ Args:
395
+ chat_id: Telegram chat ID
396
+ message: User message to process
397
+ """
398
+ self.state.processing[chat_id] = True
399
+
400
+ # Get or create session
401
+ session_id = self.state.sessions.get(chat_id)
402
+
403
+ # Create formatter with settings
404
+ formatter = SSEEventFormatter(
405
+ show_thinking=self.config.settings.show_thinking,
406
+ show_tools=self.config.settings.show_tool_calls,
407
+ compact=self.config.settings.compact_mode,
408
+ )
409
+
410
+ # Send typing indicator
411
+ if self._bot:
412
+ try:
413
+ await self._bot.send_chat_action(chat_id, "typing")
414
+ except Exception:
415
+ pass
416
+
417
+ try:
418
+ # Stream from agent
419
+ last_update_time = 0.0
420
+ update_interval = self.config.settings.update_interval_ms / 1000.0
421
+ has_sent_response = False
422
+
423
+ # Get current mode for this chat (default to code)
424
+ current_mode = self.state.modes.get(chat_id, "code")
425
+
426
+ async for event_type, data in self._stream_agent_chat(message, session_id, current_mode):
427
+ # Check if cancelled
428
+ if not self.state.processing.get(chat_id):
429
+ break
430
+
431
+ # Update session ID if provided (don't send session_start as separate message)
432
+ if event_type == "session_start" and data.get("session_id"):
433
+ self.state.sessions[chat_id] = data["session_id"]
434
+ continue # Skip sending session_start notification
435
+
436
+ # Skip session_end notifications (clutters the chat)
437
+ if event_type == "session_end":
438
+ continue
439
+
440
+ # Handle interactive events - store pending state
441
+ if event_type == "clarification":
442
+ await self._handle_clarification_event(chat_id, data)
443
+ continue
444
+
445
+ if event_type == "plan_submitted":
446
+ await self._handle_plan_submitted_event(chat_id, data)
447
+ continue
448
+
449
+ if event_type == "plan_mode_requested":
450
+ await self._handle_plan_mode_event(chat_id, data)
451
+ continue
452
+
453
+ # Format the event
454
+ formatted = formatter.format_event(event_type, data)
455
+
456
+ # Track if we've sent a response (to avoid duplicates)
457
+ if event_type == "response":
458
+ has_sent_response = True
459
+
460
+ # Send formatted message
461
+ if formatted:
462
+ now = time.time()
463
+
464
+ # Rate limit updates
465
+ if formatted.is_update and (now - last_update_time) < update_interval:
466
+ continue
467
+
468
+ last_update_time = now
469
+
470
+ # Edit or send new message
471
+ if formatted.is_update and chat_id in self.state.last_message_ids:
472
+ await self._edit_message(
473
+ chat_id,
474
+ self.state.last_message_ids[chat_id],
475
+ formatted.text,
476
+ formatted.parse_mode,
477
+ )
478
+ else:
479
+ msg = await self._send_message(
480
+ chat_id, formatted.text, formatted.parse_mode
481
+ )
482
+ if msg:
483
+ self.state.last_message_ids[chat_id] = msg.message_id
484
+
485
+ # Flush any pending partial content (only if we haven't sent a full response)
486
+ if not has_sent_response:
487
+ pending_content = formatter.get_pending_content()
488
+ if pending_content:
489
+ await self._send_message(chat_id, pending_content.text, pending_content.parse_mode)
490
+
491
+ except Exception as e:
492
+ if self.on_message:
493
+ self.on_message("error", {"error": str(e)})
494
+ await self._send_message(chat_id, f"Error: {str(e)}")
495
+
496
+ finally:
497
+ self.state.processing[chat_id] = False
498
+
499
+ async def _stream_agent_chat(
500
+ self,
501
+ message: str,
502
+ session_id: str | None = None,
503
+ mode: str = "code",
504
+ ) -> AsyncIterator[tuple[str, dict]]:
505
+ """Stream agent chat response via SSE.
506
+
507
+ Args:
508
+ message: User message
509
+ session_id: Optional session ID for continuity
510
+ mode: Agent mode (code or plan)
511
+
512
+ Yields:
513
+ Tuples of (event_type, data)
514
+ """
515
+ if not self._http_client:
516
+ return
517
+
518
+ payload = {
519
+ "message": message,
520
+ "options": {
521
+ "max_iterations": 50,
522
+ "verbose": True,
523
+ "mode": mode,
524
+ },
525
+ }
526
+
527
+ if session_id:
528
+ payload["session_id"] = session_id
529
+
530
+ url = f"{self.server_url}/api/agent/chat"
531
+
532
+ try:
533
+ async with self._http_client.stream("POST", url, json=payload) as response:
534
+ response.raise_for_status()
535
+
536
+ current_event = None
537
+
538
+ async for line in response.aiter_lines():
539
+ line = line.strip()
540
+
541
+ if line.startswith("event: "):
542
+ current_event = line[7:]
543
+ elif line.startswith("data: "):
544
+ if current_event:
545
+ try:
546
+ data = json.loads(line[6:])
547
+ if data is None:
548
+ data = {}
549
+ yield current_event, data
550
+ except json.JSONDecodeError:
551
+ pass
552
+ elif line == ": ping":
553
+ # Keep-alive ping
554
+ pass
555
+
556
+ except httpx.HTTPError as e:
557
+ yield "error", {"message": f"HTTP error: {str(e)}"}
558
+ except Exception as e:
559
+ yield "error", {"message": str(e)}
560
+
561
+ async def _send_message(
562
+ self,
563
+ chat_id: int,
564
+ text: str,
565
+ parse_mode: str | None = "Markdown",
566
+ ) -> TelegramMessage | None:
567
+ """Send a message to a chat.
568
+
569
+ Args:
570
+ chat_id: Target chat ID
571
+ text: Message text
572
+ parse_mode: Parse mode for formatting
573
+
574
+ Returns:
575
+ Sent message, or None on error
576
+ """
577
+ if not self._bot:
578
+ return None
579
+
580
+ try:
581
+ return await self._bot.send_message(
582
+ chat_id, text, parse_mode=parse_mode
583
+ )
584
+ except Exception as e:
585
+ # Try sending without parse mode if Markdown fails
586
+ if parse_mode:
587
+ try:
588
+ return await self._bot.send_message(
589
+ chat_id, text, parse_mode=None
590
+ )
591
+ except Exception:
592
+ pass
593
+
594
+ if self.on_message:
595
+ self.on_message("send_error", {"error": str(e)})
596
+ return None
597
+
598
+ async def _edit_message(
599
+ self,
600
+ chat_id: int,
601
+ message_id: int,
602
+ text: str,
603
+ parse_mode: str | None = "Markdown",
604
+ ) -> None:
605
+ """Edit an existing message.
606
+
607
+ Args:
608
+ chat_id: Chat containing the message
609
+ message_id: Message ID to edit
610
+ text: New text
611
+ parse_mode: Parse mode for formatting
612
+ """
613
+ if not self._bot:
614
+ return
615
+
616
+ try:
617
+ await self._bot.edit_message_text(
618
+ chat_id, message_id, text, parse_mode=parse_mode
619
+ )
620
+ except Exception:
621
+ # Editing can fail if message is identical - ignore
622
+ pass
623
+
624
+ async def _send_long_message(self, chat_id: int, text: str) -> None:
625
+ """Send a long message, splitting if necessary.
626
+
627
+ Args:
628
+ chat_id: Target chat ID
629
+ text: Message text (can be longer than 4096 chars)
630
+ """
631
+ max_len = self.config.settings.max_message_length
632
+
633
+ # If short enough, send as-is
634
+ if len(text) <= max_len:
635
+ await self._send_message(chat_id, text)
636
+ return
637
+
638
+ # Split into chunks at paragraph boundaries
639
+ chunks = []
640
+ current_chunk = ""
641
+
642
+ paragraphs = text.split("\n\n")
643
+ for para in paragraphs:
644
+ if len(current_chunk) + len(para) + 2 <= max_len:
645
+ if current_chunk:
646
+ current_chunk += "\n\n"
647
+ current_chunk += para
648
+ else:
649
+ if current_chunk:
650
+ chunks.append(current_chunk)
651
+ # If paragraph itself is too long, split it
652
+ if len(para) > max_len:
653
+ words = para.split()
654
+ current_chunk = ""
655
+ for word in words:
656
+ if len(current_chunk) + len(word) + 1 <= max_len:
657
+ if current_chunk:
658
+ current_chunk += " "
659
+ current_chunk += word
660
+ else:
661
+ chunks.append(current_chunk)
662
+ current_chunk = word
663
+ else:
664
+ current_chunk = para
665
+
666
+ if current_chunk:
667
+ chunks.append(current_chunk)
668
+
669
+ # Send each chunk
670
+ for i, chunk in enumerate(chunks):
671
+ if len(chunks) > 1:
672
+ chunk = f"*({i + 1}/{len(chunks)})*\n\n{chunk}"
673
+ await self._send_message(chat_id, chunk)
674
+ # Small delay between chunks to avoid rate limits
675
+ if i < len(chunks) - 1:
676
+ await asyncio.sleep(0.5)
677
+
678
+ # ─────────────────────────────────────────────────────────────────────────────
679
+ # Interactive event handlers
680
+ # ─────────────────────────────────────────────────────────────────────────────
681
+
682
+ async def _handle_clarification_event(self, chat_id: int, data: dict) -> None:
683
+ """Handle clarification event - prompt user for response.
684
+
685
+ Args:
686
+ chat_id: Telegram chat ID
687
+ data: Clarification event data
688
+ """
689
+ question = data.get("question", "")
690
+ options = data.get("options", [])
691
+ context = data.get("context", "")
692
+
693
+ # Ensure options is a list
694
+ if isinstance(options, str):
695
+ options = [options] if options else []
696
+
697
+ # Store pending interaction
698
+ self.state.pending[chat_id] = PendingInteraction(
699
+ type="clarification",
700
+ data=data,
701
+ options=options,
702
+ )
703
+
704
+ # Build message
705
+ text = f"❓ *Question:*\n{question}"
706
+
707
+ if options:
708
+ text += "\n\n*Options:*"
709
+ for i, opt in enumerate(options, 1):
710
+ text += f"\n{i}. {opt}"
711
+ text += "\n\n_Reply with option number (1-{}) or type your answer_".format(len(options))
712
+ else:
713
+ text += "\n\n_Type your answer_"
714
+
715
+ await self._send_message(chat_id, text)
716
+
717
+ async def _handle_plan_submitted_event(self, chat_id: int, data: dict) -> None:
718
+ """Handle plan submitted event - prompt user for approval.
719
+
720
+ Args:
721
+ chat_id: Telegram chat ID
722
+ data: Plan submitted event data
723
+ """
724
+ plan = data.get("plan", "")
725
+
726
+ # Store pending interaction
727
+ self.state.pending[chat_id] = PendingInteraction(
728
+ type="plan_approval",
729
+ data=data,
730
+ )
731
+
732
+ # Build message
733
+ text = f"📋 *Plan Submitted:*\n\n{plan}"
734
+ text += "\n\n_Reply:_"
735
+ text += '\n• "approve" or "yes" to proceed'
736
+ text += '\n• "reject" or "no" to cancel'
737
+ text += "\n• Or type feedback to request changes"
738
+
739
+ await self._send_long_message(chat_id, text)
740
+
741
+ async def _handle_plan_mode_event(self, chat_id: int, data: dict) -> None:
742
+ """Handle plan mode request event - prompt user for approval.
743
+
744
+ Args:
745
+ chat_id: Telegram chat ID
746
+ data: Plan mode request event data
747
+ """
748
+ reason = data.get("reason", "")
749
+
750
+ # Store pending interaction
751
+ self.state.pending[chat_id] = PendingInteraction(
752
+ type="planmode_request",
753
+ data=data,
754
+ )
755
+
756
+ # Build message
757
+ text = "🗺️ *Plan Mode Requested*"
758
+ if reason:
759
+ text += f"\n\n{reason}"
760
+ text += "\n\n_Reply:_"
761
+ text += '\n• "approve" or "yes" to enter plan mode'
762
+ text += '\n• "reject" or "no" to continue without plan'
763
+
764
+ await self._send_message(chat_id, text)
765
+
766
+ async def _handle_pending_response(
767
+ self, chat_id: int, text: str, pending: PendingInteraction
768
+ ) -> None:
769
+ """Handle user response to a pending interaction.
770
+
771
+ Args:
772
+ chat_id: Telegram chat ID
773
+ text: User's response text
774
+ pending: The pending interaction
775
+ """
776
+ # Clear pending state
777
+ del self.state.pending[chat_id]
778
+
779
+ if pending.type == "clarification":
780
+ await self._process_clarification_answer(chat_id, text, pending)
781
+ elif pending.type == "plan_approval":
782
+ await self._process_plan_approval(chat_id, text, pending)
783
+ elif pending.type == "planmode_request":
784
+ await self._process_planmode_approval(chat_id, text, pending)
785
+
786
+ async def _process_clarification_answer(
787
+ self, chat_id: int, text: str, pending: PendingInteraction
788
+ ) -> None:
789
+ """Process clarification answer and continue agent.
790
+
791
+ Args:
792
+ chat_id: Telegram chat ID
793
+ text: User's answer
794
+ pending: The pending clarification
795
+ """
796
+ session_id = self.state.sessions.get(chat_id)
797
+ if not session_id:
798
+ await self._send_message(chat_id, "Error: No active session")
799
+ return
800
+
801
+ # Check if user replied with option number
802
+ answer = text.strip()
803
+ if pending.options and answer.isdigit():
804
+ idx = int(answer) - 1
805
+ if 0 <= idx < len(pending.options):
806
+ answer = pending.options[idx]
807
+
808
+ await self._send_message(chat_id, f"✅ Selected: {answer}\nContinuing...")
809
+
810
+ # Call continuation endpoint and stream response
811
+ await self._stream_continuation(
812
+ chat_id,
813
+ f"{self.server_url}/api/agent/chat/{session_id}/clarification/answer",
814
+ params={"answer": answer},
815
+ )
816
+
817
+ async def _process_plan_approval(
818
+ self, chat_id: int, text: str, pending: PendingInteraction
819
+ ) -> None:
820
+ """Process plan approval/rejection and continue agent.
821
+
822
+ Args:
823
+ chat_id: Telegram chat ID
824
+ text: User's response
825
+ pending: The pending plan approval
826
+ """
827
+ session_id = self.state.sessions.get(chat_id)
828
+ if not session_id:
829
+ await self._send_message(chat_id, "Error: No active session")
830
+ return
831
+
832
+ response = text.strip().lower()
833
+
834
+ if response in ("approve", "yes", "y", "ok", "proceed"):
835
+ await self._send_message(chat_id, "✅ Plan approved. Executing...")
836
+ await self._stream_continuation(
837
+ chat_id,
838
+ f"{self.server_url}/api/agent/chat/{session_id}/plan/approve",
839
+ )
840
+ elif response in ("reject", "no", "n", "cancel"):
841
+ await self._send_message(chat_id, "❌ Plan rejected.")
842
+ await self._stream_continuation(
843
+ chat_id,
844
+ f"{self.server_url}/api/agent/chat/{session_id}/plan/reject",
845
+ params={"feedback": ""},
846
+ )
847
+ else:
848
+ # Treat as feedback
849
+ await self._send_message(chat_id, f"📝 Feedback sent: {text[:50]}...")
850
+ await self._stream_continuation(
851
+ chat_id,
852
+ f"{self.server_url}/api/agent/chat/{session_id}/plan/reject",
853
+ params={"feedback": text},
854
+ )
855
+
856
+ async def _process_planmode_approval(
857
+ self, chat_id: int, text: str, pending: PendingInteraction
858
+ ) -> None:
859
+ """Process plan mode approval/rejection and continue agent.
860
+
861
+ Args:
862
+ chat_id: Telegram chat ID
863
+ text: User's response
864
+ pending: The pending plan mode request
865
+ """
866
+ session_id = self.state.sessions.get(chat_id)
867
+ if not session_id:
868
+ await self._send_message(chat_id, "Error: No active session")
869
+ return
870
+
871
+ response = text.strip().lower()
872
+
873
+ if response in ("approve", "yes", "y", "ok"):
874
+ await self._send_message(chat_id, "✅ Entering plan mode...")
875
+ await self._stream_continuation(
876
+ chat_id,
877
+ f"{self.server_url}/api/agent/chat/{session_id}/planmode/approve",
878
+ )
879
+ else:
880
+ await self._send_message(chat_id, "Continuing without plan mode...")
881
+ await self._stream_continuation(
882
+ chat_id,
883
+ f"{self.server_url}/api/agent/chat/{session_id}/planmode/reject",
884
+ params={"feedback": text if response not in ("reject", "no", "n") else ""},
885
+ )
886
+
887
+ async def _stream_continuation(
888
+ self,
889
+ chat_id: int,
890
+ url: str,
891
+ params: dict | None = None,
892
+ ) -> None:
893
+ """Stream a continuation endpoint response.
894
+
895
+ Args:
896
+ chat_id: Telegram chat ID
897
+ url: API endpoint URL
898
+ params: Optional query parameters
899
+ """
900
+ if not self._http_client:
901
+ return
902
+
903
+ self.state.processing[chat_id] = True
904
+
905
+ # Create formatter with settings
906
+ formatter = SSEEventFormatter(
907
+ show_thinking=self.config.settings.show_thinking,
908
+ show_tools=self.config.settings.show_tool_calls,
909
+ compact=self.config.settings.compact_mode,
910
+ )
911
+
912
+ try:
913
+ response_content = ""
914
+ last_update_time = 0.0
915
+ update_interval = self.config.settings.update_interval_ms / 1000.0
916
+
917
+ async with self._http_client.stream("POST", url, params=params) as response:
918
+ response.raise_for_status()
919
+
920
+ current_event = None
921
+
922
+ async for line in response.aiter_lines():
923
+ # Check if cancelled
924
+ if not self.state.processing.get(chat_id):
925
+ break
926
+
927
+ line = line.strip()
928
+
929
+ if line.startswith("event: "):
930
+ current_event = line[7:]
931
+ elif line.startswith("data: "):
932
+ if current_event:
933
+ try:
934
+ data = json.loads(line[6:])
935
+ if data is None:
936
+ data = {}
937
+
938
+ # Handle interactive events recursively
939
+ if current_event == "clarification":
940
+ await self._handle_clarification_event(chat_id, data)
941
+ continue
942
+ if current_event == "plan_submitted":
943
+ await self._handle_plan_submitted_event(chat_id, data)
944
+ continue
945
+ if current_event == "plan_mode_requested":
946
+ await self._handle_plan_mode_event(chat_id, data)
947
+ continue
948
+
949
+ # Format the event
950
+ formatted = formatter.format_event(current_event, data)
951
+
952
+ if current_event == "response":
953
+ response_content = data.get("content", "")
954
+
955
+ if formatted:
956
+ now = time.time()
957
+ if formatted.is_update and (now - last_update_time) < update_interval:
958
+ continue
959
+ last_update_time = now
960
+
961
+ msg = await self._send_message(
962
+ chat_id, formatted.text, formatted.parse_mode
963
+ )
964
+ if msg:
965
+ self.state.last_message_ids[chat_id] = msg.message_id
966
+
967
+ except json.JSONDecodeError:
968
+ pass
969
+
970
+ # Send final response if we have one
971
+ if response_content and chat_id not in self.state.pending:
972
+ await self._send_long_message(chat_id, response_content)
973
+
974
+ except Exception as e:
975
+ if self.on_message:
976
+ self.on_message("error", {"error": str(e)})
977
+ await self._send_message(chat_id, f"Error: {str(e)}")
978
+
979
+ finally:
980
+ self.state.processing[chat_id] = False