Compare commits
3 Commits
a99c283656
...
072f83bf25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072f83bf25 | ||
|
|
5e697cd919 | ||
|
|
b3825e1c8a |
@@ -248,3 +248,6 @@
|
|||||||
- **원인**: 1) UI 버튼 텍스트에 `keyboard_arrow_up` 등 머티리얼 아이콘 텍스트가 접착(`Always runkeyboard_arrow_up`)되어 정규식이 실패할 것을 우려해 단어 경계(`\b`)를 제거한 패치가 원인. 단어 경계가 사라지면서 `/Run/i` 패턴이 `Running1 command` 같은 다른 상태 텍스트 버튼에 오탐(False Positive)됨. 2) DOM 순서상 상태 텍스트 버튼이 앞서 있으므로 오탐된 버튼이 우선 클릭됨.
|
- **원인**: 1) UI 버튼 텍스트에 `keyboard_arrow_up` 등 머티리얼 아이콘 텍스트가 접착(`Always runkeyboard_arrow_up`)되어 정규식이 실패할 것을 우려해 단어 경계(`\b`)를 제거한 패치가 원인. 단어 경계가 사라지면서 `/Run/i` 패턴이 `Running1 command` 같은 다른 상태 텍스트 버튼에 오탐(False Positive)됨. 2) DOM 순서상 상태 텍스트 버튼이 앞서 있으므로 오탐된 버튼이 우선 클릭됨.
|
||||||
- **해결**: `trigger-click` 로직 실행 전 버튼의 `textContent`에서 `keyboard_arrow_up` 등 알려진 꼬리 아이콘 문자열을 명시적으로 제거(strip)하고, 모든 트리거 정규식에 다시 단어 경계(`\b`)를 강제 삽입하여 오탐을 원천 차단함.
|
- **해결**: `trigger-click` 로직 실행 전 버튼의 `textContent`에서 `keyboard_arrow_up` 등 알려진 꼬리 아이콘 문자열을 명시적으로 제거(strip)하고, 모든 트리거 정규식에 다시 단어 경계(`\b`)를 강제 삽입하여 오탐을 원천 차단함.
|
||||||
- **주의**: UI 요소를 DOM에서 긁어올 때는 텍스트에 숨겨진 아이콘/웹폰트 리거쳐(ligatures)가 없는지 검토해야 함. 패턴 매칭 시 꼬리표를 먼저 제거하고 명확한 경계를 부여할 것.
|
- **주의**: UI 요소를 DOM에서 긁어올 때는 텍스트에 숨겨진 아이콘/웹폰트 리거쳐(ligatures)가 없는지 검토해야 함. 패턴 매칭 시 꼬리표를 먼저 제거하고 명확한 경계를 부여할 것.
|
||||||
|
|
||||||
|
### [2026-04-10] [Extension] Ghost Session Hijack & Infinite Polling Loop (trajectory not found)
|
||||||
|
- **증상**: 신규 작업 시 '신호안들어와' (Discord로 릴레이 안 됨). 로그에 500 error trajectory not found 무한 반복.\n- **원인**: Antigravity가 작업하면서 brain/에 36글자 폴더를 생성하는데, Cascade가 아니므로 GetCascadeTrajectorySteps에서 500 에러를 냅니다. 하지만 이전 신규 세션 유실 방지 패치가 이 Ghost 세션을 RUNNING으로 강제 등록하면서, 활성 세션(activeSessionId)을 탈취하고 무한 에러 루프에 빠지게 만들었습니다.\n- **해결**: step-probe.ts에서 폴백 등록 시 error message에 'trajectory not found'가 포함되면 Ghost 세션으로 간주해 강제 등록(continue)을 건너뛰게 하고, Stall Probe 에러 catch에서도 UTF-8 에러가 아니면 stallProbed=true를 주어 재시도 무한 루프를 완전히 끊어냈습니다.\n- **주의**: uuid 길이(36자)만으로 디렉토리를 식별할 때 Antigravity와 Google Agent가 모호해질 수 있으므로, 반드시 Backend 응답의 확실한 에러(trajectory not found) 메시지로 예외 판별을 해야 합니다.\n
|
||||||
478
bot.py
478
bot.py
@@ -30,8 +30,7 @@ from parser import (
|
|||||||
md_to_discord_text,
|
md_to_discord_text,
|
||||||
format_task_embed_text,
|
format_task_embed_text,
|
||||||
)
|
)
|
||||||
from watcher import BrainEvent, EventType
|
from models import BrainEvent, EventType, ApprovalRequest, UserResponse
|
||||||
from bridge import BridgeProtocol, ApprovalRequest, UserResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -47,10 +46,9 @@ class ApprovalView(discord.ui.View):
|
|||||||
(e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny)
|
(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):
|
buttons: list[dict] | None = None, hub=None):
|
||||||
super().__init__(timeout=1800) # 30 minutes
|
super().__init__(timeout=1800) # 30 minutes
|
||||||
self.bridge = bridge
|
|
||||||
self.hub = hub # WSHub instance for WS response routing
|
self.hub = hub # WSHub instance for WS response routing
|
||||||
self.request = request
|
self.request = request
|
||||||
self.responded = False
|
self.responded = False
|
||||||
@@ -100,12 +98,9 @@ class ApprovalView(discord.ui.View):
|
|||||||
# Hub WS route (primary — reaches remote Extensions)
|
# Hub WS route (primary — reaches remote Extensions)
|
||||||
delivered = False
|
delivered = False
|
||||||
if self.hub:
|
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,
|
"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
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||||
if embed:
|
if embed:
|
||||||
color = discord.Color.red() if is_reject else discord.Color.green()
|
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', ''),
|
"step_type": getattr(self.request, 'step_type', ''),
|
||||||
"project_name": getattr(self.request, 'project_name', ''),
|
"project_name": getattr(self.request, 'project_name', ''),
|
||||||
}
|
}
|
||||||
delivered = False
|
|
||||||
if self.hub:
|
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,
|
"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
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||||
if embed:
|
if embed:
|
||||||
embed.color = discord.Color.green()
|
embed.color = discord.Color.green()
|
||||||
@@ -158,13 +150,10 @@ class ApprovalView(discord.ui.View):
|
|||||||
"step_type": getattr(self.request, 'step_type', ''),
|
"step_type": getattr(self.request, 'step_type', ''),
|
||||||
"project_name": getattr(self.request, 'project_name', ''),
|
"project_name": getattr(self.request, 'project_name', ''),
|
||||||
}
|
}
|
||||||
delivered = False
|
|
||||||
if self.hub:
|
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,
|
"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
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||||
if embed:
|
if embed:
|
||||||
embed.color = discord.Color.red()
|
embed.color = discord.Color.red()
|
||||||
@@ -172,12 +161,14 @@ class ApprovalView(discord.ui.View):
|
|||||||
await interaction.response.edit_message(embed=embed, view=None)
|
await interaction.response.edit_message(embed=embed, view=None)
|
||||||
|
|
||||||
async def on_timeout(self):
|
async def on_timeout(self):
|
||||||
if not self.responded:
|
if not self.responded and self.hub:
|
||||||
self.bridge.write_response(UserResponse(
|
await self.hub.send_response_to_pending_owner(self.request.request_id, {
|
||||||
request_id=self.request.request_id, approved=False,
|
"type": "response", "data": {
|
||||||
step_type=getattr(self.request, 'step_type', ''),
|
"request_id": self.request.request_id, "approved": False,
|
||||||
project_name=getattr(self.request, 'project_name', ''),
|
"step_type": getattr(self.request, 'step_type', ''),
|
||||||
))
|
"project_name": getattr(self.request, 'project_name', ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ─── Bot ─────────────────────────────────────────────────────────────
|
# ─── Bot ─────────────────────────────────────────────────────────────
|
||||||
@@ -207,7 +198,6 @@ class GravityBot(commands.Bot):
|
|||||||
self._sent_commands: dict[str, str] = {} # request_id → command text (for MERGE edit detection)
|
self._sent_commands: dict[str, str] = {} # request_id → command text (for MERGE edit detection)
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
self._channel_lock = asyncio.Lock()
|
self._channel_lock = asyncio.Lock()
|
||||||
self.bridge = BridgeProtocol()
|
|
||||||
self.session_category: discord.CategoryChannel | None = None
|
self.session_category: discord.CategoryChannel | None = None
|
||||||
self.guild: discord.Guild | None = None
|
self.guild: discord.Guild | None = None
|
||||||
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
|
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),
|
"project_name": kwargs.get('project_name', project),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Hub route (primary — skip file bridge to prevent double delivery)
|
# Hub route (primary)
|
||||||
if self.hub:
|
if self.hub:
|
||||||
import time as _time
|
import time as _time
|
||||||
cmd_data["id"] = str(int(_time.time() * 1000))
|
cmd_data["id"] = str(int(_time.time() * 1000))
|
||||||
@@ -246,14 +236,6 @@ class GravityBot(commands.Bot):
|
|||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self.hub.broadcast_to_project(project, msg)
|
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):
|
def _cap_dict(self, d: dict, max_size: int = 5000):
|
||||||
"""Prevent memory leaks by capping dictionary sizes using insertion order (oldest first)."""
|
"""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):
|
async def setup_hook(self):
|
||||||
self.loop.create_task(self._process_events())
|
self.loop.create_task(self._process_events())
|
||||||
self.pending_approval_scanner.start()
|
|
||||||
self.chat_snapshot_scanner.start()
|
|
||||||
self._register_slash_commands()
|
self._register_slash_commands()
|
||||||
# Register Hub handlers (if Hub is available, set after setup_hook by main.py)
|
# Register Hub handlers (if Hub is available, set after setup_hook by main.py)
|
||||||
asyncio.get_event_loop().call_soon(self._register_hub_handlers)
|
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!")
|
logger.error("No permission to create category!")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Discover existing project channels
|
# Start WS Hub processors by ensuring ready gate is open
|
||||||
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
|
|
||||||
self._ready_event.set()
|
self._ready_event.set()
|
||||||
logger.info("Ready gate opened — event processing enabled")
|
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 ──────────────────────────────────────────
|
# ─── 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 ──────────────────────────────────────────
|
# ─── Channel Management ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -618,270 +553,7 @@ class GravityBot(commands.Bot):
|
|||||||
|
|
||||||
# ─── Approval Scanner ────────────────────────────────────────────
|
# ─── 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(
|
async def _send_approval_request(
|
||||||
self, channel: discord.TextChannel, request: ApprovalRequest
|
self, channel: discord.TextChannel, request: ApprovalRequest
|
||||||
@@ -1133,9 +805,8 @@ class GravityBot(commands.Bot):
|
|||||||
self._cap_dict(self._sent_approval_ids)
|
self._cap_dict(self._sent_approval_ids)
|
||||||
self._sent_approval_ids[request.request_id] = True
|
self._sent_approval_ids[request.request_id] = True
|
||||||
|
|
||||||
delivered = False
|
|
||||||
if self.hub:
|
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",
|
"type": "response",
|
||||||
"data": {
|
"data": {
|
||||||
"request_id": request.request_id,
|
"request_id": request.request_id,
|
||||||
@@ -1145,13 +816,6 @@ class GravityBot(commands.Bot):
|
|||||||
"project_name": request.project_name,
|
"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)
|
# Send compact auto-approved embed to Discord (was missing — caused silent approvals)
|
||||||
channel = await self._get_channel(request.project_name)
|
channel = await self._get_channel(request.project_name)
|
||||||
if channel:
|
if channel:
|
||||||
@@ -1282,114 +946,4 @@ class GravityBot(commands.Bot):
|
|||||||
|
|
||||||
# ─── Chat Snapshot Scanner ─────────────────────────────────────────
|
# ─── 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
267
bridge.py
@@ -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
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
| NNN | 시간 | 작업 설명 | 커밋해시 | 완료여부 |
|
| NNN | 시간 | 작업 설명 | 커밋해시 | 완료여부 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 001 | 17:11 | step-probe.ts 의 isRunning 조건 누락으로 인한 릴레이 증발 버그 픽스 | COMMITTING | ✅ |
|
| 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 | ✅ |
|
||||||
|
|||||||
5
docs/devlog/2026-04-11.md
Normal file
5
docs/devlog/2026-04-11.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-04-11
|
||||||
|
|
||||||
|
| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 |
|
||||||
|
|-------|-------|----------|-----------|-----------|
|
||||||
|
| 001 | 13:04 | Pure 웹소켓 게이트웨이 완전 전환 및 레거시 파일 브릿지 통신코드 삭제 | `TBD` | ✅ |
|
||||||
13
docs/devlog/entries/20260411-001.md
Normal file
13
docs/devlog/entries/20260411-001.md
Normal 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.
@@ -120,15 +120,6 @@ function detectProjectName() {
|
|||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
// ─── Bridge File I/O ───
|
// ─── 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
|
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
|
||||||
let activeSessionId = '';
|
let activeSessionId = '';
|
||||||
let activeTrajectoryId = '';
|
let activeTrajectoryId = '';
|
||||||
@@ -136,39 +127,16 @@ let activeTrajectoryId = '';
|
|||||||
const recentDiscordSentTexts = new Map();
|
const recentDiscordSentTexts = new Map();
|
||||||
function writeChatSnapshot(text) {
|
function writeChatSnapshot(text) {
|
||||||
try {
|
try {
|
||||||
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
|
|
||||||
if (wsBridge && wsBridge.isConnected()) {
|
if (wsBridge && wsBridge.isConnected()) {
|
||||||
wsBridge.sendChat({
|
wsBridge.sendChat({
|
||||||
content: text,
|
content: text,
|
||||||
conversation_id: activeSessionId,
|
conversation_id: (0, step_probe_1.getActiveSessionId)(),
|
||||||
project_name: projectName,
|
project_name: projectName,
|
||||||
});
|
});
|
||||||
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
|
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
|
||||||
if (activeSessionId) {
|
if ((0, step_probe_1.getActiveSessionId)()) {
|
||||||
(0, step_probe_1.writeRegistration)(activeSessionId);
|
(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) {
|
catch (e) {
|
||||||
@@ -177,38 +145,17 @@ function writeChatSnapshot(text) {
|
|||||||
}
|
}
|
||||||
function writeChatSnapshotWithFiles(text, files) {
|
function writeChatSnapshotWithFiles(text, files) {
|
||||||
try {
|
try {
|
||||||
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
|
|
||||||
if (wsBridge && wsBridge.isConnected()) {
|
if (wsBridge && wsBridge.isConnected()) {
|
||||||
wsBridge.sendChat({
|
wsBridge.sendChat({
|
||||||
content: text,
|
content: text,
|
||||||
attached_files: files,
|
attached_files: files,
|
||||||
conversation_id: activeSessionId,
|
conversation_id: (0, step_probe_1.getActiveSessionId)(),
|
||||||
project_name: projectName,
|
project_name: projectName,
|
||||||
});
|
});
|
||||||
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
|
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
|
||||||
if (activeSessionId) {
|
if ((0, step_probe_1.getActiveSessionId)()) {
|
||||||
(0, step_probe_1.writeRegistration)(activeSessionId);
|
(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) {
|
catch (e) {
|
||||||
@@ -405,7 +352,6 @@ async function activate(context) {
|
|||||||
const config = vscode.workspace.getConfiguration('gravityBridge');
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
||||||
const configPath = config.get('bridgePath');
|
const configPath = config.get('bridgePath');
|
||||||
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
|
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
|
||||||
ensureBridgeDir();
|
|
||||||
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
|
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
|
||||||
// ── WebSocket Hub Connection ──
|
// ── WebSocket Hub Connection ──
|
||||||
const hubUrl = process.env.GRAVITY_HUB_URL || config.get('hubUrl') || '';
|
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 activeSessionId() { return (0, step_probe_1.getStepProbeContext)().activeSessionId; },
|
||||||
get sessionStalled() { return (0, step_probe_1.getStepProbeContext)().sessionStalled; },
|
get sessionStalled() { return (0, step_probe_1.getStepProbeContext)().sessionStalled; },
|
||||||
get lastPendingStepIndex() { return (0, step_probe_1.getStepProbeContext)().lastPendingStepIndex; },
|
get lastPendingStepIndex() { return (0, step_probe_1.getStepProbeContext)().lastPendingStepIndex; },
|
||||||
|
writeChatSnapshot,
|
||||||
};
|
};
|
||||||
const bridgePort = await (0, http_bridge_1.startHttpBridge)(httpBridgeCtx, sdk);
|
const bridgePort = await (0, http_bridge_1.startHttpBridge)(httpBridgeCtx, sdk);
|
||||||
let localPort = bridgePort;
|
let localPort = bridgePort;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
454
extension/package-lock.json
generated
454
extension/package-lock.json
generated
@@ -1,82 +1,380 @@
|
|||||||
{
|
{
|
||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"version": "0.5.25",
|
"version": "0.5.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"version": "0.5.25",
|
"version": "0.5.34",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.19.0"
|
"cheerio": "^1.2.0",
|
||||||
},
|
"ws": "^8.19.0"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/node": "^20.0.0",
|
"devDependencies": {
|
||||||
"@types/vscode": "^1.100.0",
|
"@types/node": "^20.0.0",
|
||||||
"typescript": "^5.3.0"
|
"@types/vscode": "^1.100.0",
|
||||||
},
|
"typescript": "^5.3.0"
|
||||||
"engines": {
|
},
|
||||||
"vscode": "^1.100.0"
|
"engines": {
|
||||||
}
|
"vscode": "^1.100.0"
|
||||||
},
|
}
|
||||||
"node_modules/@types/node": {
|
},
|
||||||
"version": "20.19.37",
|
"node_modules/@types/node": {
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
"version": "20.19.37",
|
||||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||||
"dev": true,
|
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||||
"license": "MIT",
|
"dev": true,
|
||||||
"dependencies": {
|
"license": "MIT",
|
||||||
"undici-types": "~6.21.0"
|
"dependencies": {
|
||||||
}
|
"undici-types": "~6.21.0"
|
||||||
},
|
}
|
||||||
"node_modules/@types/vscode": {
|
},
|
||||||
"version": "1.100.0",
|
"node_modules/@types/vscode": {
|
||||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
|
"version": "1.100.0",
|
||||||
"integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==",
|
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
|
||||||
"dev": true,
|
"integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==",
|
||||||
"license": "MIT"
|
"dev": true,
|
||||||
},
|
"license": "MIT"
|
||||||
"node_modules/typescript": {
|
},
|
||||||
"version": "5.9.3",
|
"node_modules/boolbase": {
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"version": "1.0.0",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
"dev": true,
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"license": "Apache-2.0",
|
"license": "ISC"
|
||||||
"bin": {
|
},
|
||||||
"tsc": "bin/tsc",
|
"node_modules/cheerio": {
|
||||||
"tsserver": "bin/tsserver"
|
"version": "1.2.0",
|
||||||
},
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
"node": ">=14.17"
|
"license": "MIT",
|
||||||
}
|
"dependencies": {
|
||||||
},
|
"cheerio-select": "^2.1.0",
|
||||||
"node_modules/undici-types": {
|
"dom-serializer": "^2.0.0",
|
||||||
"version": "6.21.0",
|
"domhandler": "^5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"domutils": "^3.2.2",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"encoding-sniffer": "^0.2.1",
|
||||||
"dev": true,
|
"htmlparser2": "^10.1.0",
|
||||||
"license": "MIT"
|
"parse5": "^7.3.0",
|
||||||
},
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
"node_modules/ws": {
|
"parse5-parser-stream": "^7.1.2",
|
||||||
"version": "8.19.0",
|
"undici": "^7.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"whatwg-mimetype": "^4.0.0"
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
},
|
||||||
"license": "MIT",
|
"engines": {
|
||||||
"engines": {
|
"node": ">=20.18.1"
|
||||||
"node": ">=10.0.0"
|
},
|
||||||
},
|
"funding": {
|
||||||
"peerDependencies": {
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
"bufferutil": "^4.0.1",
|
}
|
||||||
"utf-8-validate": ">=5.0.2"
|
},
|
||||||
},
|
"node_modules/cheerio-select": {
|
||||||
"peerDependenciesMeta": {
|
"version": "2.1.0",
|
||||||
"bufferutil": {
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
"optional": true
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
},
|
"license": "BSD-2-Clause",
|
||||||
"utf-8-validate": {
|
"dependencies": {
|
||||||
"optional": true
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,90 @@
|
|||||||
{
|
{
|
||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"displayName": "Gravity Bridge",
|
"displayName": "Gravity Bridge",
|
||||||
"description": "Discord-based unified approval system for Antigravity AI interactions.",
|
"description": "Discord-based unified approval system for Antigravity AI interactions.",
|
||||||
"version": "0.5.27",
|
"version": "0.5.36",
|
||||||
"publisher": "variet",
|
"publisher": "variet",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.100.0"
|
"vscode": "^1.100.0"
|
||||||
},
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
"Other",
|
"Other",
|
||||||
"Chat"
|
"Chat"
|
||||||
|
],
|
||||||
|
"activationEvents": [
|
||||||
|
"onStartupFinished"
|
||||||
|
],
|
||||||
|
"main": "./out/extension.js",
|
||||||
|
"scripts": {
|
||||||
|
"vscode:prepublish": "npm run compile",
|
||||||
|
"compile": "tsc -p ./ && node -e \"const fs=require('fs'),p=require('path');const s=p.join('src','sdk'),d=p.join('out','sdk');if(fs.existsSync(s)){fs.mkdirSync(d,{recursive:true});fs.readdirSync(s).forEach(f=>fs.copyFileSync(p.join(s,f),p.join(d,f)));console.log('SDK copied to out/sdk')};\"",
|
||||||
|
"watch": "tsc -watch -p ./"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/vscode": "^1.100.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
},
|
||||||
|
"contributes": {
|
||||||
|
"chatParticipants": [
|
||||||
|
{
|
||||||
|
"id": "gravity-bridge.gravity",
|
||||||
|
"name": "gravity",
|
||||||
|
"fullName": "Gravity Bridge",
|
||||||
|
"description": "?<3F>???<3F>스?<3F>리<EFBFBD>?Discord<72>??<3F>송 + AI ?<3F>어",
|
||||||
|
"isSticky": false
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"activationEvents": [
|
"commands": [
|
||||||
"onStartupFinished"
|
{
|
||||||
|
"command": "gravityBridge.start",
|
||||||
|
"title": "Gravity Bridge: Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gravityBridge.stop",
|
||||||
|
"title": "Gravity Bridge: Stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gravityBridge.approve",
|
||||||
|
"title": "Gravity Bridge: Approve Pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gravityBridge.reject",
|
||||||
|
"title": "Gravity Bridge: Reject Pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gravityBridge.connect",
|
||||||
|
"title": "Gravity Bridge: Connect Session"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"main": "./out/extension.js",
|
"configuration": {
|
||||||
"scripts": {
|
"title": "Gravity Bridge",
|
||||||
"vscode:prepublish": "npm run compile",
|
"properties": {
|
||||||
"compile": "tsc -p ./ && node -e \"const fs=require('fs'),p=require('path');const s=p.join('src','sdk'),d=p.join('out','sdk');if(fs.existsSync(s)){fs.mkdirSync(d,{recursive:true});fs.readdirSync(s).forEach(f=>fs.copyFileSync(p.join(s,f),p.join(d,f)));console.log('SDK copied to out/sdk')};\"",
|
"gravityBridge.bridgePath": {
|
||||||
"watch": "tsc -watch -p ./"
|
"type": "string",
|
||||||
},
|
"default": "",
|
||||||
"devDependencies": {
|
"description": "Bridge ?<3F>렉?<3F>리 경로 (기본: ~/.gemini/antigravity/bridge)"
|
||||||
"@types/node": "^20.0.0",
|
},
|
||||||
"@types/vscode": "^1.100.0",
|
"gravityBridge.projectName": {
|
||||||
"typescript": "^5.3.0"
|
"type": "string",
|
||||||
},
|
"default": "",
|
||||||
"contributes": {
|
"description": "?<3F>로?<3F>트 ?<3F>름 (기본: git remote ?<3F>포<EFBFBD>?"
|
||||||
"chatParticipants": [
|
},
|
||||||
{
|
"gravityBridge.hubUrl": {
|
||||||
"id": "gravity-bridge.gravity",
|
"type": "string",
|
||||||
"name": "gravity",
|
"default": "",
|
||||||
"fullName": "Gravity Bridge",
|
"description": "WebSocket Hub URL (?? wss://your-server.com/ws)"
|
||||||
"description": "대화 히스토리를 Discord로 전송 + AI 제어",
|
},
|
||||||
"isSticky": false
|
"gravityBridge.registrationCode": {
|
||||||
}
|
"type": "string",
|
||||||
],
|
"default": "",
|
||||||
"commands": [
|
"description": "Hub ?<3F>록 코드 (?<3F>버?<3F>서 발급)"
|
||||||
{
|
|
||||||
"command": "gravityBridge.start",
|
|
||||||
"title": "Gravity Bridge: Start"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "gravityBridge.stop",
|
|
||||||
"title": "Gravity Bridge: Stop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "gravityBridge.approve",
|
|
||||||
"title": "Gravity Bridge: Approve Pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "gravityBridge.reject",
|
|
||||||
"title": "Gravity Bridge: Reject Pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "gravityBridge.connect",
|
|
||||||
"title": "Gravity Bridge: Connect Session"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configuration": {
|
|
||||||
"title": "Gravity Bridge",
|
|
||||||
"properties": {
|
|
||||||
"gravityBridge.bridgePath": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)"
|
|
||||||
},
|
|
||||||
"gravityBridge.projectName": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "프로젝트 이름 (기본: git remote 레포명)"
|
|
||||||
},
|
|
||||||
"gravityBridge.hubUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "WebSocket Hub URL (예: wss://your-server.com/ws)"
|
|
||||||
},
|
|
||||||
"gravityBridge.registrationCode": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Hub 등록 코드 (서버에서 발급)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"dependencies": {
|
|
||||||
"ws": "^8.19.0"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
extension/src/brain-watcher.ts
Normal file
96
extension/src/brain-watcher.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,13 +86,7 @@ function detectProjectName(): string {
|
|||||||
|
|
||||||
// ─── Bridge File I/O ───
|
// ─── 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
|
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
|
||||||
let activeSessionId = '';
|
let activeSessionId = '';
|
||||||
@@ -102,34 +96,15 @@ const recentDiscordSentTexts: Map<string, number> = new Map();
|
|||||||
|
|
||||||
function writeChatSnapshot(text: string) {
|
function writeChatSnapshot(text: string) {
|
||||||
try {
|
try {
|
||||||
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
|
|
||||||
if (wsBridge && wsBridge.isConnected()) {
|
if (wsBridge && wsBridge.isConnected()) {
|
||||||
wsBridge.sendChat({
|
wsBridge.sendChat({
|
||||||
content: text,
|
content: text,
|
||||||
conversation_id: activeSessionId,
|
conversation_id: getStepProbeSessionId(),
|
||||||
project_name: projectName,
|
project_name: projectName,
|
||||||
});
|
});
|
||||||
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
|
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
|
||||||
if (activeSessionId) { writeRegistration(activeSessionId); }
|
if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); }
|
||||||
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) { writeRegistration(activeSessionId); }
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
|
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 }>) {
|
function writeChatSnapshotWithFiles(text: string, files: Array<{ name: string, content: string }>) {
|
||||||
try {
|
try {
|
||||||
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
|
|
||||||
if (wsBridge && wsBridge.isConnected()) {
|
if (wsBridge && wsBridge.isConnected()) {
|
||||||
wsBridge.sendChat({
|
wsBridge.sendChat({
|
||||||
content: text,
|
content: text,
|
||||||
attached_files: files,
|
attached_files: files,
|
||||||
conversation_id: activeSessionId,
|
conversation_id: getStepProbeSessionId(),
|
||||||
project_name: projectName,
|
project_name: projectName,
|
||||||
});
|
});
|
||||||
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
|
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
|
||||||
if (activeSessionId) { writeRegistration(activeSessionId); }
|
if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); }
|
||||||
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) { writeRegistration(activeSessionId); }
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`);
|
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 config = vscode.workspace.getConfiguration('gravityBridge');
|
||||||
const configPath = config.get<string>('bridgePath');
|
const configPath = config.get<string>('bridgePath');
|
||||||
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
|
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
|
||||||
ensureBridgeDir();
|
|
||||||
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
|
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
|
||||||
|
|
||||||
// ── WebSocket Hub Connection ──
|
// ── WebSocket Hub Connection ──
|
||||||
@@ -527,6 +485,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
get activeSessionId() { return getStepProbeContext().activeSessionId; },
|
get activeSessionId() { return getStepProbeContext().activeSessionId; },
|
||||||
get sessionStalled() { return getStepProbeContext().sessionStalled; },
|
get sessionStalled() { return getStepProbeContext().sessionStalled; },
|
||||||
get lastPendingStepIndex() { return getStepProbeContext().lastPendingStepIndex; },
|
get lastPendingStepIndex() { return getStepProbeContext().lastPendingStepIndex; },
|
||||||
|
writeChatSnapshot,
|
||||||
};
|
};
|
||||||
const bridgePort = await startHttpBridge(httpBridgeCtx, sdk);
|
const bridgePort = await startHttpBridge(httpBridgeCtx, sdk);
|
||||||
let localPort = bridgePort;
|
let localPort = bridgePort;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { WSBridgeClient } from './ws-client';
|
import { WSBridgeClient } from './ws-client';
|
||||||
|
|
||||||
|
let lastFilePermissionTime = 0;
|
||||||
|
|
||||||
// ─── Context interface (shared state from extension.ts) ───
|
// ─── Context interface (shared state from extension.ts) ───
|
||||||
|
|
||||||
export interface HttpBridgeContext {
|
export interface HttpBridgeContext {
|
||||||
@@ -23,6 +25,7 @@ export interface HttpBridgeContext {
|
|||||||
sessionStalled: boolean;
|
sessionStalled: boolean;
|
||||||
lastPendingStepIndex: number;
|
lastPendingStepIndex: number;
|
||||||
logToFile: (msg: string) => void;
|
logToFile: (msg: string) => void;
|
||||||
|
writeChatSnapshot?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Module-level state ───
|
// ─── Module-level state ───
|
||||||
@@ -106,6 +109,12 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /chat — renderer posts chat snapshots directly
|
||||||
|
if (req.method === 'POST' && url.pathname === '/chat') {
|
||||||
|
_handleChatSnapshot(req, res, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// POST /deep-inspect-result — renderer posts inspection results here
|
// POST /deep-inspect-result — renderer posts inspection results here
|
||||||
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
|
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
|
||||||
_handleDeepInspectResult(req, res, ctx);
|
_handleDeepInspectResult(req, res, ctx);
|
||||||
@@ -120,7 +129,7 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
fs.writeFileSync(path.join(ctx.bridgePath, 'dump_html.json'), dumpBody, 'utf-8');
|
fs.writeFileSync(path.join(ctx.bridgePath, 'dump_html.json'), dumpBody, 'utf-8');
|
||||||
} catch(e) {}
|
} catch (e) { }
|
||||||
res.writeHead(200); res.end('ok');
|
res.writeHead(200); res.end('ok');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -133,9 +142,9 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
|
|||||||
try {
|
try {
|
||||||
const params = JSON.parse(rpcBody);
|
const params = JSON.parse(rpcBody);
|
||||||
const result = await sdk.ls.rawRPC(params.method, params.args || {});
|
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));
|
res.end(typeof result === 'string' ? result : JSON.stringify(result));
|
||||||
} catch(e: any) {
|
} catch (e: any) {
|
||||||
res.writeHead(500); res.end(e.message);
|
res.writeHead(500); res.end(e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -239,9 +248,6 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rid = data.request_id || Date.now().toString();
|
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> = {
|
const pending: Record<string, any> = {
|
||||||
...data,
|
...data,
|
||||||
request_id: rid,
|
request_id: rid,
|
||||||
@@ -258,22 +264,13 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
if (cmdLower.includes('allow') && !pending.buttons) {
|
if (cmdLower.includes('allow') && !pending.buttons) {
|
||||||
// Dedup: skip if another file_permission pending was created within 10s
|
// Dedup: skip if another file_permission pending was created within 10s
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
try {
|
if (nowMs - lastFilePermissionTime < 10000) {
|
||||||
const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json'));
|
ctx.logToFile(`[HTTP] filtered duplicate file_permission (${nowMs - lastFilePermissionTime}ms old)`);
|
||||||
for (const ef of existingFiles) {
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8'));
|
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
|
||||||
if (existing.step_type === 'file_permission' && existing.status === 'pending'
|
return;
|
||||||
&& existing.project_name === ctx.projectName) {
|
}
|
||||||
const age = nowMs - (existing.timestamp * 1000);
|
lastFilePermissionTime = nowMs;
|
||||||
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 { }
|
|
||||||
|
|
||||||
pending.buttons = [
|
pending.buttons = [
|
||||||
{ text: 'Allow Once', index: 0 },
|
{ text: 'Allow Once', index: 0 },
|
||||||
@@ -285,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();
|
const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
|
||||||
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
|
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
|
||||||
}
|
}
|
||||||
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
// WS dispatch
|
||||||
// WS dual-write
|
|
||||||
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
|
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
|
||||||
ctx.wsBridge.sendPending({
|
ctx.wsBridge.sendPending({
|
||||||
request_id: rid,
|
request_id: rid,
|
||||||
@@ -381,10 +377,8 @@ function _handleDeepInspectResult(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
deepInspectResult = data;
|
deepInspectResult = data;
|
||||||
ctx.logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
|
ctx.logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
|
||||||
// Write to file for reference
|
|
||||||
const inspectFile = path.join(ctx.bridgePath, 'deep-inspect-result.json');
|
const inspectFile = path.join(ctx.bridgePath, 'deep-inspect-result.json');
|
||||||
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
|
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
|
||||||
// Notify waiters
|
|
||||||
const waiters = [...deepInspectWaiters];
|
const waiters = [...deepInspectWaiters];
|
||||||
deepInspectWaiters = [];
|
deepInspectWaiters = [];
|
||||||
waiters.forEach(w => w(data));
|
waiters.forEach(w => w(data));
|
||||||
@@ -396,3 +390,22 @@ function _handleDeepInspectResult(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _handleChatSnapshot(req: any, res: any, ctx: HttpBridgeContext) {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c: string) => body += c);
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
if (data.text && typeof ctx.writeChatSnapshot === 'function') {
|
||||||
|
ctx.writeChatSnapshot(`💬 **[DOM 추출] AI 응답**\n\n${data.text}`);
|
||||||
|
ctx.logToFile(`[HTTP] chat snapshot written (${data.text.length} chars)`);
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.logToFile(`[HTTP] chat parse error: ${e.message}`);
|
||||||
|
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export function generateApprovalObserverScript(_port: number): string {
|
export function generateApprovalObserverScript(_port: number): string {
|
||||||
return `
|
return `
|
||||||
// ── Gravity Bridge v4: React Tailwind UI Observer ──
|
// ── Gravity Bridge v5: Context-First DOM Extraction ──
|
||||||
(function(){
|
(function(){
|
||||||
'use strict';
|
'use strict';
|
||||||
var BASE='',_obs=false,_sent={},_ready=false;
|
var BASE='',_obs=false,_sent={},_ready=false;
|
||||||
@@ -9,7 +9,7 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
var CLEANUP_MS=300000;
|
var CLEANUP_MS=300000;
|
||||||
|
|
||||||
function log(m){console.log('[GB Observer] '+m);}
|
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
|
// React-Compatible Synthetic Clicker
|
||||||
function dispatchReactClick(el){
|
function dispatchReactClick(el){
|
||||||
@@ -21,28 +21,22 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
el.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true, view:window, composed:true}));
|
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}));
|
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window, composed:true}));
|
||||||
} catch(e) {
|
} 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) {
|
function cleanButtonText(btn) {
|
||||||
if (!btn) return '';
|
if (!btn) return '';
|
||||||
// if internal truncate span, use it
|
var clone = btn.cloneNode(true);
|
||||||
var tr = btn.querySelector('.truncate');
|
var icons = clone.querySelectorAll('.google-symbols, .codicon');
|
||||||
var txt = (tr ? tr.textContent : btn.textContent) || '';
|
for(var i=0; i<icons.length; i++) {
|
||||||
return txt.trim().replace(/(Alt|Ctrl|Shift|Meta)\\\\+.*/i,'').trim();
|
if(icons[i].parentNode) icons[i].parentNode.removeChild(icons[i]);
|
||||||
|
}
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stable button fingerprint ──
|
|
||||||
function btnId(b,type){
|
function btnId(b,type){
|
||||||
var txt = cleanButtonText(b);
|
var txt = cleanButtonText(b);
|
||||||
var parent = b.parentElement;
|
var parent = b.parentElement;
|
||||||
@@ -54,70 +48,73 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
return type+'|'+txt+'|'+idx;
|
return type+'|'+txt+'|'+idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context extraction — target BOTH chat history and command payload ──
|
|
||||||
function extractCommandContext(b){
|
function extractCommandContext(b){
|
||||||
var container = findButtonContainer(b);
|
var container = b.closest('.p-1') || b.parentElement.parentElement;
|
||||||
if (!container) return "";
|
if (!container) return "";
|
||||||
|
|
||||||
var titleSpans = container.querySelectorAll('span[title^="command("]');
|
var titleSpans = container.querySelectorAll('span[title^="command("]');
|
||||||
if (titleSpans && titleSpans.length > 0) {
|
if (titleSpans && titleSpans.length > 0) {
|
||||||
var t = titleSpans[0].getAttribute('title');
|
var t = titleSpans[0].getAttribute('title');
|
||||||
if (t && t.length > 5) return t.substring(0, 800);
|
if (t && t.length > 5) return t.substring(0, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
var preEls = container.querySelectorAll('pre');
|
var preEls = container.querySelectorAll('pre');
|
||||||
if (preEls && preEls.length > 0) {
|
if (preEls && preEls.length > 0) {
|
||||||
var t2 = (preEls[preEls.length-1].textContent || '').trim();
|
var t2 = (preEls[preEls.length-1].textContent || '').trim();
|
||||||
if (t2.length > 2) return t2.substring(0, 800);
|
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();
|
var fallback = (container.textContent || '').replace(cleanButtonText(b), '').trim();
|
||||||
return fallback.substring(0, 500);
|
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) {
|
function extractChatContext(b) {
|
||||||
try {
|
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) {
|
if (!botTurn) {
|
||||||
var container = findButtonContainer(b);
|
var container = b.closest('.p-1') || b.parentElement;
|
||||||
botTurn = container ? container.parentElement : null;
|
botTurn = container ? container.parentElement : null;
|
||||||
}
|
}
|
||||||
if (!botTurn) return '';
|
return extractChatContextFromNode(botTurn);
|
||||||
|
|
||||||
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);
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -133,15 +130,21 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
return combined.trim();
|
return combined.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Action Buttons Patterns ──
|
var ACTION_WORDS = ['Allow', 'Run', 'Approve', 'Accept', '결행', '수락', '반영', '허용', '승인'];
|
||||||
var PATS = [
|
var REJECT_WORDS = ['Reject', 'Cancel', 'Deny', 'Stop', 'Decline', 'Dismiss', '거절', '거부', '취소', '차단', '정지', '무시'];
|
||||||
{ type: 'command', re: /^(?:Always\\s*)?Run\\b/i },
|
|
||||||
{ type: 'permission', re: /^(?:Always\\s*)?Allow\\b/i },
|
function isActionBtn(txt) {
|
||||||
{ type: 'permission', re: /^(?:Always\\s*)?Approve\\b/i },
|
for(var i=0; i<ACTION_WORDS.length; i++) {
|
||||||
{ type: 'diff_review', re: /^(?:Always\\s*)?Accept\\b/i },
|
if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true;
|
||||||
];
|
}
|
||||||
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];
|
return false;
|
||||||
var REJECT_RE=[/^Reject\\b/i,/^Cancel\\b/i,/^Deny\\b/i,/^Stop\\b/i,/^Decline\\b/i,/^Dismiss\\b/i];
|
}
|
||||||
|
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){
|
function collectSiblingButtons(container,triggerBtn){
|
||||||
if(!container)return [];
|
if(!container)return [];
|
||||||
@@ -150,64 +153,91 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
for(var i=0;i<siblings.length;i++){
|
for(var i=0;i<siblings.length;i++){
|
||||||
var sb=siblings[i];
|
var sb=siblings[i];
|
||||||
if(sb.disabled||sb.hidden||(!sb.offsetParent&&sb.style.display!=='fixed'))continue;
|
if(sb.disabled||sb.hidden||(!sb.offsetParent&&sb.style.display!=='fixed'))continue;
|
||||||
|
|
||||||
var stxt = cleanButtonText(sb);
|
var stxt = cleanButtonText(sb);
|
||||||
if(stxt.length <= 1) continue; // Ignore icon buttons
|
if(stxt.length <= 1) continue;
|
||||||
|
if(!isActionBtn(stxt) && !isRejectBtn(stxt)) continue;
|
||||||
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;
|
|
||||||
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
|
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
var HARDCODED_PORT=${_port};
|
var HARDCODED_PORT=${_port};
|
||||||
|
|
||||||
function tryPingAsync(port){
|
function tryPingAsync(port){
|
||||||
return fetch('http://127.0.0.1:'+port+'/ping?t='+Date.now(),{signal:AbortSignal.timeout(2000)})
|
return fetch('http://127.0.0.1:'+port+'/ping?t='+Date.now(),{signal:AbortSignal.timeout(2000)})
|
||||||
.then(function(r){return r.text();})
|
.then(function(r){return r.text();}).then(function(t){return t==='pong';}).catch(function(){return false;});
|
||||||
.then(function(t){return t==='pong';})
|
|
||||||
.catch(function(){return false;});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverPort(cb){
|
function discoverPort(cb){
|
||||||
log('Waiting for Gravity Bridge status...');
|
|
||||||
var attempts=0;
|
var attempts=0;
|
||||||
var timer=setInterval(function(){
|
var timer=setInterval(function(){
|
||||||
attempts++;
|
attempts++;
|
||||||
var items = document.querySelectorAll('[aria-label^="Gravity Bridge Control"], [title^="Gravity Bridge Control"]');
|
var items = document.querySelectorAll('[aria-label^="Gravity Bridge Control"], [title^="Gravity Bridge Control"]');
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
var text = items[0].getAttribute('aria-label') || items[0].getAttribute('title') || '';
|
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]) {
|
if (m && m[1]) {
|
||||||
var domPort = parseInt(m[1], 10);
|
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
tryPingAsync(domPort).then(function(ok){
|
tryPingAsync(parseInt(m[1], 10)).then(function(ok){ cb(ok ? parseInt(m[1],10) : HARDCODED_PORT); });
|
||||||
if(ok) cb(domPort); else cb(HARDCODED_PORT);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If we are in the webview, the status bar is invisible. Skip quickly.
|
|
||||||
if(attempts>1){
|
if(attempts>1){
|
||||||
clearInterval(timer);
|
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){
|
discoverPort(function(port){
|
||||||
BASE='http://127.0.0.1:'+port;
|
BASE='http://127.0.0.1:'+port;
|
||||||
fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){
|
_ready=true;
|
||||||
if(t==='pong'){_ready=true;startObserver();}
|
startObserver();
|
||||||
}).catch(function(e){});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var _lastText = "";
|
||||||
|
var _lastTextTime = 0;
|
||||||
|
var _lastTextSent = false;
|
||||||
|
|
||||||
|
function scanChatBodies() {
|
||||||
|
if(!_ready)return;
|
||||||
|
var botTurns = document.querySelectorAll('.text-ide-message-block-bot-color');
|
||||||
|
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 {
|
||||||
|
lastTurn.dataset.agChatScraped = "true";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scan(){
|
function scan(){
|
||||||
if(!_ready)return;
|
if(!_ready)return;
|
||||||
|
scanChatBodies();
|
||||||
var now=Date.now();
|
var now=Date.now();
|
||||||
var allBtns=document.querySelectorAll('button');
|
var allBtns=document.querySelectorAll('button');
|
||||||
if(!allBtns.length)return;
|
if(!allBtns.length)return;
|
||||||
@@ -217,26 +247,17 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
if(b.disabled||b.hidden||(!b.offsetParent&&b.style.display!=='fixed'))continue;
|
if(b.disabled||b.hidden||(!b.offsetParent&&b.style.display!=='fixed'))continue;
|
||||||
|
|
||||||
var txt=cleanButtonText(b);
|
var txt=cleanButtonText(b);
|
||||||
console.log("[JSDOM] Button scan:", txt);
|
if(txt.length <= 1) continue;
|
||||||
if(txt.length <= 1) continue; // Icon
|
|
||||||
|
|
||||||
var matchedType=null;
|
if(!isActionBtn(txt)) continue;
|
||||||
for(var p=0;p<PATS.length;p++){
|
// Skip inline code lens buttons unless they actually match the pattern properly
|
||||||
if(PATS[p].re.test(txt)){
|
if (b.closest('.codelens-decoration') && !txt.includes('Accept') && !txt.includes('수락') && !txt.includes('반영')) {
|
||||||
if (b.closest('.codelens-decoration') && PATS[p].type !== 'diff_review' && PATS[p].type !== 'permission') {
|
continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
matchedType=PATS[p].type;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if(!matchedType){
|
|
||||||
console.log("[JSDOM] NOT MATCHED:", txt);
|
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt.includes('Run') ? 'command' : 'permission');
|
||||||
continue;
|
var container=b.closest('.p-1') || b.parentElement.parentElement;
|
||||||
}
|
|
||||||
var container=findButtonContainer(b);
|
|
||||||
var groupKey=matchedType+'|'+btnId(b,matchedType);
|
var groupKey=matchedType+'|'+btnId(b,matchedType);
|
||||||
console.log("[JSDOM] MATCHED:", matchedType, "KEY:", groupKey, "SENT:", !!_sent[groupKey]);
|
|
||||||
if(_sent[groupKey])continue;
|
if(_sent[groupKey])continue;
|
||||||
|
|
||||||
var siblings=collectSiblingButtons(container,b);
|
var siblings=collectSiblingButtons(container,b);
|
||||||
@@ -254,7 +275,6 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var desc=extractContext(b);
|
var desc=extractContext(b);
|
||||||
|
|
||||||
var is_dom_dummy = false;
|
var is_dom_dummy = false;
|
||||||
if (!desc || desc.trim().length <= 2) {
|
if (!desc || desc.trim().length <= 2) {
|
||||||
desc = "MISSING_DESCRIPTION_FROM_DOM_FALLBACK_TO_STEP_PROBE";
|
desc = "MISSING_DESCRIPTION_FROM_DOM_FALLBACK_TO_STEP_PROBE";
|
||||||
@@ -333,17 +353,15 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clickRejectButton(approveBtn){
|
function clickRejectButton(approveBtn){
|
||||||
var container=findButtonContainer(approveBtn);
|
var container=approveBtn.closest('.p-1') || approveBtn.parentElement.parentElement;
|
||||||
if(!container)return;
|
if(!container)return;
|
||||||
var siblings=container.querySelectorAll('button');
|
var siblings=container.querySelectorAll('button');
|
||||||
for(var i=0;i<siblings.length;i++){
|
for(var i=0;i<siblings.length;i++){
|
||||||
var t=cleanButtonText(siblings[i]);
|
var t=cleanButtonText(siblings[i]);
|
||||||
for(var r=0;r<REJECT_RE.length;r++){
|
if(isRejectBtn(t)){
|
||||||
if(REJECT_RE[r].test(t)){
|
log('Clicking reject: '+t);
|
||||||
log('Clicking reject: '+t);
|
dispatchReactClick(siblings[i]);
|
||||||
dispatchReactClick(siblings[i]);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,22 +410,17 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
if(_ready&&BASE){
|
if(_ready&&BASE){
|
||||||
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
|
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
|
||||||
if(!d.action)return;
|
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 isApprove = (d.action==='approve');
|
||||||
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 btns = document.querySelectorAll('button');
|
var btns = document.querySelectorAll('button');
|
||||||
for(var i=0;i<btns.length;i++){
|
for(var i=0;i<btns.length;i++){
|
||||||
var bx = btns[i];
|
var bx = btns[i];
|
||||||
if(bx.disabled||bx.hidden||(!bx.offsetParent&&bx.style.display!=='fixed'))continue;
|
if(bx.disabled||bx.hidden||(!bx.offsetParent&&bx.style.display!=='fixed'))continue;
|
||||||
var t = cleanButtonText(bx);
|
var t = cleanButtonText(bx);
|
||||||
if(t.length <= 1) continue;
|
if(t.length <= 1) continue;
|
||||||
for(var pi=0;pi<patterns.length;pi++){
|
if (isApprove ? isActionBtn(t) : isRejectBtn(t)) {
|
||||||
if(patterns[pi].test(t)){
|
log('Fallback TRIGGER-CLICK on "' + t + '"');
|
||||||
log('Fallback TRIGGER-CLICK on "' + t + '"');
|
dispatchReactClick(bx);
|
||||||
dispatchReactClick(bx);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as path from 'path';
|
|||||||
import { WSBridgeClient } from './ws-client';
|
import { WSBridgeClient } from './ws-client';
|
||||||
import { extractPlannerText, extractToolCommand, extractToolDescription } from './step-utils';
|
import { extractPlannerText, extractToolCommand, extractToolDescription } from './step-utils';
|
||||||
import { initApprovalHandler, setupResponseWatcher } from './approval-handler';
|
import { initApprovalHandler, setupResponseWatcher } from './approval-handler';
|
||||||
|
import { BrainWatcher } from './brain-watcher';
|
||||||
|
|
||||||
// Re-export from approval-handler for backward compatibility with extension.ts imports
|
// Re-export from approval-handler for backward compatibility with extension.ts imports
|
||||||
export { handleDiffReviewResponse, tryApprovalStrategies } from './approval-handler';
|
export { handleDiffReviewResponse, tryApprovalStrategies } from './approval-handler';
|
||||||
@@ -35,6 +36,7 @@ export interface BridgeContext {
|
|||||||
|
|
||||||
let ctx: BridgeContext;
|
let ctx: BridgeContext;
|
||||||
let responseWatcher: fs.FSWatcher | null = null;
|
let responseWatcher: fs.FSWatcher | null = null;
|
||||||
|
let brainWatcher: BrainWatcher | null = null;
|
||||||
let activeTrajectoryId = '';
|
let activeTrajectoryId = '';
|
||||||
const recentPendingSteps = new Map<string, number>();
|
const recentPendingSteps = new Map<string, number>();
|
||||||
const PENDING_MEMORY_TTL_MS = 60_000;
|
const PENDING_MEMORY_TTL_MS = 60_000;
|
||||||
@@ -275,6 +277,19 @@ function setupMonitor() {
|
|||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (pollCount <= 30) ctx.logToFile(`[POLL] Fallback 2 error for sid=${sid}: ${e.message}`);
|
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.
|
||||||
|
// 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: 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 (Antigravity Native)',
|
||||||
|
trajectoryMetadata: { workspaces: [{ workspaceFolderAbsoluteUri: ctx.workspaceUri.replace(/\\/g, '/') }] }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,6 +384,9 @@ function setupMonitor() {
|
|||||||
// Session changed?
|
// Session changed?
|
||||||
if (bestSessionId !== ctx.activeSessionId) {
|
if (bestSessionId !== ctx.activeSessionId) {
|
||||||
ctx.activeSessionId = bestSessionId;
|
ctx.activeSessionId = bestSessionId;
|
||||||
|
if (brainWatcher) {
|
||||||
|
brainWatcher.updateSession(bestSessionId);
|
||||||
|
}
|
||||||
activeTrajectoryId = (bestSession as any).trajectoryId || '';
|
activeTrajectoryId = (bestSession as any).trajectoryId || '';
|
||||||
activeSessionTitle = currentTitle;
|
activeSessionTitle = currentTitle;
|
||||||
lastKnownStepCount = currentCount;
|
lastKnownStepCount = currentCount;
|
||||||
@@ -772,6 +790,8 @@ function setupMonitor() {
|
|||||||
ctx.logToFile(`[STEP-PROBE] UTF-8 offset also failed: ${oe.message?.substring(0, 100)}`);
|
ctx.logToFile(`[STEP-PROBE] UTF-8 offset also failed: ${oe.message?.substring(0, 100)}`);
|
||||||
ctx.stallProbed = true; // permanent error — block retry loop; resets on delta>0
|
ctx.stallProbed = true; // permanent error — block retry loop; resets on delta>0
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
ctx.stallProbed = true; // Not a UTF-8 error (e.g. trajectory not found), prevent infinite loop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1247,6 +1267,13 @@ export function writePendingApproval(data: { conversation_id: string; command: s
|
|||||||
*/
|
*/
|
||||||
export function initStepProbe(context: BridgeContext) {
|
export function initStepProbe(context: BridgeContext) {
|
||||||
ctx = context;
|
ctx = context;
|
||||||
|
if (ctx.wsBridge) {
|
||||||
|
brainWatcher = new BrainWatcher({
|
||||||
|
logToFile: ctx.logToFile,
|
||||||
|
wsBridge: ctx.wsBridge,
|
||||||
|
projectName: ctx.projectName
|
||||||
|
});
|
||||||
|
}
|
||||||
initApprovalHandler(context, () => activeTrajectoryId);
|
initApprovalHandler(context, () => activeTrajectoryId);
|
||||||
setupMonitor();
|
setupMonitor();
|
||||||
setupResponseWatcher();
|
setupResponseWatcher();
|
||||||
|
|||||||
56
main.py
56
main.py
@@ -10,7 +10,6 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
from watcher import BrainWatcher
|
|
||||||
from bot import GravityBot
|
from bot import GravityBot
|
||||||
|
|
||||||
# Logging setup (UTF-8 forced for Windows cp949 compatibility)
|
# Logging setup (UTF-8 forced for Windows cp949 compatibility)
|
||||||
@@ -51,45 +50,32 @@ async def main():
|
|||||||
# Get the running loop
|
# Get the running loop
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
# ── Local / Gateway mode ──
|
|
||||||
|
|
||||||
# Create components
|
# Create components
|
||||||
watcher = None
|
|
||||||
if Config.BOT_MODE != 'gateway':
|
|
||||||
watcher = BrainWatcher(event_queue, loop)
|
|
||||||
bot = GravityBot(event_queue)
|
bot = GravityBot(event_queue)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start watcher (local mode only — gateway receives data via HTTP)
|
# Start Gateway HTTP API + WebSocket Hub
|
||||||
if watcher:
|
from gateway import GatewayAPI
|
||||||
watcher.start()
|
from hub import WSHub
|
||||||
logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions")
|
from auth import TokenManager
|
||||||
else:
|
|
||||||
logger.info("Gateway mode — watcher disabled (data via HTTP API)")
|
|
||||||
|
|
||||||
# Start Gateway HTTP API + WebSocket Hub (gateway mode)
|
# Initialize Hub
|
||||||
if Config.BOT_MODE == 'gateway':
|
token_mgr = TokenManager(
|
||||||
from gateway import GatewayAPI
|
secret=Config.GRAVITY_HUB_SECRET,
|
||||||
from hub import WSHub
|
registration_code=Config.GRAVITY_REGISTRATION_CODE,
|
||||||
from auth import TokenManager
|
)
|
||||||
|
hub = WSHub(token_mgr)
|
||||||
|
|
||||||
# Initialize Hub
|
gateway_port = int(os.environ.get('GATEWAY_PORT', '8585'))
|
||||||
token_mgr = TokenManager(
|
gateway = GatewayAPI(
|
||||||
secret=Config.GRAVITY_HUB_SECRET,
|
bot, port=gateway_port,
|
||||||
registration_code=Config.GRAVITY_REGISTRATION_CODE,
|
api_key=Config.GATEWAY_API_KEY,
|
||||||
)
|
hub=hub,
|
||||||
hub = WSHub(token_mgr)
|
)
|
||||||
|
bot.gateway = gateway # Enable _write_command → gateway.push_command
|
||||||
gateway_port = int(os.environ.get('GATEWAY_PORT', '8585'))
|
bot.hub = hub # Enable Hub-based message routing
|
||||||
gateway = GatewayAPI(
|
await gateway.start()
|
||||||
bot, port=gateway_port,
|
logger.info(f"Gateway API + WS Hub running on 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)
|
# Run Discord bot (blocks until bot disconnects)
|
||||||
await bot.start(Config.DISCORD_TOKEN)
|
await bot.start(Config.DISCORD_TOKEN)
|
||||||
@@ -100,8 +86,6 @@ async def main():
|
|||||||
logger.error(f"Fatal error: {e}", exc_info=True)
|
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
# Cleanup
|
# Cleanup
|
||||||
if watcher:
|
|
||||||
watcher.stop()
|
|
||||||
if not bot.is_closed():
|
if not bot.is_closed():
|
||||||
await bot.close()
|
await bot.close()
|
||||||
logger.info("Gravity Control shutdown complete")
|
logger.info("Gravity Control shutdown complete")
|
||||||
|
|||||||
55
models.py
Normal file
55
models.py
Normal 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)
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
290
watcher.py
290
watcher.py
@@ -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
|
|
||||||
Reference in New Issue
Block a user