diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..e46c6af --- /dev/null +++ b/bot.py @@ -0,0 +1,263 @@ +"""Discord bot β€” relays Antigravity brain events to Discord channels. + +Creates per-session text channels in Discord when new Antigravity sessions are +detected, relays task progress and artifact content as text messages and embeds. +""" + +import asyncio +import logging +from datetime import datetime, timezone +from pathlib import Path + +import discord +from discord.ext import commands + +from config import Config +from parser import ( + parse_task_progress, + md_to_discord_text, + format_task_embed_text, +) +from watcher import BrainEvent, EventType + +logger = logging.getLogger(__name__) + + +class GravityBot(commands.Bot): + """Discord bot for Antigravity session monitoring.""" + + def __init__(self, event_queue: asyncio.Queue): + intents = discord.Intents.default() + intents.message_content = True + intents.guilds = True + super().__init__(command_prefix="!", intents=intents) + + self.event_queue = event_queue + # conversation_id -> channel_id + self.session_channels: dict[str, int] = {} + # conversation_id -> status message id (to edit in-place) + self.session_status_messages: dict[str, int] = {} + # Main channel for creating session threads + self.main_channel: discord.TextChannel | None = None + # Category for session channels + self.session_category: discord.CategoryChannel | None = None + + async def setup_hook(self): + """Called after login, before processing events.""" + self.loop.create_task(self._process_events()) + logger.info("Bot setup complete, event processor started") + + async def on_ready(self): + """Called when bot is ready.""" + logger.info(f"Bot connected as {self.user} (ID: {self.user.id})") + + # Find or create main channel + if Config.DISCORD_CHANNEL_ID: + self.main_channel = self.get_channel(Config.DISCORD_CHANNEL_ID) + if self.main_channel: + logger.info(f"Main channel: #{self.main_channel.name}") + + # Find or create session category + guild = self.main_channel.guild + category_name = "Antigravity Sessions" + self.session_category = discord.utils.get( + guild.categories, name=category_name + ) + if not self.session_category: + try: + self.session_category = await guild.create_category(category_name) + logger.info(f"Created category: {category_name}") + except discord.errors.Forbidden: + logger.warning("No permission to create category, using main channel") + + # Send startup message + embed = discord.Embed( + title="πŸ›°οΈ Gravity Control 연결됨", + description="Antigravity μ„Έμ…˜ λͺ¨λ‹ˆν„°λ§μ„ μ‹œμž‘ν•©λ‹ˆλ‹€.", + color=discord.Color.green(), + timestamp=datetime.now(timezone.utc), + ) + embed.add_field( + name="Brain Path", + value=f"`{Config.BRAIN_PATH}`", + inline=False, + ) + await self.main_channel.send(embed=embed) + + async def _process_events(self): + """Main event processing loop β€” consumes brain events.""" + await self.wait_until_ready() + + while not self.is_closed(): + try: + event = await asyncio.wait_for( + self.event_queue.get(), timeout=5.0 + ) + await self._handle_event(event) + except asyncio.TimeoutError: + continue + except Exception as e: + logger.error(f"Error processing event: {e}", exc_info=True) + + async def _handle_event(self, event: BrainEvent): + """Route brain events to appropriate handlers.""" + if event.event_type == EventType.SESSION_START: + await self._handle_session_start(event) + elif event.event_type in (EventType.FILE_CREATED, EventType.FILE_CHANGED): + await self._handle_file_event(event) + + async def _handle_session_start(self, event: BrainEvent): + """Create a new Discord channel for the session.""" + conv_id = event.conversation_id + short_id = conv_id[:8] + + if self.session_category: + guild = self.session_category.guild + channel_name = f"session-{short_id}" + + try: + channel = await guild.create_text_channel( + name=channel_name, + category=self.session_category, + topic=f"Antigravity Session: {conv_id}", + ) + self.session_channels[conv_id] = channel.id + logger.info(f"Created channel #{channel_name} for session {conv_id}") + + # Send welcome embed + embed = discord.Embed( + title=f"πŸš€ μƒˆ Antigravity μ„Έμ…˜", + description=f"Session ID: `{conv_id}`", + color=discord.Color.blue(), + timestamp=datetime.now(timezone.utc), + ) + await channel.send(embed=embed) + + # Notify main channel + if self.main_channel: + await self.main_channel.send( + f"πŸ“‘ μƒˆ μ„Έμ…˜ μ‹œμž‘: {channel.mention} (`{short_id}`)" + ) + except discord.errors.Forbidden: + logger.warning(f"No permission to create channel for {conv_id}") + # Fall back to main channel + self.session_channels[conv_id] = Config.DISCORD_CHANNEL_ID + else: + # No category β€” use main channel + self.session_channels[conv_id] = Config.DISCORD_CHANNEL_ID + if self.main_channel: + await self.main_channel.send( + f"πŸ“‘ μƒˆ μ„Έμ…˜ 감지: `{short_id}`" + ) + + async def _handle_file_event(self, event: BrainEvent): + """Handle file creation/modification events.""" + channel = await self._get_session_channel(event.conversation_id) + if not channel: + return + + if event.file_name == "task.md": + await self._send_task_update(channel, event) + else: + await self._send_artifact_content(channel, event) + + async def _send_task_update( + self, channel: discord.TextChannel, event: BrainEvent + ): + """Send task progress update as an embed.""" + progress = parse_task_progress(event.content) + + embed = discord.Embed( + title="πŸ“‹ Task μ§„ν–‰ ν˜„ν™©", + description=format_task_embed_text(progress), + color=discord.Color.gold() if progress.in_progress > 0 + else discord.Color.green() if progress.done == progress.total + else discord.Color.greyple(), + timestamp=datetime.now(timezone.utc), + ) + embed.set_footer(text=f"Session: {event.conversation_id[:8]}") + + # Edit existing status message or send new one + msg_id = self.session_status_messages.get(event.conversation_id) + if msg_id: + try: + msg = await channel.fetch_message(msg_id) + await msg.edit(embed=embed) + return + except (discord.NotFound, discord.HTTPException): + pass + + msg = await channel.send(embed=embed) + self.session_status_messages[event.conversation_id] = msg.id + + async def _send_artifact_content( + self, channel: discord.TextChannel, event: BrainEvent + ): + """Send artifact file content as Discord text messages.""" + # File type label + labels = { + "implementation_plan.md": "πŸ“ κ΅¬ν˜„ κ³„νš", + "walkthrough.md": "πŸ“ μž‘μ—… κ²°κ³Ό μš”μ•½", + } + label = labels.get(event.file_name, f"πŸ“„ {event.file_name}") + event_label = "생성" if event.event_type == EventType.FILE_CREATED else "μ—…λ°μ΄νŠΈ" + + # Header message + await channel.send(f"**{label} ({event_label}됨)**") + + # Convert and send content + chunks = md_to_discord_text(event.content) + for chunk in chunks: + if chunk.strip(): + await channel.send(chunk) + # Small delay to avoid rate limits + await asyncio.sleep(0.5) + + async def _get_session_channel( + self, conversation_id: str + ) -> discord.TextChannel | None: + """Get the Discord channel for a session.""" + channel_id = self.session_channels.get(conversation_id) + + # If no channel mapped, check if this is a known session and create one + if not channel_id: + # Auto-create for sessions that started before the bot + await self._handle_session_start(BrainEvent( + event_type=EventType.SESSION_START, + conversation_id=conversation_id, + )) + channel_id = self.session_channels.get(conversation_id) + + if channel_id: + channel = self.get_channel(channel_id) + if channel: + return channel + + return self.main_channel + + async def cleanup_session(self, conversation_id: str): + """Clean up when an Antigravity session ends.""" + channel_id = self.session_channels.pop(conversation_id, None) + self.session_status_messages.pop(conversation_id, None) + + if channel_id: + channel = self.get_channel(channel_id) + if channel and channel.id != Config.DISCORD_CHANNEL_ID: + try: + # Send closing message before archiving + embed = discord.Embed( + title="πŸ”΄ μ„Έμ…˜ μ’…λ£Œ", + description=f"Session `{conversation_id[:8]}` 이 μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + color=discord.Color.red(), + timestamp=datetime.now(timezone.utc), + ) + await channel.send(embed=embed) + + # Archive channel (move to bottom, read-only) + await channel.edit( + name=f"closed-{conversation_id[:8]}", + sync_permissions=True, + ) + logger.info(f"Archived channel for session {conversation_id[:8]}") + except discord.errors.Forbidden: + logger.warning(f"No permission to archive channel for {conversation_id}") diff --git a/config.py b/config.py new file mode 100644 index 0000000..a319ae0 --- /dev/null +++ b/config.py @@ -0,0 +1,48 @@ +"""Configuration module β€” loads settings from .env file or environment variables.""" + +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load .env from project root +load_dotenv(Path(__file__).parent / ".env") + + +class Config: + """Bridge configuration.""" + + # Discord + DISCORD_TOKEN: str = os.getenv("DISCORD_TOKEN", "") + DISCORD_CHANNEL_ID: int = int(os.getenv("DISCORD_CHANNEL_ID", "0")) + + # Antigravity Brain path + BRAIN_PATH: Path = Path(os.getenv( + "BRAIN_PATH", + os.path.expanduser("~/.gemini/antigravity/brain") + )) + + # Watcher settings + DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "2")) + + # Files to monitor within each conversation directory + WATCHED_FILES: set = { + "task.md", + "implementation_plan.md", + "walkthrough.md", + } + + # Discord message limits + DISCORD_MSG_LIMIT: int = 2000 + DISCORD_EMBED_DESC_LIMIT: int = 4096 + + @classmethod + def validate(cls) -> list[str]: + """Return list of configuration errors.""" + errors = [] + if not cls.DISCORD_TOKEN: + errors.append("DISCORD_TOKEN is not set") + if not cls.DISCORD_CHANNEL_ID: + errors.append("DISCORD_CHANNEL_ID is not set") + if not cls.BRAIN_PATH.exists(): + errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}") + return errors diff --git a/main.py b/main.py new file mode 100644 index 0000000..2a9c8e4 --- /dev/null +++ b/main.py @@ -0,0 +1,79 @@ +"""Gravity Control β€” Antigravity Discord Bridge. + +Entry point that runs the brain watcher and Discord bot together. +""" + +import asyncio +import logging +import signal +import sys + +from config import Config +from watcher import BrainWatcher +from bot import GravityBot + +# Logging setup +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("gravity_control.log", encoding="utf-8"), + ], +) +logger = logging.getLogger("gravity_control") + + +async def main(): + """Run the bridge: watcher + Discord bot.""" + + # Validate config + errors = Config.validate() + if errors: + for e in errors: + logger.error(f"Config error: {e}") + logger.error("Fix configuration issues and restart.") + sys.exit(1) + + logger.info("=" * 50) + logger.info("Gravity Control β€” Antigravity Discord Bridge") + logger.info("=" * 50) + logger.info(f"Brain path: {Config.BRAIN_PATH}") + logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s") + + # Shared event queue + event_queue = asyncio.Queue() + + # Get the running loop + loop = asyncio.get_running_loop() + + # Create components + watcher = BrainWatcher(event_queue, loop) + bot = GravityBot(event_queue) + + try: + # Start watcher (runs in a separate thread via watchdog) + watcher.start() + logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions") + + # Run Discord bot (blocks until bot disconnects) + await bot.start(Config.DISCORD_TOKEN) + + except KeyboardInterrupt: + logger.info("Received keyboard interrupt") + except Exception as e: + logger.error(f"Fatal error: {e}", exc_info=True) + finally: + # Cleanup + watcher.stop() + if not bot.is_closed(): + await bot.close() + logger.info("Gravity Control shutdown complete") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..363b56f --- /dev/null +++ b/parser.py @@ -0,0 +1,218 @@ +"""Markdown β†’ Discord text parser. + +Handles: +- task.md checkbox progress extraction +- MD β†’ Discord-friendly text conversion +- Long text splitting for Discord's 2000 char limit +""" + +import re +from dataclasses import dataclass + + +@dataclass +class TaskProgress: + """Parsed progress from task.md.""" + total: int = 0 + done: int = 0 + in_progress: int = 0 + pending: int = 0 + current_task: str = "" + sections: list = None + + def __post_init__(self): + if self.sections is None: + self.sections = [] + + @property + def summary_line(self) -> str: + bar_len = 10 + filled = round(self.done / max(self.total, 1) * bar_len) + bar = "β–ˆ" * filled + "β–‘" * (bar_len - filled) + return f"[{bar}] {self.done}/{self.total} μ™„λ£Œ" + + +def parse_task_progress(content: str) -> TaskProgress: + """Parse task.md and extract checkbox progress.""" + progress = TaskProgress() + current_section = "" + + for line in content.splitlines(): + # Section headers + header_match = re.match(r'^#{1,3}\s+(.+)', line) + if header_match: + current_section = header_match.group(1).strip() + continue + + # Checkboxes + checkbox_match = re.match(r'^\s*-\s*\[([ x/])\]\s*(.+)', line) + if checkbox_match: + state, text = checkbox_match.groups() + progress.total += 1 + + if state == 'x': + progress.done += 1 + elif state == '/': + progress.in_progress += 1 + progress.current_task = text.strip() + else: + progress.pending += 1 + + progress.sections.append({ + "section": current_section, + "state": state, + "text": text.strip() + }) + + return progress + + +def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]: + """Convert markdown to Discord-friendly text, splitting into chunks. + + Preserves: + - Headers β†’ **bold** + - Code blocks β†’ unchanged (Discord supports ```) + - Checkboxes β†’ emoji representation + - Tables β†’ simplified text + + Strips: + - Mermaid diagrams + - HTML comments + - Alert syntax (> [!NOTE] etc.) + + Returns list of text chunks, each under max_length. + """ + lines = content.splitlines() + output_lines = [] + in_mermaid = False + in_code_block = False + + for line in lines: + # Skip mermaid blocks + if re.match(r'^```mermaid', line): + in_mermaid = True + output_lines.append("*(mermaid λ‹€μ΄μ–΄κ·Έλž¨ μƒλž΅)*") + continue + if in_mermaid: + if line.strip() == '```': + in_mermaid = False + continue + + # Track code blocks + if re.match(r'^```', line) and not in_mermaid: + in_code_block = not in_code_block + output_lines.append(line) + continue + + if in_code_block: + output_lines.append(line) + continue + + # Skip HTML comments + if re.match(r'^\s*\s*$', line): + continue + + # Skip alert syntax but keep content + alert_match = re.match(r'>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]', line) + if alert_match: + alert_type = alert_match.group(1) + emoji_map = { + "NOTE": "πŸ“", "TIP": "πŸ’‘", "IMPORTANT": "❗", + "WARNING": "⚠️", "CAUTION": "πŸ”΄" + } + output_lines.append(f"{emoji_map.get(alert_type, 'πŸ“Œ')} **{alert_type}**") + continue + + # Convert headers to bold + header_match = re.match(r'^(#{1,3})\s+(.+)', line) + if header_match: + level = len(header_match.group(1)) + text = header_match.group(2) + if level == 1: + output_lines.append(f"\n**━━ {text} ━━**\n") + elif level == 2: + output_lines.append(f"\n**β–Έ {text}**") + else: + output_lines.append(f"**{text}**") + continue + + # Convert checkboxes to emoji + checkbox_match = re.match(r'^(\s*)-\s*\[([ x/])\]\s*(.+)', line) + if checkbox_match: + indent, state, text = checkbox_match.groups() + emoji = {"x": "βœ…", "/": "πŸ”„", " ": "⬜"}.get(state, "⬜") + output_lines.append(f"{indent}{emoji} {text}") + continue + + # Convert blockquote markers + if line.startswith("> "): + output_lines.append(f"β”‚ {line[2:]}") + continue + + # Pass through everything else + output_lines.append(line) + + # Join and split into chunks + full_text = "\n".join(output_lines).strip() + return split_text(full_text, max_length) + + +def split_text(text: str, max_length: int = 1900) -> list[str]: + """Split text into chunks respecting Discord's message limit. + + Tries to split on newlines first, then on spaces. + """ + if len(text) <= max_length: + return [text] + + chunks = [] + current = "" + + for line in text.split("\n"): + if len(current) + len(line) + 1 > max_length: + if current: + chunks.append(current) + current = "" + + # If single line is too long, split on spaces + if len(line) > max_length: + words = line.split(" ") + for word in words: + if len(current) + len(word) + 1 > max_length: + if current: + chunks.append(current) + current = word + else: + current = f"{current} {word}" if current else word + else: + current = line + else: + current = f"{current}\n{line}" if current else line + + if current: + chunks.append(current) + + return chunks + + +def format_task_embed_text(progress: TaskProgress) -> str: + """Format task progress as a compact Discord text message.""" + lines = [ + f"πŸ“‹ **μ§„ν–‰ 상황** {progress.summary_line}", + ] + + if progress.current_task: + lines.append(f"πŸ”„ ν˜„μž¬: {progress.current_task}") + + # Group by section + current_section = "" + for item in progress.sections: + if item["section"] != current_section: + current_section = item["section"] + lines.append(f"\n**{current_section}**") + + emoji = {"x": "βœ…", "/": "πŸ”„", " ": "⬜"}.get(item["state"], "⬜") + lines.append(f" {emoji} {item['text']}") + + return "\n".join(lines) diff --git a/watcher.py b/watcher.py new file mode 100644 index 0000000..05e8975 --- /dev/null +++ b/watcher.py @@ -0,0 +1,171 @@ +"""Brain directory watcher β€” monitors Antigravity's brain/ for file changes. + +Uses watchdog to detect file creation/modification events in the brain directory. +Emits events to an asyncio queue for the Discord bot to consume. +""" + +import asyncio +import time +import logging +from pathlib import Path +from dataclasses import dataclass, field +from enum import Enum +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, FileSystemEvent + +from config import Config + +logger = logging.getLogger(__name__) + + +class EventType(Enum): + """Types of brain events.""" + SESSION_START = "session_start" # New conversation directory created + SESSION_END = "session_end" # Conversation directory removed (or program exit) + FILE_CHANGED = "file_changed" # Watched file created/modified + FILE_CREATED = "file_created" # Watched file first created + + +@dataclass +class BrainEvent: + """An event from the brain directory.""" + event_type: EventType + conversation_id: str + file_name: str = "" + file_path: Path = None + content: str = "" + timestamp: float = field(default_factory=time.time) + + +class BrainEventHandler(FileSystemEventHandler): + """Watchdog handler that filters and debounces brain events.""" + + def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop): + super().__init__() + self.event_queue = event_queue + self.loop = loop + self._last_events: dict[str, float] = {} # path -> timestamp (debounce) + self._known_sessions: set[str] = set() + self._initialize_known_sessions() + + def _initialize_known_sessions(self): + """Scan existing brain directories to establish baseline.""" + brain_path = Config.BRAIN_PATH + if brain_path.exists(): + for entry in brain_path.iterdir(): + if entry.is_dir() and self._is_conversation_id(entry.name): + self._known_sessions.add(entry.name) + logger.info(f"Found {len(self._known_sessions)} existing sessions at startup") + + def _is_conversation_id(self, name: str) -> bool: + """Check if directory name looks like a UUID conversation ID.""" + parts = name.split("-") + return len(parts) == 5 and all(len(p) >= 4 for p in parts) + + def _get_conversation_id(self, path: Path) -> str | None: + """Extract conversation ID from file path.""" + brain_path = Config.BRAIN_PATH + try: + relative = path.relative_to(brain_path) + parts = relative.parts + if parts and self._is_conversation_id(parts[0]): + return parts[0] + except ValueError: + pass + return None + + def _should_debounce(self, path_str: str) -> bool: + """Check if this event should be debounced.""" + now = time.time() + last = self._last_events.get(path_str, 0) + if now - last < Config.DEBOUNCE_SECONDS: + return True + self._last_events[path_str] = now + return False + + def _emit(self, event: BrainEvent): + """Thread-safe emit to asyncio queue.""" + self.loop.call_soon_threadsafe(self.event_queue.put_nowait, event) + + def on_created(self, event: FileSystemEvent): + if event.is_directory: + self._handle_directory_created(Path(event.src_path)) + else: + self._handle_file_event(Path(event.src_path), EventType.FILE_CREATED) + + def on_modified(self, event: FileSystemEvent): + if not event.is_directory: + self._handle_file_event(Path(event.src_path), EventType.FILE_CHANGED) + + def _handle_directory_created(self, path: Path): + """Detect new session directories.""" + conv_id = self._get_conversation_id(path) + if conv_id and conv_id not in self._known_sessions: + # Check if this is a direct child of brain/ + if path.parent == Config.BRAIN_PATH: + self._known_sessions.add(conv_id) + logger.info(f"New session detected: {conv_id}") + self._emit(BrainEvent( + event_type=EventType.SESSION_START, + conversation_id=conv_id, + )) + + def _handle_file_event(self, path: Path, event_type: EventType): + """Process file creation/modification events.""" + conv_id = self._get_conversation_id(path) + if not conv_id: + return + + file_name = path.name + if file_name not in Config.WATCHED_FILES: + return + + if self._should_debounce(str(path)): + return + + # Read file content + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as e: + logger.warning(f"Failed to read {path}: {e}") + return + + logger.info(f"File event: {event_type.value} {conv_id}/{file_name}") + self._emit(BrainEvent( + event_type=event_type, + conversation_id=conv_id, + file_name=file_name, + file_path=path, + content=content, + )) + + +class BrainWatcher: + """Manages the watchdog observer for the brain directory.""" + + def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop): + self.event_queue = event_queue + self.loop = loop + self.observer = Observer() + self.handler = BrainEventHandler(event_queue, loop) + + def start(self): + """Start watching the brain directory.""" + brain_path = Config.BRAIN_PATH + if not brain_path.exists(): + logger.error(f"Brain path does not exist: {brain_path}") + return + + self.observer.schedule(self.handler, str(brain_path), recursive=True) + self.observer.start() + logger.info(f"Watching brain directory: {brain_path}") + + def stop(self): + """Stop the watcher.""" + self.observer.stop() + self.observer.join() + logger.info("Brain watcher stopped") + + @property + def known_sessions(self) -> set[str]: + return self.handler._known_sessions