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