refactor: 전면 재설계 - 시작 시 채널 스팸 제거, content hash 중복 방지, 단일 이벤트 경로

This commit is contained in:
2026-03-07 11:42:11 +09:00
parent 52fed8c1d3
commit e32be6b2f3
3 changed files with 155 additions and 312 deletions

405
bot.py
View File

@@ -1,10 +1,9 @@
"""Discord bot — relays Antigravity brain events to Discord channels. """Discord bot — relays Antigravity brain events to Discord channels.
Dynamic channel management: Dynamic channel management:
- Scans brain/ for active sessions on startup - Creates `AG-{project_name}` channels only when file events arrive
- Creates `gravity-{project_name}` channels per active session - NO startup channel creation — only reconnects to existing Discord channels
- Archives channels when sessions become inactive - Archives channels after 10 minutes of inactivity
- Project name extracted from artifact content or short conversation ID
""" """
import asyncio import asyncio
@@ -30,11 +29,13 @@ from bridge import BridgeProtocol, ApprovalRequest, UserResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ─── Discord UI Components ──────────────────────────────────────────
class ApprovalView(discord.ui.View): class ApprovalView(discord.ui.View):
"""Discord buttons for approving/rejecting Antigravity actions.""" """Discord buttons for approving/rejecting Antigravity actions."""
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest): def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest):
super().__init__(timeout=300) # 5 min timeout super().__init__(timeout=300)
self.bridge = bridge self.bridge = bridge
self.request = request self.request = request
self.responded = False self.responded = False
@@ -46,8 +47,7 @@ class ApprovalView(discord.ui.View):
return return
self.responded = True self.responded = True
self.bridge.write_response(UserResponse( self.bridge.write_response(UserResponse(
request_id=self.request.request_id, request_id=self.request.request_id, approved=True,
approved=True,
)) ))
embed = interaction.message.embeds[0] if interaction.message.embeds else None embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed: if embed:
@@ -62,8 +62,7 @@ class ApprovalView(discord.ui.View):
return return
self.responded = True self.responded = True
self.bridge.write_response(UserResponse( self.bridge.write_response(UserResponse(
request_id=self.request.request_id, request_id=self.request.request_id, approved=False,
approved=False,
)) ))
embed = interaction.message.embeds[0] if interaction.message.embeds else None embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed: if embed:
@@ -72,99 +71,46 @@ class ApprovalView(discord.ui.View):
await interaction.response.edit_message(embed=embed, view=None) await interaction.response.edit_message(embed=embed, view=None)
async def on_timeout(self): async def on_timeout(self):
"""Auto-timeout after 5 minutes."""
if not self.responded: if not self.responded:
self.bridge.write_response(UserResponse( self.bridge.write_response(UserResponse(
request_id=self.request.request_id, request_id=self.request.request_id, approved=False,
approved=False,
)) ))
# ─── Project Name Detection ─────────────────────────────────────────
def detect_project_name(conv_dir: Path) -> str: def detect_project_name(conv_dir: Path) -> str:
"""Extract a human-readable project name from conversation artifacts. """Extract project name from conversation artifacts.
Output: lowercase_with_underscores (e.g. 'gravity_control')
Strategy:
1. Check task.md first heading for a title
2. Check implementation_plan.md first heading
3. Check any .metadata.json for summary keywords
4. Fallback to short conversation ID
Output format: lowercase_with_underscores (e.g. 'gravity_control')
""" """
short_id = conv_dir.name[:8] short_id = conv_dir.name[:8]
def _sanitize(raw: str) -> str: def _sanitize(raw: str) -> str:
"""Convert raw title to Discord-friendly channel name part."""
# Remove common suffixes
for suffix in ["Task Tracker", "— Task Tracker", "태스크", "구현 계획"]: for suffix in ["Task Tracker", "— Task Tracker", "태스크", "구현 계획"]:
raw = raw.replace(suffix, "") raw = raw.replace(suffix, "")
raw = raw.strip(" —-") raw = raw.strip(" —-")
# Keep only alphanumeric, Korean, spaces, hyphens
raw = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', raw) raw = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', raw)
# Convert to underscore format
raw = re.sub(r'[\s\-]+', '_', raw).strip('_').lower() raw = re.sub(r'[\s\-]+', '_', raw).strip('_').lower()
return raw[:30] if raw else "" return raw[:30] if raw else ""
# Try task.md title for fname in ["task.md", "implementation_plan.md"]:
task_file = conv_dir / "task.md" fpath = conv_dir / fname
if task_file.exists(): if fpath.exists():
try: try:
first_lines = task_file.read_text(encoding="utf-8").splitlines()[:5] first_lines = fpath.read_text(encoding="utf-8").splitlines()[:5]
for line in first_lines: for line in first_lines:
match = re.match(r'^#\s+(.+)', line) match = re.match(r'^#\s+(.+)', line)
if match: if match:
name = _sanitize(match.group(1)) name = _sanitize(match.group(1))
if name and name != "task": if name and name != "task":
return name return name
except (OSError, UnicodeDecodeError): except (OSError, UnicodeDecodeError):
pass pass
# Try implementation_plan.md title
plan_file = conv_dir / "implementation_plan.md"
if plan_file.exists():
try:
first_lines = plan_file.read_text(encoding="utf-8").splitlines()[:5]
for line in first_lines:
match = re.match(r'^#\s+(.+)', line)
if match:
name = _sanitize(match.group(1))
if name:
return name
except (OSError, UnicodeDecodeError):
pass
# Try metadata summary (first 2 words)
for meta_file in conv_dir.glob("*.metadata.json"):
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
summary = meta.get("summary", "")
words = summary.split()[:2]
if words:
name = "_".join(words).lower()
name = re.sub(r'[^a-z0-9가-힣_]', '', name)[:30]
if name:
return name
except (OSError, json.JSONDecodeError):
pass
return short_id return short_id
def is_session_active(conv_dir: Path) -> bool: # ─── Bot ─────────────────────────────────────────────────────────────
"""Check if a session is active based on file modification time."""
now = time.time()
threshold = Config.ACTIVE_TIMEOUT_SECONDS
for f in conv_dir.iterdir():
if f.is_file():
try:
mtime = f.stat().st_mtime
if now - mtime < threshold:
return True
except OSError:
continue
return False
class GravityBot(commands.Bot): class GravityBot(commands.Bot):
"""Discord bot for Antigravity session monitoring.""" """Discord bot for Antigravity session monitoring."""
@@ -176,41 +122,28 @@ class GravityBot(commands.Bot):
super().__init__(command_prefix="!", intents=intents) super().__init__(command_prefix="!", intents=intents)
self.event_queue = event_queue self.event_queue = event_queue
# conversation_id -> channel object
self.session_channels: dict[str, discord.TextChannel] = {} self.session_channels: dict[str, discord.TextChannel] = {}
# conversation_id -> status message id (to edit in-place)
self.session_status_messages: dict[str, int] = {} self.session_status_messages: dict[str, int] = {}
# conversation_id -> project name
self.session_names: dict[str, str] = {} self.session_names: dict[str, str] = {}
# Locks to prevent duplicate channel creation
self._channel_locks: dict[str, asyncio.Lock] = {} self._channel_locks: dict[str, asyncio.Lock] = {}
# Bridge protocol for bidirectional communication
self.bridge = BridgeProtocol() self.bridge = BridgeProtocol()
# Cache: conversation_id -> last metadata summary (skip unchanged)
self._last_meta_summary: dict[str, str] = {}
# Category for session channels
self.session_category: discord.CategoryChannel | None = None self.session_category: discord.CategoryChannel | None = None
# Guild reference
self.guild: discord.Guild | None = None self.guild: discord.Guild | None = None
async def setup_hook(self): async def setup_hook(self):
"""Called after login, before processing events."""
self.loop.create_task(self._process_events()) self.loop.create_task(self._process_events())
self.session_cleanup_loop.start()
self.pending_approval_scanner.start() self.pending_approval_scanner.start()
logger.info("Bot setup complete, event processor + approval scanner started") logger.info("Bot setup complete, event processor started")
async def on_ready(self): async def on_ready(self):
"""Called when bot is ready."""
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})") logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
# Get guild
self.guild = self.get_guild(Config.DISCORD_GUILD_ID) self.guild = self.get_guild(Config.DISCORD_GUILD_ID)
if not self.guild: if not self.guild:
logger.error(f"Guild {Config.DISCORD_GUILD_ID} not found!") logger.error(f"Guild {Config.DISCORD_GUILD_ID} not found!")
return return
# Find or create session category # Find or create category
category_name = "Antigravity Sessions" category_name = "Antigravity Sessions"
self.session_category = discord.utils.get( self.session_category = discord.utils.get(
self.guild.categories, name=category_name self.guild.categories, name=category_name
@@ -223,66 +156,83 @@ class GravityBot(commands.Bot):
logger.error("No permission to create category!") logger.error("No permission to create category!")
return return
# Sync existing active sessions # ONLY reconnect to existing Discord channels (NO new channel creation)
await self._sync_active_sessions() await self._reconnect_existing_channels()
async def _sync_active_sessions(self): async def _reconnect_existing_channels(self):
"""Scan brain/ and create channels for currently active sessions.""" """Scan existing Discord channels and map them to conversation IDs.
brain_path = Config.BRAIN_PATH Does NOT create any new channels."""
if not brain_path.exists(): if not self.session_category:
return return
active_count = 0 count = 0
for entry in brain_path.iterdir(): for ch in self.session_category.text_channels:
if entry.is_dir() and self._is_conversation_id(entry.name): if ch.topic and "Antigravity Session:" in ch.topic:
if is_session_active(entry): # Extract conversation ID from topic
project_name = detect_project_name(entry) conv_id = ch.topic.replace("Antigravity Session:", "").strip()
await self._ensure_channel(entry.name, project_name) if conv_id:
active_count += 1 self.session_channels[conv_id] = ch
# Recover last task embed
await self._recover_task_message(ch, conv_id)
count += 1
logger.info(f"Synced {active_count} active sessions on startup") logger.info(f"Reconnected to {count} existing channels")
def _is_conversation_id(self, name: str) -> bool: async def _recover_task_message(
"""Check if directory name looks like a UUID.""" self, channel: discord.TextChannel, conversation_id: str
parts = name.split("-") ):
return len(parts) == 5 and all(len(p) >= 4 for p in parts) """Find last task embed in channel to reuse for editing."""
if conversation_id in self.session_status_messages:
return
try:
async for msg in channel.history(limit=10):
if msg.author == self.user and msg.embeds:
embed = msg.embeds[0]
if embed.title and "Task" in embed.title:
self.session_status_messages[conversation_id] = msg.id
return
except (discord.Forbidden, discord.HTTPException):
pass
# ─── Channel Management ──────────────────────────────────────────
async def _ensure_channel( async def _ensure_channel(
self, conversation_id: str, project_name: str self, conversation_id: str, project_name: str
) -> discord.TextChannel: ) -> discord.TextChannel:
"""Get or create a Discord channel for a session (thread-safe).""" """Get or create a channel (thread-safe, single creation per session)."""
# Fast path: already mapped
if conversation_id in self.session_channels: if conversation_id in self.session_channels:
return self.session_channels[conversation_id] ch = self.session_channels[conversation_id]
# Rename back from "closed-" if needed
expected = f"{Config.CHANNEL_PREFIX}-{project_name}".lower()
if ch.name.startswith("closed-") and ch.name != expected:
try:
await ch.edit(name=f"{Config.CHANNEL_PREFIX}-{project_name}")
except discord.errors.Forbidden:
pass
return ch
# Get or create a lock for this conversation # Lock per conversation to prevent duplicates
if conversation_id not in self._channel_locks: if conversation_id not in self._channel_locks:
self._channel_locks[conversation_id] = asyncio.Lock() self._channel_locks[conversation_id] = asyncio.Lock()
async with self._channel_locks[conversation_id]: async with self._channel_locks[conversation_id]:
# Double-check after acquiring lock # Double-check
if conversation_id in self.session_channels: if conversation_id in self.session_channels:
return self.session_channels[conversation_id] return self.session_channels[conversation_id]
channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}" channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}"
# Check if channel already exists in category (from previous run) # Check if channel exists but wasn't mapped yet
if self.session_category: if self.session_category:
for ch in self.session_category.text_channels: for ch in self.session_category.text_channels:
if ch.topic and conversation_id in ch.topic: if ch.topic and conversation_id in ch.topic:
self.session_channels[conversation_id] = ch self.session_channels[conversation_id] = ch
self.session_names[conversation_id] = project_name self.session_names[conversation_id] = project_name
# Rename back from closed- if needed if ch.name.startswith("closed-"):
expected_name = f"{Config.CHANNEL_PREFIX}-{project_name}"
if ch.name != expected_name.lower():
try: try:
await ch.edit(name=expected_name) await ch.edit(name=channel_name)
logger.info(f"Renamed channel {ch.name} -> {expected_name}")
except discord.errors.Forbidden: except discord.errors.Forbidden:
pass pass
# Recover last task embed message ID
await self._recover_task_message(ch, conversation_id)
logger.info(f"Reconnected to existing channel #{ch.name}")
return ch return ch
# Create new channel # Create new channel
@@ -296,13 +246,9 @@ class GravityBot(commands.Bot):
self.session_names[conversation_id] = project_name self.session_names[conversation_id] = project_name
logger.info(f"Created channel #{channel_name}") logger.info(f"Created channel #{channel_name}")
# Welcome embed
embed = discord.Embed( embed = discord.Embed(
title=f"🚀 {project_name}", title=f"🚀 {project_name}",
description=( description=f"Antigravity 세션 연결됨\nSession: `{conversation_id}`",
f"Antigravity 세션 연결됨\n"
f"Session: `{conversation_id}`"
),
color=discord.Color.blue(), color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
@@ -313,26 +259,10 @@ class GravityBot(commands.Bot):
logger.error(f"No permission to create channel: {channel_name}") logger.error(f"No permission to create channel: {channel_name}")
return None return None
async def _recover_task_message( # ─── Event Processing (SINGLE ROUTE) ─────────────────────────────
self, channel: discord.TextChannel, conversation_id: str
):
"""Find the last task embed in channel history to reuse for editing."""
if conversation_id in self.session_status_messages:
return # Already have it
try:
async for msg in channel.history(limit=20):
if msg.author == self.user and msg.embeds:
embed = msg.embeds[0]
if embed.title and "Task 진행 현황" in embed.title:
self.session_status_messages[conversation_id] = msg.id
logger.info(f"Recovered task embed msg {msg.id} for {conversation_id[:8]}")
return
except (discord.Forbidden, discord.HTTPException):
pass
async def _process_events(self): async def _process_events(self):
"""Main event processing loop.""" """Main event loop — ALL events go through here sequentially."""
await self.wait_until_ready() await self.wait_until_ready()
while not self.is_closed(): while not self.is_closed():
@@ -347,30 +277,30 @@ class GravityBot(commands.Bot):
logger.error(f"Error processing event: {e}", exc_info=True) logger.error(f"Error processing event: {e}", exc_info=True)
async def _handle_event(self, event: BrainEvent): async def _handle_event(self, event: BrainEvent):
"""Route brain events to handlers.""" """Route brain events to handlers — single entry point."""
conv_dir = Config.BRAIN_PATH / event.conversation_id
project_name = detect_project_name(conv_dir)
if event.event_type == EventType.SESSION_START: if event.event_type == EventType.SESSION_START:
conv_dir = Config.BRAIN_PATH / event.conversation_id
project_name = detect_project_name(conv_dir)
await self._ensure_channel(event.conversation_id, project_name) await self._ensure_channel(event.conversation_id, project_name)
return
elif event.event_type in (EventType.FILE_CREATED, EventType.FILE_CHANGED): # FILE_CREATED or FILE_CHANGED
# Ensure channel exists channel = await self._ensure_channel(event.conversation_id, project_name)
conv_dir = Config.BRAIN_PATH / event.conversation_id if not channel:
project_name = detect_project_name(conv_dir) return
channel = await self._ensure_channel(event.conversation_id, project_name)
if channel: if event.file_name == "task.md":
if event.file_name == "task.md": await self._send_task_update(channel, event)
await self._send_task_update(channel, event) else:
elif event.file_name.endswith(".metadata.json"): await self._send_artifact_update(channel, event)
await self._send_metadata_update(channel, event)
else: # ─── Message Senders ─────────────────────────────────────────────
await self._send_artifact_content(channel, event)
async def _send_task_update( async def _send_task_update(
self, channel: discord.TextChannel, event: BrainEvent self, channel: discord.TextChannel, event: BrainEvent
): ):
"""Send task progress update as an embed.""" """Send/edit task progress embed (SINGLE message, always edited)."""
progress = parse_task_progress(event.content) progress = parse_task_progress(event.content)
embed = discord.Embed( embed = discord.Embed(
@@ -381,10 +311,9 @@ class GravityBot(commands.Bot):
else discord.Color.greyple(), else discord.Color.greyple(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
short_id = event.conversation_id[:8] embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
embed.set_footer(text=f"Session: {short_id}")
# Edit existing or send new # Always try to edit existing message first
msg_id = self.session_status_messages.get(event.conversation_id) msg_id = self.session_status_messages.get(event.conversation_id)
if msg_id: if msg_id:
try: try:
@@ -397,10 +326,10 @@ class GravityBot(commands.Bot):
msg = await channel.send(embed=embed) msg = await channel.send(embed=embed)
self.session_status_messages[event.conversation_id] = msg.id self.session_status_messages[event.conversation_id] = msg.id
async def _send_artifact_content( async def _send_artifact_update(
self, channel: discord.TextChannel, event: BrainEvent self, channel: discord.TextChannel, event: BrainEvent
): ):
"""Send artifact change notification (compact — metadata handler sends summary).""" """Send artifact update as single compact embed (preview only)."""
labels = { labels = {
"implementation_plan.md": "📐 구현 계획", "implementation_plan.md": "📐 구현 계획",
"walkthrough.md": "📝 작업 결과 요약", "walkthrough.md": "📝 작업 결과 요약",
@@ -408,15 +337,11 @@ class GravityBot(commands.Bot):
label = labels.get(event.file_name, f"📄 {event.file_name}") label = labels.get(event.file_name, f"📄 {event.file_name}")
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트" event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
# Only send first few lines as preview instead of full content # Preview: first 6 non-empty lines only
lines = event.content.strip().splitlines() lines = event.content.strip().splitlines()
preview_lines = [] preview = "\n".join(l for l in lines[:6] if l.strip())
for line in lines[:8]: if len(lines) > 6:
if line.strip(): preview += f"\n... (+{len(lines) - 6} lines)"
preview_lines.append(line)
preview = "\n".join(preview_lines)
if len(lines) > 8:
preview += f"\n... (+{len(lines) - 8} lines)"
embed = discord.Embed( embed = discord.Embed(
title=f"{label} ({event_label}됨)", title=f"{label} ({event_label}됨)",
@@ -426,108 +351,30 @@ class GravityBot(commands.Bot):
) )
await channel.send(embed=embed) await channel.send(embed=embed)
async def _send_metadata_update( # ─── Approval Scanner ────────────────────────────────────────────
self, channel: discord.TextChannel, event: BrainEvent
):
"""Send artifact metadata summary changes as compact embed."""
try:
meta = json.loads(event.content)
except json.JSONDecodeError:
return
summary = meta.get("summary", "")
artifact_type = meta.get("artifactType", "")
if not summary:
return
# Dedup: skip if summary unchanged since last notification
cache_key = f"{event.conversation_id}:{event.file_name}"
if self._last_meta_summary.get(cache_key) == summary:
return
self._last_meta_summary[cache_key] = summary
# Map artifact types to emoji
type_emoji = {
"ARTIFACT_TYPE_TASK": "📋",
"ARTIFACT_TYPE_IMPLEMENTATION_PLAN": "📐",
"ARTIFACT_TYPE_WALKTHROUGH": "📝",
"ARTIFACT_TYPE_OTHER": "📄",
}
emoji = type_emoji.get(artifact_type, "📄")
# Artifact name from filename (strip .metadata.json)
artifact_name = event.file_name.replace(".metadata.json", "")
embed = discord.Embed(
description=f"{emoji} **{artifact_name}** 업데이트\n\n{summary[:500]}",
color=discord.Color.dark_teal(),
timestamp=datetime.now(timezone.utc),
)
await channel.send(embed=embed)
@tasks.loop(minutes=5)
async def session_cleanup_loop(self):
"""Periodically check for inactive sessions and archive their channels."""
if not self.guild:
return
to_remove = []
for conv_id, channel in self.session_channels.items():
conv_dir = Config.BRAIN_PATH / conv_id
if not conv_dir.exists() or not is_session_active(conv_dir):
to_remove.append(conv_id)
for conv_id in to_remove:
channel = self.session_channels.pop(conv_id, None)
self.session_status_messages.pop(conv_id, None)
name = self.session_names.pop(conv_id, conv_id[:8])
if channel:
try:
embed = discord.Embed(
title="🔴 세션 비활성",
description=f"`{name}` 세션이 비활성 상태입니다.",
color=discord.Color.red(),
timestamp=datetime.now(timezone.utc),
)
await channel.send(embed=embed)
await channel.edit(name=f"closed-{name[:20]}")
logger.info(f"Archived channel for {conv_id[:8]}")
except discord.errors.Forbidden:
logger.warning(f"No permission to archive {conv_id[:8]}")
except Exception as e:
logger.warning(f"Failed to archive {conv_id[:8]}: {e}")
@session_cleanup_loop.before_loop
async def before_cleanup(self):
await self.wait_until_ready()
@tasks.loop(seconds=3) @tasks.loop(seconds=3)
async def pending_approval_scanner(self): async def pending_approval_scanner(self):
"""Scan bridge/pending/ for new approval requests and send Discord buttons.""" """Scan bridge/pending/ for new approval requests."""
try: try:
requests = self.bridge.get_pending_requests() requests = self.bridge.get_pending_requests()
for req in requests: for req in requests:
# Find the right channel if req.discord_message_id != 0:
if req.conversation_id in self.session_channels: continue # Already sent
channel = self.session_channels[req.conversation_id]
elif req.discord_message_id == 0: channel = self.session_channels.get(req.conversation_id)
# Try to find channel by conversation_id if not channel:
conv_dir = Config.BRAIN_PATH / req.conversation_id conv_dir = Config.BRAIN_PATH / req.conversation_id
if conv_dir.exists(): if conv_dir.exists():
project_name = detect_project_name(conv_dir) project_name = detect_project_name(conv_dir)
channel = await self._ensure_channel(req.conversation_id, project_name) channel = await self._ensure_channel(
else: req.conversation_id, project_name
continue )
else:
continue
if channel and req.discord_message_id == 0: if channel:
await self._send_approval_request(channel, req) await self._send_approval_request(channel, req)
except Exception as e: except Exception as e:
logger.error(f"Error scanning pending approvals: {e}") logger.error(f"Error scanning approvals: {e}")
@pending_approval_scanner.before_loop @pending_approval_scanner.before_loop
async def before_scanner(self): async def before_scanner(self):
@@ -536,7 +383,6 @@ class GravityBot(commands.Bot):
async def _send_approval_request( async def _send_approval_request(
self, channel: discord.TextChannel, request: ApprovalRequest self, channel: discord.TextChannel, request: ApprovalRequest
): ):
"""Send an approval request with buttons to Discord."""
embed = discord.Embed( embed = discord.Embed(
title="⚠️ 승인 요청", title="⚠️ 승인 요청",
description=( description=(
@@ -558,25 +404,22 @@ class GravityBot(commands.Bot):
data = json.loads(pending_file.read_text(encoding="utf-8")) data = json.loads(pending_file.read_text(encoding="utf-8"))
data["discord_message_id"] = msg.id data["discord_message_id"] = msg.id
pending_file.write_text( pending_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2), json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
encoding="utf-8"
) )
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
logger.info(f"Sent approval request: {request.request_id[:8]}") logger.info(f"Sent approval request: {request.request_id[:8]}")
# ─── Discord → Antigravity Text Relay ─────────────────────────────
async def on_message(self, message: discord.Message): async def on_message(self, message: discord.Message):
"""Handle user messages in AG channels → relay as text input."""
# Ignore bot's own messages
if message.author == self.user: if message.author == self.user:
return return
# Check if message is in an AG session channel if not message.channel.name.startswith(Config.CHANNEL_PREFIX.lower() + "-"):
if not message.channel.name.startswith(Config.CHANNEL_PREFIX + "-"):
return return
# Find conversation_id for this channel
conv_id = None conv_id = None
for cid, ch in self.session_channels.items(): for cid, ch in self.session_channels.items():
if ch.id == message.channel.id: if ch.id == message.channel.id:
@@ -584,11 +427,7 @@ class GravityBot(commands.Bot):
break break
if conv_id and message.content.strip(): if conv_id and message.content.strip():
# Write user input to bridge commands self.bridge.write_command(conv_id, message.content.strip())
cmd_id = self.bridge.write_command(conv_id, message.content.strip())
await message.add_reaction("📨") await message.add_reaction("📨")
logger.info(f"User input relayed: {message.content[:50]}... → {conv_id[:8]}")
# Process commands (e.g., !approve, !reject)
await self.process_commands(message) await self.process_commands(message)

View File

@@ -21,24 +21,16 @@ class Config:
os.path.expanduser("~/.gemini/antigravity/brain") os.path.expanduser("~/.gemini/antigravity/brain")
)) ))
# Session activity detection
ACTIVE_TIMEOUT_SECONDS: int = int(os.getenv("ACTIVE_TIMEOUT_SECONDS", "300"))
# Watcher settings # Watcher settings
DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "2")) DEBOUNCE_SECONDS: float = float(os.getenv("DEBOUNCE_SECONDS", "5"))
# Files to monitor within each conversation directory # Files to monitor within each conversation directory (PRIMARY ONLY)
WATCHED_FILES: set = { WATCHED_FILES: set = {
"task.md", "task.md",
"implementation_plan.md", "implementation_plan.md",
"walkthrough.md", "walkthrough.md",
} }
# Also monitor these patterns (matched by suffix)
WATCHED_SUFFIXES: set = {
".metadata.json", # artifact summary changes
}
# Discord message limits # Discord message limits
DISCORD_MSG_LIMIT: int = 2000 DISCORD_MSG_LIMIT: int = 2000
DISCORD_EMBED_DESC_LIMIT: int = 4096 DISCORD_EMBED_DESC_LIMIT: int = 4096

View File

@@ -2,9 +2,12 @@
Uses watchdog to detect file creation/modification events in the brain directory. Uses watchdog to detect file creation/modification events in the brain directory.
Emits events to an asyncio queue for the Discord bot to consume. Emits events to an asyncio queue for the Discord bot to consume.
Key design: ONLY emits events for meaningful content changes using hash dedup.
""" """
import asyncio import asyncio
import hashlib
import time import time
import logging import logging
from pathlib import Path from pathlib import Path
@@ -21,8 +24,7 @@ logger = logging.getLogger(__name__)
class EventType(Enum): class EventType(Enum):
"""Types of brain events.""" """Types of brain events."""
SESSION_START = "session_start" # New conversation directory created SESSION_START = "session_start" # New conversation directory created
SESSION_END = "session_end" # Conversation directory removed (or program exit) FILE_CHANGED = "file_changed" # Watched file modified
FILE_CHANGED = "file_changed" # Watched file created/modified
FILE_CREATED = "file_created" # Watched file first created FILE_CREATED = "file_created" # Watched file first created
@@ -38,18 +40,19 @@ class BrainEvent:
class BrainEventHandler(FileSystemEventHandler): class BrainEventHandler(FileSystemEventHandler):
"""Watchdog handler that filters and debounces brain events.""" """Watchdog handler that filters, debounces, and deduplicates brain events."""
def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop): def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
super().__init__() super().__init__()
self.event_queue = event_queue self.event_queue = event_queue
self.loop = loop self.loop = loop
self._last_events: dict[str, float] = {} # path -> timestamp (debounce) self._last_events: dict[str, float] = {} # path -> timestamp (debounce)
self._content_hashes: dict[str, str] = {} # path -> md5 hash (dedup)
self._known_sessions: set[str] = set() self._known_sessions: set[str] = set()
self._initialize_known_sessions() self._initialize_known_sessions()
def _initialize_known_sessions(self): def _initialize_known_sessions(self):
"""Scan existing brain directories to establish baseline.""" """Scan existing brain directories to establish baseline (no events emitted)."""
brain_path = Config.BRAIN_PATH brain_path = Config.BRAIN_PATH
if brain_path.exists(): if brain_path.exists():
for entry in brain_path.iterdir(): for entry in brain_path.iterdir():
@@ -58,12 +61,10 @@ class BrainEventHandler(FileSystemEventHandler):
logger.info(f"Found {len(self._known_sessions)} existing sessions at startup") logger.info(f"Found {len(self._known_sessions)} existing sessions at startup")
def _is_conversation_id(self, name: str) -> bool: def _is_conversation_id(self, name: str) -> bool:
"""Check if directory name looks like a UUID conversation ID."""
parts = name.split("-") parts = name.split("-")
return len(parts) == 5 and all(len(p) >= 4 for p in parts) return len(parts) == 5 and all(len(p) >= 4 for p in parts)
def _get_conversation_id(self, path: Path) -> str | None: def _get_conversation_id(self, path: Path) -> str | None:
"""Extract conversation ID from file path."""
brain_path = Config.BRAIN_PATH brain_path = Config.BRAIN_PATH
try: try:
relative = path.relative_to(brain_path) relative = path.relative_to(brain_path)
@@ -75,7 +76,6 @@ class BrainEventHandler(FileSystemEventHandler):
return None return None
def _should_debounce(self, path_str: str) -> bool: def _should_debounce(self, path_str: str) -> bool:
"""Check if this event should be debounced."""
now = time.time() now = time.time()
last = self._last_events.get(path_str, 0) last = self._last_events.get(path_str, 0)
if now - last < Config.DEBOUNCE_SECONDS: if now - last < Config.DEBOUNCE_SECONDS:
@@ -83,8 +83,20 @@ class BrainEventHandler(FileSystemEventHandler):
self._last_events[path_str] = now self._last_events[path_str] = now
return False return False
def _content_changed(self, path_str: str, content: str) -> bool:
"""Check if content actually changed using MD5 hash."""
new_hash = hashlib.md5(content.encode()).hexdigest()
old_hash = self._content_hashes.get(path_str)
if old_hash == new_hash:
return False
self._content_hashes[path_str] = new_hash
return True
def _is_watched_file(self, file_name: str) -> bool:
"""Strict filter: only watch primary artifact files."""
return file_name in Config.WATCHED_FILES
def _emit(self, event: BrainEvent): def _emit(self, event: BrainEvent):
"""Thread-safe emit to asyncio queue."""
self.loop.call_soon_threadsafe(self.event_queue.put_nowait, event) self.loop.call_soon_threadsafe(self.event_queue.put_nowait, event)
def on_created(self, event: FileSystemEvent): def on_created(self, event: FileSystemEvent):
@@ -98,10 +110,8 @@ class BrainEventHandler(FileSystemEventHandler):
self._handle_file_event(Path(event.src_path), EventType.FILE_CHANGED) self._handle_file_event(Path(event.src_path), EventType.FILE_CHANGED)
def _handle_directory_created(self, path: Path): def _handle_directory_created(self, path: Path):
"""Detect new session directories."""
conv_id = self._get_conversation_id(path) conv_id = self._get_conversation_id(path)
if conv_id and conv_id not in self._known_sessions: 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: if path.parent == Config.BRAIN_PATH:
self._known_sessions.add(conv_id) self._known_sessions.add(conv_id)
logger.info(f"New session detected: {conv_id}") logger.info(f"New session detected: {conv_id}")
@@ -111,17 +121,17 @@ class BrainEventHandler(FileSystemEventHandler):
)) ))
def _handle_file_event(self, path: Path, event_type: EventType): def _handle_file_event(self, path: Path, event_type: EventType):
"""Process file creation/modification events."""
conv_id = self._get_conversation_id(path) conv_id = self._get_conversation_id(path)
if not conv_id: if not conv_id:
return return
file_name = path.name file_name = path.name
if file_name not in Config.WATCHED_FILES:
# Check suffix patterns
if not any(file_name.endswith(s) for s in Config.WATCHED_SUFFIXES):
return
# STRICT filter: only primary artifacts
if not self._is_watched_file(file_name):
return
# Debounce: skip rapid-fire events for same file
if self._should_debounce(str(path)): if self._should_debounce(str(path)):
return return
@@ -132,7 +142,11 @@ class BrainEventHandler(FileSystemEventHandler):
logger.warning(f"Failed to read {path}: {e}") logger.warning(f"Failed to read {path}: {e}")
return return
logger.info(f"File event: {event_type.value} {conv_id}/{file_name}") # Content hash dedup: skip if content hasn't actually changed
if not self._content_changed(str(path), content):
return
logger.info(f"File event: {event_type.value} {conv_id[:8]}/{file_name}")
self._emit(BrainEvent( self._emit(BrainEvent(
event_type=event_type, event_type=event_type,
conversation_id=conv_id, conversation_id=conv_id,
@@ -152,7 +166,6 @@ class BrainWatcher:
self.handler = BrainEventHandler(event_queue, loop) self.handler = BrainEventHandler(event_queue, loop)
def start(self): def start(self):
"""Start watching the brain directory."""
brain_path = Config.BRAIN_PATH brain_path = Config.BRAIN_PATH
if not brain_path.exists(): if not brain_path.exists():
logger.error(f"Brain path does not exist: {brain_path}") logger.error(f"Brain path does not exist: {brain_path}")
@@ -163,7 +176,6 @@ class BrainWatcher:
logger.info(f"Watching brain directory: {brain_path}") logger.info(f"Watching brain directory: {brain_path}")
def stop(self): def stop(self):
"""Stop the watcher."""
self.observer.stop() self.observer.stop()
self.observer.join() self.observer.join()
logger.info("Brain watcher stopped") logger.info("Brain watcher stopped")