From 072f83bf25e33ccc05d80f0b9a6cff8f0e665188 Mon Sep 17 00:00:00 2001 From: Variet Worker Date: Sat, 11 Apr 2026 13:06:38 +0900 Subject: [PATCH] refactor(bridge): migrate gravity bridge to pure websocket gateway architecture, deleting legacy local file scanners and dependencies --- bot.py | 478 +--------------------------- bridge.py | 267 ---------------- docs/devlog/2026-04-10.md | 2 +- docs/devlog/2026-04-11.md | 5 + docs/devlog/entries/20260411-001.md | 13 + extension/gravity-bridge-0.1.0.vsix | Bin 16034 -> 0 bytes extension/out/extension.js | 67 +--- extension/out/extension.js.map | 2 +- extension/package-lock.json | 454 +++++++++++++++++++++----- extension/package.json | 5 +- extension/src/brain-watcher.ts | 96 ++++++ extension/src/extension.ts | 55 +--- extension/src/http-bridge.ts | 37 +-- extension/src/observer-script.ts | 319 ++++++++----------- extension/src/step-probe.ts | 25 +- main.py | 56 ++-- models.py | 55 ++++ scratch_test.js | 4 - start_bot.bat | 63 ---- watcher.py | 290 ----------------- 20 files changed, 756 insertions(+), 1537 deletions(-) delete mode 100644 bridge.py create mode 100644 docs/devlog/2026-04-11.md create mode 100644 docs/devlog/entries/20260411-001.md delete mode 100644 extension/gravity-bridge-0.1.0.vsix create mode 100644 extension/src/brain-watcher.ts create mode 100644 models.py delete mode 100644 scratch_test.js delete mode 100644 start_bot.bat delete mode 100644 watcher.py diff --git a/bot.py b/bot.py index fff7cf7..1d0967b 100644 --- a/bot.py +++ b/bot.py @@ -30,8 +30,7 @@ from parser import ( md_to_discord_text, format_task_embed_text, ) -from watcher import BrainEvent, EventType -from bridge import BridgeProtocol, ApprovalRequest, UserResponse +from models import BrainEvent, EventType, ApprovalRequest, UserResponse logger = logging.getLogger(__name__) @@ -47,10 +46,9 @@ class ApprovalView(discord.ui.View): (e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny) """ - def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest, + def __init__(self, request: ApprovalRequest, buttons: list[dict] | None = None, hub=None): super().__init__(timeout=1800) # 30 minutes - self.bridge = bridge self.hub = hub # WSHub instance for WS response routing self.request = request self.responded = False @@ -100,12 +98,9 @@ class ApprovalView(discord.ui.View): # Hub WS route (primary — reaches remote Extensions) delivered = False if self.hub: - delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, { + await self.hub.send_response_to_pending_owner(self.request.request_id, { "type": "response", "data": response_data, }) - if not delivered: - # File bridge fallback (Hub unavailable OR owner disconnected) - self.bridge.write_response(UserResponse(**response_data)) embed = interaction.message.embeds[0] if interaction.message.embeds else None if embed: color = discord.Color.red() if is_reject else discord.Color.green() @@ -131,13 +126,10 @@ class ApprovalView(discord.ui.View): "step_type": getattr(self.request, 'step_type', ''), "project_name": getattr(self.request, 'project_name', ''), } - delivered = False if self.hub: - delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, { + await self.hub.send_response_to_pending_owner(self.request.request_id, { "type": "response", "data": response_data, }) - if not delivered: - self.bridge.write_response(UserResponse(**response_data)) embed = interaction.message.embeds[0] if interaction.message.embeds else None if embed: embed.color = discord.Color.green() @@ -158,13 +150,10 @@ class ApprovalView(discord.ui.View): "step_type": getattr(self.request, 'step_type', ''), "project_name": getattr(self.request, 'project_name', ''), } - delivered = False if self.hub: - delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, { + await self.hub.send_response_to_pending_owner(self.request.request_id, { "type": "response", "data": response_data, }) - if not delivered: - self.bridge.write_response(UserResponse(**response_data)) embed = interaction.message.embeds[0] if interaction.message.embeds else None if embed: embed.color = discord.Color.red() @@ -172,12 +161,14 @@ class ApprovalView(discord.ui.View): await interaction.response.edit_message(embed=embed, view=None) async def on_timeout(self): - if not self.responded: - self.bridge.write_response(UserResponse( - request_id=self.request.request_id, approved=False, - step_type=getattr(self.request, 'step_type', ''), - project_name=getattr(self.request, 'project_name', ''), - )) + if not self.responded and self.hub: + await self.hub.send_response_to_pending_owner(self.request.request_id, { + "type": "response", "data": { + "request_id": self.request.request_id, "approved": False, + "step_type": getattr(self.request, 'step_type', ''), + "project_name": getattr(self.request, 'project_name', ''), + } + }) # ─── Bot ───────────────────────────────────────────────────────────── @@ -207,7 +198,6 @@ class GravityBot(commands.Bot): self._sent_commands: dict[str, str] = {} # request_id → command text (for MERGE edit detection) self._ready_event = asyncio.Event() self._channel_lock = asyncio.Lock() - self.bridge = BridgeProtocol() self.session_category: discord.CategoryChannel | None = None self.guild: discord.Guild | None = None self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled @@ -233,7 +223,7 @@ class GravityBot(commands.Bot): "project_name": kwargs.get('project_name', project), } - # Hub route (primary — skip file bridge to prevent double delivery) + # Hub route (primary) if self.hub: import time as _time cmd_data["id"] = str(int(_time.time() * 1000)) @@ -246,14 +236,6 @@ class GravityBot(commands.Bot): asyncio.create_task( self.hub.broadcast_to_project(project, msg) ) - return # ← WS sent, skip file bridge - - # Legacy fallback (file bridge + gateway HTTP) — only when Hub is unavailable - self.bridge.write_command(project, text, **kwargs) - if self.gateway: - import time as _time - cmd_data["id"] = cmd_data.get("id", str(int(_time.time() * 1000))) - self.gateway.push_command(project, cmd_data) def _cap_dict(self, d: dict, max_size: int = 5000): """Prevent memory leaks by capping dictionary sizes using insertion order (oldest first).""" @@ -269,8 +251,6 @@ class GravityBot(commands.Bot): async def setup_hook(self): self.loop.create_task(self._process_events()) - self.pending_approval_scanner.start() - self.chat_snapshot_scanner.start() self._register_slash_commands() # Register Hub handlers (if Hub is available, set after setup_hook by main.py) asyncio.get_event_loop().call_soon(self._register_hub_handlers) @@ -353,57 +333,12 @@ class GravityBot(commands.Bot): logger.error("No permission to create category!") return - # Discover existing project channels - await self._discover_channels() - - # Load conversation → project registrations from Extension - self._load_registrations() - - # Sync slash commands to guild - try: - self.tree.copy_global_to(guild=self.guild) - synced = await self.tree.sync(guild=self.guild) - logger.info(f"Synced {len(synced)} slash commands to guild") - except Exception as e: - logger.warning(f"Slash command sync failed: {e}") - - # Open the gate + # Start WS Hub processors by ensuring ready gate is open self._ready_event.set() logger.info("Ready gate opened — event processing enabled") - # Start scanner loops - if not self.pending_approval_scanner.is_running(): - self.pending_approval_scanner.start() - if not self.chat_snapshot_scanner.is_running(): - self.chat_snapshot_scanner.start() - logger.info("Scanner loops started") - # ─── Channel Management ────────────────────────────────────────── - def _load_registrations(self): - """Read bridge/register/ to learn conversation → project mappings.""" - register_dir = self.bridge.bridge_dir / "register" - if not register_dir.exists(): - return - - count = 0 - for f in register_dir.glob("*.json"): - try: - data = json.loads(f.read_text(encoding="utf-8-sig")) - conv_id = data.get("conversation_id", "") - project = data.get("project_name", "") - if conv_id and project: - self.conv_to_project[conv_id] = project - count += 1 - except (json.JSONDecodeError, OSError): - pass - - # Only log when count changes - prev = getattr(self, '_last_reg_count', -1) - if count != prev: - self._last_reg_count = count - if count: - logger.info(f"Loaded {count} conversation→project registrations") # ─── Channel Management ────────────────────────────────────────── @@ -618,270 +553,7 @@ class GravityBot(commands.Bot): # ─── Approval Scanner ──────────────────────────────────────────── - @tasks.loop(seconds=30) # Hub mode: WS is primary, file scan is fallback only - async def pending_approval_scanner(self): - """Scan bridge/pending/ for new approval requests + reload registrations. - Per-tick caps prevent Discord API rate limit cascade when multiple - projects generate pending files simultaneously. - """ - try: - # Reload conv→project registrations each cycle - self._load_registrations() - - # Channels are created on-demand when actual signals arrive - # (via _get_channel in snapshot scanner / approval sender) - - MAX_NEW_PER_TICK = 5 # Phase 1: max new pending to process per tick - MAX_STATUS_PER_TICK = 5 # Phase 2: max status changes to process per tick - phase1_processed = 0 - - requests = self.bridge.get_pending_requests() - for req in requests: - if phase1_processed >= MAX_NEW_PER_TICK: - break - if req.request_id in self._sent_approval_ids: - continue - if req.discord_message_id != 0: - continue - - # Learn project mapping from pending approval - project = req.project_name or Config.PROJECT_NAME - if req.conversation_id and req.conversation_id != '__global__': - self.conv_to_project[req.conversation_id] = project - - # ── SafeToAutoRun: approve immediately and quietly ── - if getattr(req, "safe_to_auto_run", False): - self._cap_dict(self._sent_approval_ids) - self._sent_approval_ids[req.request_id] = True - - # Generate approve response back to extension - approve_btn_index = 0 - pending_file = self.bridge.pending_dir / f"{req.request_id}.json" - if pending_file.exists(): - try: - pdata = json.loads(pending_file.read_text(encoding="utf-8-sig")) - btns = pdata.get("buttons") - if btns and len(btns) > 1: - reject_words = {"deny", "reject", "cancel", "reject all", "decline", "dismiss", "stop"} - for b in btns: - txt = b.get("text", "").lower().strip() - if txt not in reject_words: - approve_btn_index = b.get("index", 0) - break - except (json.JSONDecodeError, OSError): - pass - - self.bridge.write_response(UserResponse( - request_id=req.request_id, - approved=True, - button_index=approve_btn_index, - step_type=getattr(req, 'step_type', ''), - project_name=project, - )) - logger.info(f"SafeToAutoRun (Quietly Auto-approved): {req.request_id[:12]} project={project}") - phase1_processed += 1 - continue - - # ── Auto-approve: if project has auto enabled, approve immediately ── - if project in self.auto_approve_projects: - # Defence: reject-word commands should NEVER be auto-approved - # (DOM observer may create standalone "Deny" pending from file_permission UI) - reject_commands = {"deny", "reject", "cancel", "decline", "dismiss", "stop"} - if req.command.strip().lower() in reject_commands: - logger.warning(f"Auto-approve BLOCKED: command='{req.command}' is reject-word — skipping") - self._cap_dict(self._sent_approval_ids) - self._sent_approval_ids[req.request_id] = True - phase1_processed += 1 - continue - - self._cap_dict(self._sent_approval_ids) - self._sent_approval_ids[req.request_id] = True - - # Smart button_index: read buttons array from pending file - # file_permission buttons = [Allow Once(0), Allow This Conv(1), Deny(2)] - # MUST pick non-reject button for safety - approve_btn_index = 0 - pending_file = self.bridge.pending_dir / f"{req.request_id}.json" - if pending_file.exists(): - try: - pdata = json.loads(pending_file.read_text(encoding="utf-8-sig")) - btns = pdata.get("buttons") - if btns and len(btns) > 1: - reject_words = {"deny", "reject", "cancel", "reject all", - "decline", "dismiss", "stop"} - for b in btns: - txt = b.get("text", "").lower().strip() - if txt not in reject_words: - approve_btn_index = b.get("index", 0) - break - except (json.JSONDecodeError, OSError): - pass - - # Write auto-approve response for Extension - self.bridge.write_response(UserResponse( - request_id=req.request_id, - approved=True, - button_index=approve_btn_index, - step_type=getattr(req, 'step_type', ''), - project_name=project, - )) - # Show compact auto-approved embed in Discord - channel = await self._get_channel(project) - if channel: - try: - embed = discord.Embed( - title="🤖 자동 승인됨", - description=f"✅ **{req.command}**\n\n```\n{req.description[:2000]}\n```" if getattr(req, "description", "") else f"✅ **{req.command}**", - color=discord.Color.green(), - ) - embed.set_footer(text=f"auto-approve | {req.request_id[:12]}") - await channel.send(embed=embed) - except Exception as e: - logger.error(f"[AUTO-APPROVE] Discord send failed for {project}: {e}") - else: - logger.warning(f"[AUTO-APPROVE] No Discord channel for project={project} — notification skipped") - logger.info(f"Auto-approved: {req.request_id[:12]} project={project} btn_idx={approve_btn_index}") - phase1_processed += 1 - continue - - # Defer short-command pendings (e.g. "Run") by 4 cycles (~12s) - # to give step_probe time to merge detailed command info - # (step_probe MERGE happens ~10s after pending creation) - if len(req.command) <= 15: - if req.request_id not in self._deferred_ids: - self._deferred_ids[req.request_id] = 1 - continue # skip this cycle - elif self._deferred_ids[req.request_id] < 4: - self._deferred_ids[req.request_id] += 1 - # Re-read from file (step_probe may have merged) - fresh = self.bridge.read_pending_request(req.request_id) - if fresh and len(fresh.command) > 15: - req = fresh # use merged version — send now! - else: - continue # wait one more cycle - - # Clean up defer tracking - self._deferred_ids.pop(req.request_id, None) - - channel = await self._get_channel(project) - if channel: - self._cap_dict(self._sent_approval_ids) - self._sent_approval_ids[req.request_id] = True - self._cap_dict(self._sent_commands) - self._sent_commands[req.request_id] = req.command - await self._send_approval_request(channel, req) - phase1_processed += 1 - else: - logger.warning(f"[APPROVAL] No Discord channel for project={project} — approval request skipped (rid={req.request_id[:12]})") - - # ── Single-pass: handle auto_resolved, expired, and MERGE in one glob ── - phase2_processed = 0 - for f in self.bridge.pending_dir.glob("*.json"): - if phase2_processed >= MAX_STATUS_PER_TICK: - break - try: - data = json.loads(f.read_text(encoding="utf-8-sig")) - status = data.get("status", "pending") - rid = data.get("request_id", "") - - if status == "auto_resolved": - # FIX #5: Use _approval_messages as fallback when discord_message_id is 0 - msg_id = data.get("discord_message_id", 0) or self._approval_messages.get(rid, 0) - project = data.get("project_name", Config.PROJECT_NAME) - logger.info(f"[AUTO-RESOLVED] rid={rid[:12]} project={project} msg_id={msg_id} cmd='{data.get('command', '')[:60]}'") - if msg_id: - channel = await self._get_channel(project) - if channel: - try: - msg = await channel.fetch_message(msg_id) - embed = discord.Embed( - title="✅ AG에서 직접 승인됨", - description=f"```\n{data.get('command', '')[:500]}\n```", - color=discord.Color.green(), - ) - embed.set_footer(text=f"ID: {rid}") - await msg.edit(embed=embed, view=None) - logger.info(f"[AUTO-RESOLVED] ✅ Discord message {msg_id} updated") - except discord.NotFound: - logger.warning(f"[AUTO-RESOLVED] Discord message {msg_id} not found") - else: - logger.warning(f"[AUTO-RESOLVED] No msg_id for rid={rid[:12]} — cannot edit Discord message") - f.unlink() - self._deferred_ids.pop(rid, None) - self._sent_commands.pop(rid, None) - self._approval_messages.pop(rid, None) - self._sent_approval_ids.pop(rid, None) - phase2_processed += 1 - - elif status == "expired": - msg_id = data.get("discord_message_id", 0) - project = data.get("project_name", Config.PROJECT_NAME) - if msg_id: - channel = await self._get_channel(project) - if channel: - try: - msg = await channel.fetch_message(msg_id) - embed = discord.Embed( - title="⏰ 만료됨", - description=f"```\n{data.get('command', '')[:500]}\n```", - color=discord.Color.light_grey(), - ) - embed.set_footer(text=f"ID: {rid}") - await msg.edit(embed=embed, view=None) - except discord.NotFound: - pass - f.unlink() - self._deferred_ids.pop(rid, None) - self._sent_commands.pop(rid, None) - self._sent_approval_ids.pop(rid, None) - phase2_processed += 1 - - elif status == "pending": - # MERGE check: step_probe updated command in already-sent pending - if rid not in self._sent_approval_ids: - continue - msg_id = data.get("discord_message_id", 0) - if not msg_id: - continue - new_cmd = data.get("command", "") - old_cmd = self._sent_commands.get(rid, "") - if new_cmd and new_cmd != old_cmd and len(new_cmd) > len(old_cmd): - self._sent_commands[rid] = new_cmd - project = data.get("project_name", Config.PROJECT_NAME) - channel = await self._get_channel(project) - if channel: - try: - msg = await channel.fetch_message(msg_id) - buttons = data.get("buttons") - desc_parts = [f"**명령어:**\n```\n{new_cmd[:1000]}\n```"] - if buttons and len(buttons) > 1: - btn_names = [b.get("text", "?") for b in buttons] - desc_parts.append(f"**선택지:** {' / '.join(btn_names)}") - desc = data.get("description", "") - if desc: - desc_parts.append(desc[:500]) - embed = discord.Embed( - title="⚠️ 승인 요청", - description="\n".join(desc_parts), - color=discord.Color.orange(), - timestamp=datetime.now(timezone.utc), - ) - embed.set_footer(text=f"ID: {rid}") - await msg.edit(embed=embed) - logger.info(f"MERGE edit: {rid[:12]} cmd='{new_cmd[:60]}'") - except discord.NotFound: - pass - - except (json.JSONDecodeError, OSError): - pass - - except Exception as e: - logger.error(f"Error scanning approvals: {e}") - - @pending_approval_scanner.before_loop - async def before_scanner(self): - await self.wait_until_ready() async def _send_approval_request( self, channel: discord.TextChannel, request: ApprovalRequest @@ -1133,9 +805,8 @@ class GravityBot(commands.Bot): self._cap_dict(self._sent_approval_ids) self._sent_approval_ids[request.request_id] = True - delivered = False if self.hub: - delivered = await self.hub.send_response_to_pending_owner(request.request_id, { + await self.hub.send_response_to_pending_owner(request.request_id, { "type": "response", "data": { "request_id": request.request_id, @@ -1145,13 +816,6 @@ class GravityBot(commands.Bot): "project_name": request.project_name, }, }) - if not delivered: - # File bridge fallback (Hub unavailable OR owner disconnected) - self.bridge.write_response(UserResponse( - request_id=request.request_id, approved=True, - step_type=request.step_type, - project_name=request.project_name, - )) # Send compact auto-approved embed to Discord (was missing — caused silent approvals) channel = await self._get_channel(request.project_name) if channel: @@ -1282,114 +946,4 @@ class GravityBot(commands.Bot): # ─── Chat Snapshot Scanner ───────────────────────────────────────── - @tasks.loop(seconds=30) # Hub mode: WS is primary, file scan is fallback only - async def chat_snapshot_scanner(self): - """Scan bridge/chat_snapshots/ for AI response dumps.""" - try: - snapshot_dir = self.bridge.bridge_dir / "chat_snapshots" - if not snapshot_dir.exists(): - return - for f in snapshot_dir.glob("*.json"): - try: - data = json.loads(f.read_text(encoding="utf-8-sig")) - project = data.get("project_name", Config.PROJECT_NAME) - content = data.get("content", "") - attached_files = data.get("attached_files", []) - - if content or attached_files: - channel = await self._get_channel(project) - if not channel: - logger.warning(f"[SNAPSHOT] No Discord channel for project={project} — snapshot skipped (len={len(content)})") - elif channel: - import io - - # ── Send attached files (from Extension's writeChatSnapshotWithFiles) ── - discord_files = [] - for af in attached_files: - af_name = af.get("name", "document.md") - af_content = af.get("content", "") - if af_content: - discord_files.append(discord.File( - io.BytesIO(af_content.encode("utf-8")), - filename=af_name, - )) - - FILE_ATTACH_THRESHOLD = 4000 - if len(content) > FILE_ATTACH_THRESHOLD: - # Long chat content → summary embed + file attachment - summary = content[:500].rsplit('\n', 1)[0] - embed = discord.Embed( - title="💬 AI 대화 내용", - description=f"{summary}\n\n📎 *전체 내용은 첨부 파일 참조* ({len(content):,}자)", - color=discord.Color.purple(), - timestamp=datetime.now(timezone.utc), - ) - # Add content itself as file attachment - discord_files.append(discord.File( - io.BytesIO(content.encode("utf-8")), - filename="chat_message.md", - )) - try: - await channel.send(embed=embed, files=discord_files) - logger.info(f"[SNAPSHOT] Sent to #{channel.name} (file, {len(content)} chars)") - except discord.NotFound: - logger.warning(f"Channel deleted for {project}, re-creating...") - self.project_channels.pop(project, None) - channel = await self._get_channel(project) - if channel: - # Re-create files (discord.File consumed after send) - discord_files2 = [] - for af in attached_files: - af_name = af.get("name", "document.md") - af_content = af.get("content", "") - if af_content: - discord_files2.append(discord.File( - io.BytesIO(af_content.encode("utf-8")), - filename=af_name, - )) - discord_files2.append(discord.File( - io.BytesIO(content.encode("utf-8")), - filename="chat_message.md", - )) - await channel.send(embed=embed, files=discord_files2) - logger.info(f"[SNAPSHOT] Sent to #{channel.name} after re-create (file, {len(content)} chars)") - except Exception as e: - logger.error(f"[SNAPSHOT] Discord send failed for {project}: {e}") - else: - # Short content → inline embed (original) - embed = discord.Embed( - title="💬 AI 대화 내용", - description=content, - color=discord.Color.purple(), - timestamp=datetime.now(timezone.utc), - ) - try: - await channel.send( - embed=embed, - files=discord_files if discord_files else discord.utils.MISSING, - ) - logger.info(f"[SNAPSHOT] Sent to #{channel.name} (inline, {len(content)} chars)") - except discord.NotFound: - logger.warning(f"Channel deleted for {project}, re-creating...") - self.project_channels.pop(project, None) - channel = await self._get_channel(project) - if channel: - await channel.send(embed=embed) - logger.info(f"[SNAPSHOT] Sent to #{channel.name} after re-create (inline)") - except Exception as e: - logger.error(f"[SNAPSHOT] Discord send failed for {project}: {e}") - - f.unlink() # Cleanup - except Exception as e: - logger.warning(f"Bad chat snapshot {f.name}: {e}") - try: - f.rename(f.with_suffix('.json.failed')) - except OSError: - pass - except Exception as e: - logger.error(f"Error scanning chat snapshots: {e}") - - @chat_snapshot_scanner.before_loop - async def before_chat_scanner(self): - await self.wait_until_ready() diff --git a/bridge.py b/bridge.py deleted file mode 100644 index 95cdb21..0000000 --- a/bridge.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Bridge protocol — communication between Discord bot and Antigravity. - -Bridge directory: ~/.gemini/antigravity/bridge/ -Structure: - bridge/ - pending/ ← Bot writes approval requests for Discord - response/ ← Bot writes user responses from Discord - commands/ ← Bot writes user text input from Discord - -Protocol: -1. VS Code Extension detects pending approval → writes JSON to pending/ -2. Bot reads pending/ → sends Discord message with ✅/❌ buttons -3. User clicks button → Bot writes JSON to response/ -4. VS Code Extension reads response/ → executes action -""" - -import json -import time -import logging -import uuid -from abc import ABC, abstractmethod -from pathlib import Path -from dataclasses import dataclass, asdict -from enum import Enum - -from config import Config - -logger = logging.getLogger(__name__) - - -class ApprovalStatus(Enum): - PENDING = "pending" - APPROVED = "approved" - REJECTED = "rejected" - TIMEOUT = "timeout" - - -@dataclass -class ApprovalRequest: - """An approval request from Antigravity.""" - request_id: str - conversation_id: str - command: str # The command/action needing approval - description: str # Human-readable description - timestamp: float - status: str = "pending" - discord_message_id: int = 0 - project_name: str = "" # Project routing key - step_type: str = "" # e.g. 'diff_review', passed through to response - safe_to_auto_run: bool = False # Allows bot to silently auto-approve - - - -@dataclass -class UserResponse: - """A user response from Discord.""" - request_id: str - approved: bool - user_input: str = "" - timestamp: float = 0 - button_index: int = -1 # -1 = legacy (approve/reject), 0+ = specific button index - step_type: str = "" # pass through from pending for extension routing - project_name: str = "" # for multi-project: extension uses this when pending file is missing - - -# ─── Transport Abstraction ─── - -class BridgeTransport(ABC): - """Abstract transport for bridge I/O. - - Implementations handle reading/writing JSON files for the bridge protocol, - regardless of whether the storage is local filesystem or remote HTTP. - """ - - @abstractmethod - def list_json_files(self, subdir: str) -> list[str]: - """List JSON filenames in a subdirectory (e.g. 'pending', 'response').""" - ... - - @abstractmethod - def read_json(self, subdir: str, filename: str) -> dict | None: - """Read and parse a JSON file. Returns None if not found or corrupt.""" - ... - - @abstractmethod - def write_json(self, subdir: str, filename: str, data: dict) -> None: - """Write data as JSON to a file in the given subdirectory.""" - ... - - @abstractmethod - def delete_file(self, subdir: str, filename: str) -> bool: - """Delete a file. Returns True if deleted, False if not found.""" - ... - - @abstractmethod - def ensure_dirs(self) -> None: - """Ensure all required subdirectories exist.""" - ... - - -class LocalTransport(BridgeTransport): - """File-system based transport (default, single-PC mode). - - Reads/writes directly to the bridge directory on local disk. - This is the existing behavior, extracted into a transport class. - """ - - def __init__(self, bridge_dir: Path): - self.bridge_dir = bridge_dir - - def list_json_files(self, subdir: str) -> list[str]: - d = self.bridge_dir / subdir - if not d.exists(): - return [] - return [f.name for f in d.glob("*.json")] - - def read_json(self, subdir: str, filename: str) -> dict | None: - fp = self.bridge_dir / subdir / filename - if not fp.exists(): - return None - try: - return json.loads(fp.read_text(encoding="utf-8-sig")) - except (json.JSONDecodeError, OSError) as e: - logger.warning(f"LocalTransport: bad file {subdir}/{filename}: {e}") - return None - - def write_json(self, subdir: str, filename: str, data: dict) -> None: - d = self.bridge_dir / subdir - d.mkdir(parents=True, exist_ok=True) - fp = d / filename - fp.write_text( - json.dumps(data, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - - def delete_file(self, subdir: str, filename: str) -> bool: - fp = self.bridge_dir / subdir / filename - if fp.exists(): - try: - fp.unlink() - return True - except OSError: - return False - return False - - def ensure_dirs(self) -> None: - for sub in ("pending", "response", "commands"): - (self.bridge_dir / sub).mkdir(parents=True, exist_ok=True) - - - -# ─── Bridge Protocol (uses Transport) ─── - -class BridgeProtocol: - """Manages the bridge protocol via a pluggable transport.""" - - def __init__(self, transport: BridgeTransport | None = None): - if transport is None: - bridge_dir = Config.BRAIN_PATH.parent / "bridge" - transport = LocalTransport(bridge_dir) - self.transport = transport - - # Legacy attributes for backward compatibility - # (bot.py uses self.bridge.pending_dir etc. in some places) - if isinstance(transport, LocalTransport): - self.bridge_dir = transport.bridge_dir - self.pending_dir = transport.bridge_dir / "pending" - self.response_dir = transport.bridge_dir / "response" - self.commands_dir = transport.bridge_dir / "commands" - - # Ensure directories exist - self.transport.ensure_dirs() - - # Startup cleanup: purge stale pending files (> 5 min old) - self._cleanup_stale_pending() - - logger.info(f"Bridge protocol initialized: transport={type(transport).__name__}") - - def _cleanup_stale_pending(self, max_age_seconds: int = 300): - """Remove pending files older than max_age_seconds on startup.""" - now = time.time() - cleaned = 0 - for fname in self.transport.list_json_files("pending"): - data = self.transport.read_json("pending", fname) - if data is None: - self.transport.delete_file("pending", fname) - cleaned += 1 - continue - ts = data.get("timestamp", 0) - if now - ts > max_age_seconds: - self.transport.delete_file("pending", fname) - cleaned += 1 - if cleaned: - logger.info(f"Startup cleanup: removed {cleaned} stale pending files") - - def get_pending_requests(self) -> list[ApprovalRequest]: - """Read all pending approval requests. Skips files older than 30 minutes.""" - requests = [] - fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()} - now = time.time() - MAX_AGE = 1800 # 30 minutes (matches Discord button timeout) - CLEANUP_AGE = 86400 # 1 day - for fname in self.transport.list_json_files("pending"): - data = self.transport.read_json("pending", fname) - if data is None: - continue - ts = data.get("timestamp", 0) - if now - ts > CLEANUP_AGE: - # Too old even to keep as expired — delete to prevent accumulation - self.transport.delete_file("pending", fname) - continue - if now - ts > MAX_AGE: - # Too old — mark expired and skip - if data.get("status") != "expired": - data["status"] = "expired" - self.transport.write_json("pending", fname, data) - continue - if data.get("status") == "pending": - # Filter to known fields only - filtered = {k: v for k, v in data.items() if k in fields} - try: - requests.append(ApprovalRequest(**filtered)) - except TypeError as e: - logger.warning(f"Bad pending request {fname}: {e}") - return requests - - def read_pending_request(self, request_id: str) -> ApprovalRequest | None: - """Re-read a specific pending request (to get merged data).""" - fname = f"{request_id}.json" - data = self.transport.read_json("pending", fname) - if data is None: - return None - fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()} - filtered = {k: v for k, v in data.items() if k in fields} - try: - return ApprovalRequest(**filtered) - except TypeError: - return None - - def write_response(self, response: UserResponse): - """Write a user response to the response directory.""" - response.timestamp = time.time() - fname = f"{response.request_id}.json" - - self.transport.write_json("response", fname, asdict(response)) - logger.info(f"Response written: {fname} (approved={response.approved})") - - # Delete pending file after processing (prevents re-processing and accumulation) - self.transport.delete_file("pending", fname) - - def write_command(self, conversation_id: str, text: str, *, project_name: str = ""): - """Write a user text command for Antigravity to consume.""" - cmd_id = f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}" - fname = f"{cmd_id}.json" - - data = { - "id": cmd_id, - "conversation_id": conversation_id, - "project_name": project_name, - "text": text, - "timestamp": time.time(), - "consumed": False, - } - - self.transport.write_json("commands", fname, data) - logger.info(f"Command written: {cmd_id} → project={project_name}") - return cmd_id diff --git a/docs/devlog/2026-04-10.md b/docs/devlog/2026-04-10.md index 8348f27..2c9d00a 100644 --- a/docs/devlog/2026-04-10.md +++ b/docs/devlog/2026-04-10.md @@ -1,4 +1,4 @@ | NNN | 시간 | 작업 설명 | 커밋해시 | 완료여부 | |---|---|---|---|---| | 001 | 17:11 | step-probe.ts 의 isRunning 조건 누락으로 인한 릴레이 증발 버그 픽스 | COMMITTING | ✅ | -| #613 | 21:10 | Bridge Relay AI Chat Body DOM extraction and template literal Regex fix | TBD | ? | +| #613 | 21:10 | Bridge Relay AI Chat Body DOM extraction and template literal Regex fix | TBD | ✅ | diff --git a/docs/devlog/2026-04-11.md b/docs/devlog/2026-04-11.md new file mode 100644 index 0000000..8770d72 --- /dev/null +++ b/docs/devlog/2026-04-11.md @@ -0,0 +1,5 @@ +# 2026-04-11 + +| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 | +|-------|-------|----------|-----------|-----------| +| 001 | 13:04 | Pure 웹소켓 게이트웨이 완전 전환 및 레거시 파일 브릿지 통신코드 삭제 | `TBD` | ✅ | diff --git a/docs/devlog/entries/20260411-001.md b/docs/devlog/entries/20260411-001.md new file mode 100644 index 0000000..4e9f4b6 --- /dev/null +++ b/docs/devlog/entries/20260411-001.md @@ -0,0 +1,13 @@ +# Pure 웹소켓 게이트웨이 전환 (Legacy 파일 브릿지 통신 완전히 제거) + +- **시간**: 2026-04-11 11:00~13:00 +- **Commit**: `(To be updated)` +- **Vikunja**: #N/A + +## 결정 사항 +- 기존 VS Code 익스텐션과 로컬 Discord Bot 간에 이루어지던 `.gemini/antigravity/bridge/` 기반 파일 공유 통신 체계를 100% 제거하였습니다. +- 파이썬 봇 서버(`bot.py`) 내부에서 동작하던 물리적인 폴링 디렉토리 스캐너(`pending_approval_scanner` 및 `chat_snapshot_scanner`) 파일 디펜던시 루프를 완전히 삭제하고 `Hub` WS 핸들러로 대체했습니다. 봇 패키지에 남아있던 `bridge.py`와 `watcher.py` 또한 사용할 이유가 없어져 레포지토리에서 영구적으로 폐기 구별을 내렸습니다. + +## 새로 알게된 사실 혹은 트러블슈팅 +- 익스텐션에서 `activeSessionId` 변경 시 `watcher.py` 대신 Node.js 네이티브 `fs.watch` 기반으로 자체적인 `BrainWatcher`를 인하우스로 구현해 `step-probe.ts`에 주입함으로써 파이썬 의존도를 완전히 분리할 수 있었습니다. +- 권한 팝업 중복 처리 역시 폴더 스캔 대신 단순히 인메모리 `lastFilePermissionTime` 단일 변수로 최적화되었습니다. diff --git a/extension/gravity-bridge-0.1.0.vsix b/extension/gravity-bridge-0.1.0.vsix deleted file mode 100644 index 1be984fcd2f549c573af57dac69b6e9f8f633d18..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16034 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.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } + "name": "gravity-bridge", + "version": "0.5.34", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gravity-bridge", + "version": "0.5.34", + "dependencies": { + "cheerio": "^1.2.0", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.100.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.100.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz", + "integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } + } } diff --git a/extension/package.json b/extension/package.json index 886a260..f97b5cd 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "gravity-bridge", "displayName": "Gravity Bridge", "description": "Discord-based unified approval system for Antigravity AI interactions.", - "version": "0.5.30", + "version": "0.5.36", "publisher": "variet", "engines": { "vscode": "^1.100.0" @@ -84,6 +84,7 @@ } }, "dependencies": { + "cheerio": "^1.2.0", "ws": "^8.19.0" } -} \ No newline at end of file +} diff --git a/extension/src/brain-watcher.ts b/extension/src/brain-watcher.ts new file mode 100644 index 0000000..87db297 --- /dev/null +++ b/extension/src/brain-watcher.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { WSBridgeClient } from './ws-client'; + +export interface BrainWatcherContext { + logToFile: (msg: string) => void; + wsBridge: WSBridgeClient; + projectName: string; +} + +export class BrainWatcher { + private brainDir: string; + private ctx: BrainWatcherContext; + private currentSessionId: string = ''; + private watcher: fs.FSWatcher | null = null; + private lastEventTimes: Map = new Map(); + + constructor(ctx: BrainWatcherContext) { + this.ctx = ctx; + // The bridgePath is ~/.gemini/antigravity/bridge, so brain is sibling + this.brainDir = path.join(os.homedir(), '.gemini', 'antigravity', 'brain'); + } + + public updateSession(sessionId: string) { + if (!sessionId || this.currentSessionId === sessionId) { + return; + } + this.currentSessionId = sessionId; + this.startWatching(sessionId); + } + + private startWatching(sessionId: string) { + this.stop(); + + const sessionDir = path.join(this.brainDir, sessionId); + if (!fs.existsSync(sessionDir)) { + // It might not be created yet, poll gently + setTimeout(() => this.startWatching(sessionId), 2000); + return; + } + + try { + this.watcher = fs.watch(sessionDir, { persistent: false }, (eventType, filename) => { + if (!filename || !filename.endsWith('.md')) return; + + // Dedup rapid events + const now = Date.now(); + const last = this.lastEventTimes.get(filename) || 0; + if (now - last < 500) return; // 500ms debounce + this.lastEventTimes.set(filename, now); + + this.handleFileChange(sessionDir, filename, eventType); + }); + this.ctx.logToFile(`[BRAIN-WATCHER] Started watching session: ${sessionId.substring(0, 8)}`); + } catch (e: any) { + this.ctx.logToFile(`[BRAIN-WATCHER] Failed to watch ${sessionId}: ${e.message}`); + } + } + + private handleFileChange(dir: string, filename: string, rawEventType: string) { + const filePath = path.join(dir, filename); + let content = ''; + let eventType = 'file_changed'; + + try { + if (fs.existsSync(filePath)) { + content = fs.readFileSync(filePath, 'utf-8'); + } else { + eventType = 'file_deleted'; + } + } catch (e) { + // File might be locked or deleted during read + return; + } + + if (this.ctx.wsBridge && this.ctx.wsBridge.isConnected()) { + this.ctx.wsBridge.sendBrainEvent({ + event_type: eventType, + conversation_id: this.currentSessionId, + file_name: filename, + content: content, + timestamp: Date.now() / 1000, + project_name: this.ctx.projectName, + }); + this.ctx.logToFile(`[BRAIN-WATCHER] Sent ${eventType} for ${filename}`); + } + } + + public stop() { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + } +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 37b9ba9..7757b01 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -86,13 +86,7 @@ function detectProjectName(): string { // ─── Bridge File I/O ─── -function ensureBridgeDir() { - const dirs = ['', 'response', 'commands', 'chat_snapshots']; - for (const d of dirs) { - const p = path.join(bridgePath, d); - if (!fs.existsSync(p)) { fs.mkdirSync(p, { recursive: true }); } - } -} + // Module-level activeSessionId so writeChatSnapshot can register sessions lazily let activeSessionId = ''; @@ -102,34 +96,15 @@ const recentDiscordSentTexts: Map = new Map(); function writeChatSnapshot(text: string) { try { - // WS route (preferred) — skip file write to prevent duplicate Discord delivery if (wsBridge && wsBridge.isConnected()) { wsBridge.sendChat({ content: text, - conversation_id: activeSessionId, + conversation_id: getStepProbeSessionId(), project_name: projectName, }); logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`); - if (activeSessionId) { writeRegistration(activeSessionId); } - return; + if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); } } - // File route (fallback — only when WS is NOT connected) - const snapshotDir = path.join(bridgePath, 'chat_snapshots'); - if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); } - const id = Date.now().toString(); - const data = { - id, - project_name: projectName, - content: text, - timestamp: Date.now() / 1000, - }; - const filePath = path.join(snapshotDir, `${id}.json`); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); - console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`); - logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars)`); - logToFile(`[SNAPSHOT] content: ${text.substring(0, 200)}`); - // Lazily register session → project mapping (correct because projectName is per-window) - if (activeSessionId) { writeRegistration(activeSessionId); } } catch (e: any) { console.log(`Gravity Bridge: snapshot write error: ${e.message}`); } @@ -137,33 +112,16 @@ function writeChatSnapshot(text: string) { function writeChatSnapshotWithFiles(text: string, files: Array<{ name: string, content: string }>) { try { - // WS route (preferred) — skip file write to prevent duplicate Discord delivery if (wsBridge && wsBridge.isConnected()) { wsBridge.sendChat({ content: text, attached_files: files, - conversation_id: activeSessionId, + conversation_id: getStepProbeSessionId(), project_name: projectName, }); logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`); - if (activeSessionId) { writeRegistration(activeSessionId); } - return; + if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); } } - // File route (fallback — only when WS is NOT connected) - const snapshotDir = path.join(bridgePath, 'chat_snapshots'); - if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); } - const id = Date.now().toString(); - const data = { - id, - project_name: projectName, - content: text, - attached_files: files, - timestamp: Date.now() / 1000, - }; - const filePath = path.join(snapshotDir, `${id}.json`); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); - logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars, ${files.length} files)`); - if (activeSessionId) { writeRegistration(activeSessionId); } } catch (e: any) { console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`); } @@ -383,7 +341,7 @@ export async function activate(context: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration('gravityBridge'); const configPath = config.get('bridgePath'); bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge'); - ensureBridgeDir(); + console.log(`Gravity Bridge: bridge path: ${bridgePath}`); // ── WebSocket Hub Connection ── @@ -527,6 +485,7 @@ export async function activate(context: vscode.ExtensionContext) { get activeSessionId() { return getStepProbeContext().activeSessionId; }, get sessionStalled() { return getStepProbeContext().sessionStalled; }, get lastPendingStepIndex() { return getStepProbeContext().lastPendingStepIndex; }, + writeChatSnapshot, }; const bridgePort = await startHttpBridge(httpBridgeCtx, sdk); let localPort = bridgePort; diff --git a/extension/src/http-bridge.ts b/extension/src/http-bridge.ts index 1800ac4..a9b2416 100644 --- a/extension/src/http-bridge.ts +++ b/extension/src/http-bridge.ts @@ -13,6 +13,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { WSBridgeClient } from './ws-client'; +let lastFilePermissionTime = 0; + // ─── Context interface (shared state from extension.ts) ─── export interface HttpBridgeContext { @@ -127,7 +129,7 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise = { ...data, request_id: rid, @@ -265,22 +264,13 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) { if (cmdLower.includes('allow') && !pending.buttons) { // Dedup: skip if another file_permission pending was created within 10s const nowMs = Date.now(); - try { - const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json')); - for (const ef of existingFiles) { - const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8')); - if (existing.step_type === 'file_permission' && existing.status === 'pending' - && existing.project_name === ctx.projectName) { - const age = nowMs - (existing.timestamp * 1000); - if (age < 10_000 && age >= 0) { - ctx.logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' })); - return; - } - } - } - } catch { } + if (nowMs - lastFilePermissionTime < 10000) { + ctx.logToFile(`[HTTP] filtered duplicate file_permission (${nowMs - lastFilePermissionTime}ms old)`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' })); + return; + } + lastFilePermissionTime = nowMs; pending.buttons = [ { text: 'Allow Once', index: 0 }, @@ -292,8 +282,7 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) { const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim(); pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`; } - fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2)); - // WS dual-write + // WS dispatch if (ctx.wsBridge && ctx.wsBridge.isConnected()) { ctx.wsBridge.sendPending({ request_id: rid, diff --git a/extension/src/observer-script.ts b/extension/src/observer-script.ts index 8e63d72..e220c2a 100644 --- a/extension/src/observer-script.ts +++ b/extension/src/observer-script.ts @@ -1,6 +1,6 @@ export function generateApprovalObserverScript(_port: number): string { return ` -// ── Gravity Bridge v4: React Tailwind UI Observer ── +// ── Gravity Bridge v5: Context-First DOM Extraction ── (function(){ 'use strict'; var BASE='',_obs=false,_sent={},_ready=false; @@ -9,7 +9,7 @@ export function generateApprovalObserverScript(_port: number): string { var CLEANUP_MS=300000; function log(m){console.log('[GB Observer] '+m);} - log('v4 Script loaded — deep Tailwind DOM traversal enabled'); + log('v5 Script loaded — Context-First Tailored Extraction'); // React-Compatible Synthetic Clicker function dispatchReactClick(el){ @@ -21,19 +21,10 @@ export function generateApprovalObserverScript(_port: number): string { el.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true, view:window, composed:true})); el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window, composed:true})); } catch(e) { - el.click(); // fallback + el.click(); } } - // ── Find common container for the step ── - function findButtonContainer(btn){ - return btn.closest('.p-1') - || btn.closest('.bg-agent-convo-background') - || btn.closest('[class*="border-gray-500/10"]') - || btn.closest('.monaco-list-row') - || btn.parentElement; - } - function cleanButtonText(btn) { if (!btn) return ''; var clone = btn.cloneNode(true); @@ -43,10 +34,9 @@ export function generateApprovalObserverScript(_port: number): string { } var tr = clone.querySelector('.truncate'); var txt = (tr ? tr.textContent : clone.textContent) || ''; - return txt.trim().replace(/^[\s\u200B-\u200D\uFEFF\u00A0]+/, '').replace(/(Alt|Ctrl|Shift|Meta)\\+.*/i,'').trim(); + return txt.trim().replace(/^[\s\u200B-\u200D\uFEFF\u00A0]+/, '').replace(/(Alt|Ctrl|Shift|Meta)\\\\+.*/i,'').trim(); } - // ── Stable button fingerprint ── function btnId(b,type){ var txt = cleanButtonText(b); var parent = b.parentElement; @@ -58,104 +48,78 @@ export function generateApprovalObserverScript(_port: number): string { return type+'|'+txt+'|'+idx; } - // ── Context extraction — target BOTH chat history and command payload ── function extractCommandContext(b){ - var container = findButtonContainer(b); + var container = b.closest('.p-1') || b.parentElement.parentElement; if (!container) return ""; - var titleSpans = container.querySelectorAll('span[title^="command("]'); if (titleSpans && titleSpans.length > 0) { var t = titleSpans[0].getAttribute('title'); if (t && t.length > 5) return t.substring(0, 800); } - var preEls = container.querySelectorAll('pre'); if (preEls && preEls.length > 0) { var t2 = (preEls[preEls.length-1].textContent || '').trim(); if (t2.length > 2) return t2.substring(0, 800); } - - var codeText = ''; - var codes = container.querySelectorAll('code, [class*="command"]'); - for(var i=0; i 2) return codeText.trim().substring(0, 800); - var fallback = (container.textContent || '').replace(cleanButtonText(b), '').trim(); return fallback.substring(0, 500); } + function extractChatContextFromNode(botTurn) { + if (!botTurn) return ''; + + var res = ''; + // Use innerText if available on the markdown container (preserves spacing perfectly) + var md = botTurn.querySelector('.markdown-body') || botTurn.querySelector('.prose'); + if (md && md.innerText && md.innerText.trim().length > 10) { + res = md.innerText.trim(); + return res.substring(0, 3500); + } + + var toolContainer = botTurn.querySelector('.bg-agent-convo-background') || botTurn.querySelector('.bg-ide-background-color'); + var textParts = []; + function walk(node) { + if (toolContainer && node === toolContainer) return; + if (node.id === 'antigravity.agentSidePanelInputBox') return; + if (node.nodeType === 1) { + var tag = node.tagName.toUpperCase(); + if (tag==='BUTTON' || tag==='SVG' || tag==='STYLE' || tag==='SCRIPT') return; + // Skip tool action blocks aggressively if they masquerade as normal divs + if (node !== botTurn && node.classList && (node.classList.contains('bg-ide-background-color') || node.classList.contains('bg-agent-convo-background'))) return; + } + if (node.nodeType === 3) { + var val = node.nodeValue; + if (val && val.trim()) textParts.push(val.trim()); + } else { + if (node.childNodes && node.childNodes.length > 0) { + for(var i=0; i 0) { var text = items[0].getAttribute('aria-label') || items[0].getAttribute('title') || ''; - var m = text.match(/port:(\d+)/); + var m = text.match(/port:(\\d+)/); if (m && m[1]) { - var domPort = parseInt(m[1], 10); clearInterval(timer); - tryPingAsync(domPort).then(function(ok){ - if(ok) cb(domPort); else cb(HARDCODED_PORT); - }); + tryPingAsync(parseInt(m[1], 10)).then(function(ok){ cb(ok ? parseInt(m[1],10) : HARDCODED_PORT); }); return; } } - // If we are in the webview, the status bar is invisible. Skip quickly. if(attempts>1){ clearInterval(timer); - tryPingAsync(HARDCODED_PORT).then(function(ok){ cb(HARDCODED_PORT); }); // Assume HARDCODED_PORT works! + tryPingAsync(HARDCODED_PORT).then(function(){ cb(HARDCODED_PORT); }); } - },500); // Wait 500ms * 2 = 1 second total + },500); } discoverPort(function(port){ BASE='http://127.0.0.1:'+port; - fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){ - if(t==='pong'){_ready=true;startObserver();} - }).catch(function(e){}); + _ready=true; + startObserver(); }); - var _chatSnapshots = []; - var _firstChatScan = true; + var _lastText = ""; + var _lastTextTime = 0; + var _lastTextSent = false; + function scanChatBodies() { if(!_ready)return; var botTurns = document.querySelectorAll('.text-ide-message-block-bot-color'); - for (var i = 0; i < botTurns.length; i++) { - var turn = botTurns[i]; - if (turn.dataset.agChatScraped === "true" || turn.dataset.agChatScraped === "pending") continue; - - if (_firstChatScan) { - turn.dataset.agChatScraped = "true"; - continue; - } - - var currentText = turn.textContent || ''; - var found = -1; - for (var j = 0; j < _chatSnapshots.length; j++) { - if (_chatSnapshots[j].node === turn) { found = j; break; } - } - - if (found === -1) { - _chatSnapshots.push({ node: turn, text: currentText, lastChanged: Date.now() }); - } else { - if (_chatSnapshots[found].text !== currentText) { - _chatSnapshots[found].text = currentText; - _chatSnapshots[found].lastChanged = Date.now(); + if (botTurns.length === 0) return; + + var lastTurn = botTurns[botTurns.length - 1]; + if (lastTurn.dataset.agChatScraped === "true" || lastTurn.dataset.agChatScraped === "pending") return; + + var currentText = lastTurn.textContent || ''; + if (currentText.length < 5) return; + + if (_lastText !== currentText) { + _lastText = currentText; + _lastTextTime = Date.now(); + _lastTextSent = false; + } else if (!_lastTextSent) { + if (Date.now() - _lastTextTime > 3000) { + _lastTextSent = true; + lastTurn.dataset.agChatScraped = "pending"; + var finalTxt = extractChatContextFromNode(lastTurn); + if (finalTxt && finalTxt.length > 5 && finalTxt !== "Review Changes") { + fetch(BASE+'/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: finalTxt }) + }).then(function(){ + lastTurn.dataset.agChatScraped = "true"; + }).catch(function(){ + lastTurn.dataset.agChatScraped = "false"; + }); } else { - if (Date.now() - _chatSnapshots[found].lastChanged > 3500) { - turn.dataset.agChatScraped = "pending"; // prevent re-entry - var finalTxt = extractChatContextFromNode(turn); - if (finalTxt && finalTxt.length > 5) { - fetch(BASE+'/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: finalTxt }) - }).then(function(){ - turn.dataset.agChatScraped = "true"; - }).catch(function(){ - turn.dataset.agChatScraped = "false"; // retry - }); - } else { - turn.dataset.agChatScraped = "true"; - } - } + lastTurn.dataset.agChatScraped = "true"; } } } - _firstChatScan = false; } function scan(){ @@ -301,26 +247,17 @@ export function generateApprovalObserverScript(_port: number): string { if(b.disabled||b.hidden||(!b.offsetParent&&b.style.display!=='fixed'))continue; var txt=cleanButtonText(b); - console.log("[JSDOM] Button scan:", txt); - if(txt.length <= 1) continue; // Icon + if(txt.length <= 1) continue; - var matchedType=null; - for(var p=0;p(); const PENDING_MEMORY_TTL_MS = 60_000; @@ -276,15 +278,16 @@ function setupMonitor() { } catch (e: any) { if (pollCount <= 30) ctx.logToFile(`[POLL] Fallback 2 error for sid=${sid}: ${e.message}`); // If trajectory explicitly does not exist, it might be an Antigravity or non-Cascade session directory. - if (e.message?.includes('trajectory not found')) { - continue; - } - // FIXED: known-issues "AI Response Missing for New Sessions" -> Force register to prevent session loss on proto/UTF-8 parse errors + // We MUST register it so activeSessionId tracks it properly. + // To prevent old ghost sessions from hijacking, we only mark it RUNNING if it was recently modified. + const ageMs = Date.now() - brainDirs[i].time; + const isFresh = ageMs < 120_000; // updated within 2 mins + allTraj.trajectorySummaries[sid] = { - status: 'CASCADE_RUN_STATUS_RUNNING', + status: isFresh ? 'CASCADE_RUN_STATUS_RUNNING' : 'CASCADE_RUN_STATUS_IDLE', stepCount: 1, // Assume progressing to allow loop delta>0 trigger lastModifiedTime: new Date(brainDirs[i].time).toISOString(), - summary: 'Discovered via brain/ scan (Fallback Error)', + summary: 'Discovered via brain/ scan (Antigravity Native)', trajectoryMetadata: { workspaces: [{ workspaceFolderAbsoluteUri: ctx.workspaceUri.replace(/\\/g, '/') }] } }; } @@ -381,6 +384,9 @@ function setupMonitor() { // Session changed? if (bestSessionId !== ctx.activeSessionId) { ctx.activeSessionId = bestSessionId; + if (brainWatcher) { + brainWatcher.updateSession(bestSessionId); + } activeTrajectoryId = (bestSession as any).trajectoryId || ''; activeSessionTitle = currentTitle; lastKnownStepCount = currentCount; @@ -1261,6 +1267,13 @@ export function writePendingApproval(data: { conversation_id: string; command: s */ export function initStepProbe(context: BridgeContext) { ctx = context; + if (ctx.wsBridge) { + brainWatcher = new BrainWatcher({ + logToFile: ctx.logToFile, + wsBridge: ctx.wsBridge, + projectName: ctx.projectName + }); + } initApprovalHandler(context, () => activeTrajectoryId); setupMonitor(); setupResponseWatcher(); diff --git a/main.py b/main.py index ed61ab2..01b4a92 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,6 @@ import os import sys from config import Config -from watcher import BrainWatcher from bot import GravityBot # Logging setup (UTF-8 forced for Windows cp949 compatibility) @@ -51,45 +50,32 @@ async def main(): # Get the running loop loop = asyncio.get_running_loop() - # ── Local / Gateway mode ── - # Create components - watcher = None - if Config.BOT_MODE != 'gateway': - watcher = BrainWatcher(event_queue, loop) bot = GravityBot(event_queue) try: - # Start watcher (local mode only — gateway receives data via HTTP) - if watcher: - watcher.start() - logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions") - else: - logger.info("Gateway mode — watcher disabled (data via HTTP API)") + # Start Gateway HTTP API + WebSocket Hub + from gateway import GatewayAPI + from hub import WSHub + from auth import TokenManager - # Start Gateway HTTP API + WebSocket Hub (gateway mode) - if Config.BOT_MODE == 'gateway': - from gateway import GatewayAPI - from hub import WSHub - from auth import TokenManager + # Initialize Hub + token_mgr = TokenManager( + secret=Config.GRAVITY_HUB_SECRET, + registration_code=Config.GRAVITY_REGISTRATION_CODE, + ) + hub = WSHub(token_mgr) - # Initialize Hub - token_mgr = TokenManager( - secret=Config.GRAVITY_HUB_SECRET, - registration_code=Config.GRAVITY_REGISTRATION_CODE, - ) - hub = WSHub(token_mgr) - - gateway_port = int(os.environ.get('GATEWAY_PORT', '8585')) - gateway = GatewayAPI( - bot, port=gateway_port, - api_key=Config.GATEWAY_API_KEY, - hub=hub, - ) - bot.gateway = gateway # Enable _write_command → gateway.push_command - bot.hub = hub # Enable Hub-based message routing - await gateway.start() - logger.info(f"Gateway API + WS Hub running on port {gateway_port}") + gateway_port = int(os.environ.get('GATEWAY_PORT', '8585')) + gateway = GatewayAPI( + bot, port=gateway_port, + api_key=Config.GATEWAY_API_KEY, + hub=hub, + ) + bot.gateway = gateway # Enable _write_command → gateway.push_command + bot.hub = hub # Enable Hub-based message routing + await gateway.start() + logger.info(f"Gateway API + WS Hub running on port {gateway_port}") # Run Discord bot (blocks until bot disconnects) await bot.start(Config.DISCORD_TOKEN) @@ -100,8 +86,6 @@ async def main(): logger.error(f"Fatal error: {e}", exc_info=True) finally: # Cleanup - if watcher: - watcher.stop() if not bot.is_closed(): await bot.close() logger.info("Gravity Control shutdown complete") diff --git a/models.py b/models.py new file mode 100644 index 0000000..fcf37ab --- /dev/null +++ b/models.py @@ -0,0 +1,55 @@ +import time +from dataclasses import dataclass, field +from enum import Enum + + +class ApprovalStatus(Enum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + TIMEOUT = "timeout" + + +@dataclass +class ApprovalRequest: + """An approval request from Antigravity.""" + request_id: str + conversation_id: str + command: str # The command/action needing approval + description: str # Human-readable description + timestamp: float + status: str = "pending" + discord_message_id: int = 0 + project_name: str = "" # Project routing key + step_type: str = "" # e.g. 'diff_review', passed through to response + safe_to_auto_run: bool = False # Allows bot to silently auto-approve + + +@dataclass +class UserResponse: + """A user response from Discord.""" + request_id: str + approved: bool + user_input: str = "" + timestamp: float = field(default_factory=time.time) + button_index: int = -1 # -1 = legacy (approve/reject), 0+ = specific button index + step_type: str = "" # pass through from pending for extension routing + project_name: str = "" # for multi-project: extension uses this when pending file is missing + + +class EventType(Enum): + """Types of brain events.""" + SESSION_START = "session_start" # New conversation directory created + FILE_CHANGED = "file_changed" # Watched file 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: str = None + content: str = "" + timestamp: float = field(default_factory=time.time) diff --git a/scratch_test.js b/scratch_test.js deleted file mode 100644 index 8b49b42..0000000 --- a/scratch_test.js +++ /dev/null @@ -1,4 +0,0 @@ -const fs = require('fs'); -const lsPath = "C:\\Users\\Variet-Worker\\.gemini\\antigravity\\sdk\\ls.js"; -// Dummy script to just read allTraj.trajectorySummaries by using the SDK directly? -// Actually simpler: I can just grep the log if I log it. diff --git a/start_bot.bat b/start_bot.bat deleted file mode 100644 index b22dba0..0000000 --- a/start_bot.bat +++ /dev/null @@ -1,63 +0,0 @@ -@echo off -chcp 65001 >nul 2>&1 -title Gravity Bridge Bot - -echo ╔══════════════════════════════════════╗ -echo ║ Gravity Bridge Bot Launcher ║ -echo ╚══════════════════════════════════════╝ -echo. - -echo [INFO] 로컬 테스트 (BOT_MODE=local)를 시작합니다. -echo [INFO] 서버 배포는 BOT_MODE=gateway로 실행하세요. -echo. -echo 시작하려면 아무 키나 누르세요... -pause >nul - -REM — Find Python (conda first, then system) -set PYTHON= -if exist "C:\ProgramData\miniforge3\envs\gravity_control\python.exe" ( - set PYTHON=C:\ProgramData\miniforge3\envs\gravity_control\python.exe -) -if "%PYTHON%"=="" ( - where python >nul 2>&1 && set PYTHON=python -) -if "%PYTHON%"=="" ( - echo [ERROR] Python not found. Install Python 3.10+ or set path. - pause - exit /b 1 -) - -REM — Check .env -if not exist "%~dp0.env" ( - echo [SETUP] .env not found. Creating from .env.example... - if exist "%~dp0.env.example" ( - copy "%~dp0.env.example" "%~dp0.env" >nul - echo [SETUP] .env created — edit it with your Discord token and Guild ID. - echo. - notepad "%~dp0.env" - echo Press any key after saving .env... - pause >nul - ) else ( - echo [ERROR] .env.example not found. - pause - exit /b 1 - ) -) - -REM — Install dependencies (first run) -if not exist "%~dp0.deps_installed" ( - echo [SETUP] Installing dependencies... - %PYTHON% -m pip install -r "%~dp0requirements.txt" -q - echo. > "%~dp0.deps_installed" - echo [SETUP] Dependencies installed. -) - -echo [START] Starting bot with %PYTHON%... -echo [START] Press Ctrl+C to stop. -echo. - -%PYTHON% "%~dp0main.py" - -echo. -echo [STOP] Bot stopped. -pause diff --git a/watcher.py b/watcher.py deleted file mode 100644 index 6e2bce3..0000000 --- a/watcher.py +++ /dev/null @@ -1,290 +0,0 @@ -"""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. - -Key design: ONLY emits events for meaningful content changes using hash dedup. -""" - -import asyncio -import hashlib -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 - FILE_CHANGED = "file_changed" # Watched file 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, debounces, and deduplicates brain events. - - Phase 2 FIX: Only emits events for sessions belonging to the current project - (Config.PROJECT_NAME), using bridge/register/ files for session→project mapping. - """ - - 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._content_hashes: dict[str, str] = {} # path -> md5 hash (dedup) - self._known_sessions: set[str] = set() - # Phase 2: project filter - self._session_project_map: dict[str, str] = {} # conv_id → project_name - self._project_map_ts: float = 0 # last load timestamp - self._PROJECT_MAP_TTL: float = 60.0 # reload every 60s - self._initialize_known_sessions() - - def _initialize_known_sessions(self): - """Scan existing brain directories to establish baseline (no events emitted). - Also pre-loads content hashes for watched files to prevent spurious events. - """ - brain_path = Config.BRAIN_PATH - hash_count = 0 - 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) - # Pre-load content hashes for watched files - for watched in Config.WATCHED_FILES: - fpath = entry / watched - if fpath.exists(): - try: - content = fpath.read_text(encoding="utf-8") - h = hashlib.md5(content.encode()).hexdigest() - self._content_hashes[str(fpath)] = h - hash_count += 1 - except (OSError, UnicodeDecodeError): - pass - logger.info( - f"Found {len(self._known_sessions)} existing sessions, " - f"pre-loaded {hash_count} content hashes" - ) - - def _load_session_project_map(self) -> dict[str, str]: - """Load session→project mapping from bridge/register/ files (cached).""" - now = time.time() - if now - self._project_map_ts < self._PROJECT_MAP_TTL: - return self._session_project_map - - import json - register_dir = Config.BRAIN_PATH.parent / "bridge" / "register" - if not register_dir.exists(): - self._project_map_ts = now - return self._session_project_map - - new_map: dict[str, str] = {} - for f in register_dir.glob("*.json"): - try: - data = json.loads(f.read_text(encoding="utf-8-sig")) - conv_id = data.get("conversation_id", "") - project = data.get("project_name", "") - if conv_id and project: - new_map[conv_id] = project - except (json.JSONDecodeError, OSError): - pass - - self._session_project_map = new_map - self._project_map_ts = now - return self._session_project_map - - def _is_my_session(self, conv_id: str) -> bool: - """Check if a session belongs to the current project. - - Returns True for: - - Sessions registered to Config.PROJECT_NAME - - Unknown sessions (not in any register file — allow to avoid blocking) - Returns False for sessions registered to OTHER projects. - """ - session_map = self._load_session_project_map() - project = session_map.get(conv_id) - if project is None: - return True # Unknown → allow (newly started, not yet registered) - return project == Config.PROJECT_NAME - - def dispatch(self, event: FileSystemEvent): - """Early filter: skip events for files/dirs we don't care about. - - This runs BEFORE on_created/on_modified, avoiding unnecessary - method dispatch overhead for the majority of file events. - """ - path = Path(event.src_path) - - # Skip .system_generated and logs subdirectories immediately - path_parts = path.parts - if '.system_generated' in path_parts or 'logs' in path_parts: - return - - # For file events, skip non-watched files immediately - if not event.is_directory: - file_name = path.name - if not self._is_watched_file(file_name): - return - - super().dispatch(event) - - def _is_conversation_id(self, name: str) -> bool: - 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: - 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: - 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 _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: - """Filter: watch primary artifact files + any file matching watched extensions.""" - if file_name in Config.WATCHED_FILES: - return True - # Extension-based matching (e.g., any .md file in conversation dir) - ext = Path(file_name).suffix - if ext and ext in Config.WATCHED_EXTENSIONS: - return True - return False - - def _emit(self, event: BrainEvent): - 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): - conv_id = self._get_conversation_id(path) - if conv_id and conv_id not in self._known_sessions: - 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): - conv_id = self._get_conversation_id(path) - if not conv_id: - return - - # Phase 2 FIX: only emit events for MY project's sessions - if not self._is_my_session(conv_id): - return - - # Exclude files in .system_generated subdirectory (AG internal logs) - try: - relative = path.relative_to(Config.BRAIN_PATH / conv_id) - if '.system_generated' in relative.parts: - return - except ValueError: - pass - - file_name = path.name - - # Filter: watched files by name or extension - if not self._is_watched_file(file_name): - return - - # Debounce: skip rapid-fire events for same file - 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 - - # 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( - 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): - 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): - self.observer.stop() - self.observer.join() - logger.info("Brain watcher stopped") - - @property - def known_sessions(self) -> set[str]: - return self.handler._known_sessions