refactor(bridge): migrate gravity bridge to pure websocket gateway architecture, deleting legacy local file scanners and dependencies

This commit is contained in:
Variet Worker
2026-04-11 13:06:38 +09:00
parent 5e697cd919
commit 072f83bf25
20 changed files with 756 additions and 1537 deletions

478
bot.py
View File

@@ -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()

267
bridge.py
View File

@@ -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

View File

@@ -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 | |

View File

@@ -0,0 +1,5 @@
# 2026-04-11
| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 |
|-------|-------|----------|-----------|-----------|
| 001 | 13:04 | Pure 웹소켓 게이트웨이 완전 전환 및 레거시 파일 브릿지 통신코드 삭제 | `TBD` | ✅ |

View File

@@ -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` 단일 변수로 최적화되었습니다.

Binary file not shown.

View File

@@ -120,15 +120,6 @@ function detectProjectName() {
return 'default';
}
// ─── 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 = '';
let activeTrajectoryId = '';
@@ -136,39 +127,16 @@ let activeTrajectoryId = '';
const recentDiscordSentTexts = new Map();
function writeChatSnapshot(text) {
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: (0, step_probe_1.getActiveSessionId)(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
if (activeSessionId) {
(0, step_probe_1.writeRegistration)(activeSessionId);
if ((0, step_probe_1.getActiveSessionId)()) {
(0, step_probe_1.writeRegistration)((0, step_probe_1.getActiveSessionId)());
}
return;
}
// 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) {
(0, step_probe_1.writeRegistration)(activeSessionId);
}
}
catch (e) {
@@ -177,38 +145,17 @@ function writeChatSnapshot(text) {
}
function writeChatSnapshotWithFiles(text, files) {
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: (0, step_probe_1.getActiveSessionId)(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
if (activeSessionId) {
(0, step_probe_1.writeRegistration)(activeSessionId);
if ((0, step_probe_1.getActiveSessionId)()) {
(0, step_probe_1.writeRegistration)((0, step_probe_1.getActiveSessionId)());
}
return;
}
// 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) {
(0, step_probe_1.writeRegistration)(activeSessionId);
}
}
catch (e) {
@@ -405,7 +352,6 @@ async function activate(context) {
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 ──
const hubUrl = process.env.GRAVITY_HUB_URL || config.get('hubUrl') || '';
@@ -541,6 +487,7 @@ async function activate(context) {
get activeSessionId() { return (0, step_probe_1.getStepProbeContext)().activeSessionId; },
get sessionStalled() { return (0, step_probe_1.getStepProbeContext)().sessionStalled; },
get lastPendingStepIndex() { return (0, step_probe_1.getStepProbeContext)().lastPendingStepIndex; },
writeChatSnapshot,
};
const bridgePort = await (0, http_bridge_1.startHttpBridge)(httpBridgeCtx, sdk);
let localPort = bridgePort;

File diff suppressed because one or more lines are too long

View File

@@ -1,82 +1,380 @@
{
"name": "gravity-bridge",
"version": "0.5.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gravity-bridge",
"version": "0.5.25",
"dependencies": {
"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/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-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
}
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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<string, number> = 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;
}
}
}

View File

@@ -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<string, number> = 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<string>('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;

View File

@@ -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<numbe
const fs = require('fs');
const path = require('path');
fs.writeFileSync(path.join(ctx.bridgePath, 'dump_html.json'), dumpBody, 'utf-8');
} catch(e) {}
} catch (e) { }
res.writeHead(200); res.end('ok');
});
return;
@@ -140,9 +142,9 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
try {
const params = JSON.parse(rpcBody);
const result = await sdk.ls.rawRPC(params.method, params.args || {});
res.writeHead(200, {'Content-Type': 'application/json'});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(typeof result === 'string' ? result : JSON.stringify(result));
} catch(e: any) {
} catch (e: any) {
res.writeHead(500); res.end(e.message);
}
});
@@ -246,9 +248,6 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
}
const rid = data.request_id || Date.now().toString();
// Write pending file for Discord bot
const pendingDir = path.join(ctx.bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
const pending: Record<string, any> = {
...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,

View File

@@ -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<codes.length; i++) {
codeText += (codes[i].textContent || '').trim() + ' ';
}
if (codeText.length > 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<node.childNodes.length; i++) {
walk(node.childNodes[i]);
}
}
}
if (node.nodeType === 1) {
var tg = node.tagName.toUpperCase();
if (tg==='P' || tg==='DIV' || tg==='BR' || tg==='LI' || tg==='PRE') textParts.push('\\n');
}
}
walk(botTurn);
res = textParts.join(' ').replace(/ \\n /g, '\\n').replace(/\\n+/g, '\\n').trim();
return res.substring(0, 3500);
}
function extractChatContext(b) {
try {
var botTurn = b.closest('.bg-agent-convo-background') || b.closest('.text-ide-message-block-bot-color');
var botTurn = b.closest('.text-ide-message-block-bot-color') || b.closest('.bg-agent-convo-background');
if (!botTurn) {
var container = findButtonContainer(b);
var container = b.closest('.p-1') || b.parentElement;
botTurn = container ? container.parentElement : null;
}
if (!botTurn) return '';
var toolContainer = findButtonContainer(b) || b;
var textParts = [];
function walk(node) {
if (node === toolContainer) return true; // Stop traversal at the tool box
if (node.nodeType === 1) {
var tag = node.tagName.toUpperCase();
if (tag==='BUTTON' || tag==='SVG' || tag==='STYLE' || tag==='SCRIPT') return false;
}
if (node.nodeType === 3) {
var val = node.nodeValue;
if (val && val.trim()) textParts.push(val.trim());
} else {
for(var i=0; i<node.childNodes.length; i++) {
if (walk(node.childNodes[i])) return true;
}
}
if (node.nodeType === 1) {
var tg = node.tagName.toUpperCase();
if (tg==='P' || tg==='DIV' || tg==='BR' || tg==='LI' || tg==='PRE') textParts.push('\\n');
}
return false;
}
walk(botTurn);
var result = textParts.join(' ').replace(/ \\n /g, '\\n').replace(/\\n+/g, '\\n').trim();
return result.substring(0, 1500);
return extractChatContextFromNode(botTurn);
} catch(e) {
return '';
}
}
function extractChatContextFromNode(botTurn) {
if (!botTurn) return '';
var toolContainer = botTurn.querySelector('.bg-ide-background-color'); // Stop at tool blocks
var textParts = [];
function walk(node) {
if (toolContainer && node === toolContainer) return true;
if (node.nodeType === 1) {
var tag = node.tagName.toUpperCase();
if (tag==='BUTTON' || tag==='SVG' || tag==='STYLE' || tag==='SCRIPT') return false;
}
if (node.nodeType === 3) {
var val = node.nodeValue;
if (val && val.trim()) textParts.push(val.trim());
} else {
for(var i=0; i<node.childNodes.length; i++) {
if (walk(node.childNodes[i])) return true;
}
}
if (node.nodeType === 1) {
var tg = node.tagName.toUpperCase();
if (tg==='P' || tg==='DIV' || tg==='BR' || tg==='LI' || tg==='PRE') textParts.push('\\n');
}
return false;
}
walk(botTurn);
var result = textParts.join(' ').replace(/ \\n /g, '\\n').replace(/\\n+/g, '\\n').trim();
return result.substring(0, 3500);
}
function extractContext(b) {
var cmd = extractCommandContext(b);
var chat = extractChatContext(b);
@@ -166,15 +130,21 @@ export function generateApprovalObserverScript(_port: number): string {
return combined.trim();
}
// ── Action Buttons Patterns (EN / KO) ──
var PATS = [
{ type: 'command', re: /^(?:Always\\s*)?(?:Run\\b|결행사양\\s*항상|결행)/i },
{ type: 'permission', re: /^(?:Always\\s*)?(?:Allow\\b|허용)/i },
{ type: 'permission', re: /^(?:Always\\s*)?(?:Approve\\b|승인)/i },
{ type: 'diff_review', re: /^(?:Always\\s*)?(?:Accept\\b|수락|반영)/i },
];
var ALL_ACTION_RE=[/^(?:Always\\s*)?(?:Run\\b|결행)/i,/^(?:Always\\s*)?(?:Accept\\b|수락|반영)/i,/^(?:Reject\\b|거절|거부)/i,/^(?:Always\\s*)?(?:Allow\\b|허용)/i,/^(?:Deny\\b|차단)/i,/^(?:Always\\s*)?(?:Approve\\b|승인)/i,/^(?:Cancel\\b|취소)/i,/^Retry\\b/i,/^(?:Dismiss\\b|무시)/i,/^(?:Stop\\b|정지)/i,/^Decline\\b/i];
var REJECT_RE=[/^(?:Reject\\b|거절|거부)/i,/^(?:Cancel\\b|취소)/i,/^(?:Deny\\b|차단)/i,/^(?:Stop\\b|정지)/i,/^Decline\\b/i,/^(?:Dismiss\\b|무시)/i];
var ACTION_WORDS = ['Allow', 'Run', 'Approve', 'Accept', '결행', '수락', '반영', '허용', '승인'];
var REJECT_WORDS = ['Reject', 'Cancel', 'Deny', 'Stop', 'Decline', 'Dismiss', '거절', '거부', '취소', '차단', '정지', '무시'];
function isActionBtn(txt) {
for(var i=0; i<ACTION_WORDS.length; i++) {
if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true;
}
return false;
}
function isRejectBtn(txt) {
for(var i=0; i<REJECT_WORDS.length; i++) {
if(txt.indexOf(REJECT_WORDS[i]) !== -1) return true;
}
return false;
}
function collectSiblingButtons(container,triggerBtn){
if(!container)return [];
@@ -183,110 +153,86 @@ export function generateApprovalObserverScript(_port: number): string {
for(var i=0;i<siblings.length;i++){
var sb=siblings[i];
if(sb.disabled||sb.hidden||(!sb.offsetParent&&sb.style.display!=='fixed'))continue;
var stxt = cleanButtonText(sb);
if(stxt.length <= 1) continue; // Ignore icon buttons
var isAction=false;
for(var a=0;a<ALL_ACTION_RE.length;a++){
if(ALL_ACTION_RE[a].test(stxt)){isAction=true;break;}
}
if(!isAction)continue;
if(stxt.length <= 1) continue;
if(!isActionBtn(stxt) && !isRejectBtn(stxt)) continue;
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
}
return result;
}
var HARDCODED_PORT=${_port};
function tryPingAsync(port){
return fetch('http://127.0.0.1:'+port+'/ping?t='+Date.now(),{signal:AbortSignal.timeout(2000)})
.then(function(r){return r.text();})
.then(function(t){return t==='pong';})
.catch(function(){return false;});
.then(function(r){return r.text();}).then(function(t){return t==='pong';}).catch(function(){return false;});
}
function discoverPort(cb){
log('Waiting for Gravity Bridge status...');
var attempts=0;
var timer=setInterval(function(){
attempts++;
var items = document.querySelectorAll('[aria-label^="Gravity Bridge Control"], [title^="Gravity Bridge Control"]');
if (items.length > 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 (botTurns.length === 0) return;
if (_firstChatScan) {
turn.dataset.agChatScraped = "true";
continue;
}
var lastTurn = botTurns[botTurns.length - 1];
if (lastTurn.dataset.agChatScraped === "true" || lastTurn.dataset.agChatScraped === "pending") return;
var currentText = turn.textContent || '';
var found = -1;
for (var j = 0; j < _chatSnapshots.length; j++) {
if (_chatSnapshots[j].node === turn) { found = j; break; }
}
var currentText = lastTurn.textContent || '';
if (currentText.length < 5) return;
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 (_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<PATS.length;p++){
if(PATS[p].re.test(txt)){
if (b.closest('.codelens-decoration') && PATS[p].type !== 'diff_review' && PATS[p].type !== 'permission') {
continue;
}
matchedType=PATS[p].type;
break;
}
if(!isActionBtn(txt)) continue;
// Skip inline code lens buttons unless they actually match the pattern properly
if (b.closest('.codelens-decoration') && !txt.includes('Accept') && !txt.includes('수락') && !txt.includes('반영')) {
continue;
}
if(!matchedType){
console.log("[JSDOM] NOT MATCHED:", txt);
continue;
}
var container=findButtonContainer(b);
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt.includes('Run') ? 'command' : 'permission');
var container=b.closest('.p-1') || b.parentElement.parentElement;
var groupKey=matchedType+'|'+btnId(b,matchedType);
console.log("[JSDOM] MATCHED:", matchedType, "KEY:", groupKey, "SENT:", !!_sent[groupKey]);
if(_sent[groupKey])continue;
var siblings=collectSiblingButtons(container,b);
@@ -338,7 +275,6 @@ export function generateApprovalObserverScript(_port: number): string {
}
var desc=extractContext(b);
var is_dom_dummy = false;
if (!desc || desc.trim().length <= 2) {
desc = "MISSING_DESCRIPTION_FROM_DOM_FALLBACK_TO_STEP_PROBE";
@@ -417,17 +353,15 @@ export function generateApprovalObserverScript(_port: number): string {
}
function clickRejectButton(approveBtn){
var container=findButtonContainer(approveBtn);
var container=approveBtn.closest('.p-1') || approveBtn.parentElement.parentElement;
if(!container)return;
var siblings=container.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){
var t=cleanButtonText(siblings[i]);
for(var r=0;r<REJECT_RE.length;r++){
if(REJECT_RE[r].test(t)){
log('Clicking reject: '+t);
dispatchReactClick(siblings[i]);
return;
}
if(isRejectBtn(t)){
log('Clicking reject: '+t);
dispatchReactClick(siblings[i]);
return;
}
}
}
@@ -476,22 +410,17 @@ export function generateApprovalObserverScript(_port: number): string {
if(_ready&&BASE){
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
if(!d.action)return;
var approveRe=[/^(?:Always\\\\s*)?(?:Run\\\\b|결행)/i,/^(?:Always\\\\s*)?(?:Accept\\\\b|수락)/i,/^(?:Always\\\\s*)?(?:Accept all\\\\b|모두 수락)/i,/^(?:Always\\\\s*)?(?:Allow\\\\b|허용)/i,/^(?:Always\\\\s*)?(?:Approve\\\\b|승인)/i];
var rejectRe=[/^(?:Reject\\\\b|거절|거부)/i,/^(?:Cancel\\\\b|취소)/i,/^(?:Deny\\\\b|차단)/i,/^(?:Stop\\\\b|정지)/i,/^Decline\\\\b/i,/^(?:Dismiss\\\\b|무시)/i];
var patterns=(d.action==='approve')?approveRe:rejectRe;
var isApprove = (d.action==='approve');
var btns = document.querySelectorAll('button');
for(var i=0;i<btns.length;i++){
var bx = btns[i];
if(bx.disabled||bx.hidden||(!bx.offsetParent&&bx.style.display!=='fixed'))continue;
var t = cleanButtonText(bx);
if(t.length <= 1) continue;
for(var pi=0;pi<patterns.length;pi++){
if(patterns[pi].test(t)){
log('Fallback TRIGGER-CLICK on "' + t + '"');
dispatchReactClick(bx);
return;
}
if (isApprove ? isActionBtn(t) : isRejectBtn(t)) {
log('Fallback TRIGGER-CLICK on "' + t + '"');
dispatchReactClick(bx);
return;
}
}
}).catch(function(){});

View File

@@ -9,6 +9,7 @@ import * as path from 'path';
import { WSBridgeClient } from './ws-client';
import { extractPlannerText, extractToolCommand, extractToolDescription } from './step-utils';
import { initApprovalHandler, setupResponseWatcher } from './approval-handler';
import { BrainWatcher } from './brain-watcher';
// Re-export from approval-handler for backward compatibility with extension.ts imports
export { handleDiffReviewResponse, tryApprovalStrategies } from './approval-handler';
@@ -35,6 +36,7 @@ export interface BridgeContext {
let ctx: BridgeContext;
let responseWatcher: fs.FSWatcher | null = null;
let brainWatcher: BrainWatcher | null = null;
let activeTrajectoryId = '';
const recentPendingSteps = new Map<string, number>();
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();

56
main.py
View File

@@ -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")

55
models.py Normal file
View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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