Compare commits
30 Commits
de6f1c7ffd
...
67619e8950
| Author | SHA1 | Date | |
|---|---|---|---|
| 67619e8950 | |||
| befa5d7c26 | |||
| 8d5e59c60f | |||
| c85bb64c03 | |||
| ae0fd78f7a | |||
| 180dba18be | |||
| c688812089 | |||
| e4eb7565ea | |||
| 7f81528c18 | |||
| 5780896273 | |||
| 35f39abf39 | |||
| b42475c610 | |||
| 0bd525a54c | |||
| 02e9e4d424 | |||
| af14e5fbc7 | |||
| 7f15e98e85 | |||
| c8c9920dd0 | |||
| d227ba57f7 | |||
| 35ee916440 | |||
| d44b4c2f77 | |||
| 046c58879c | |||
| 3c84cf5b4b | |||
| 98bb037c81 | |||
| 887850d0c9 | |||
| 2c56fc7607 | |||
| 2a4ef8d0d9 | |||
| f0184ec9bd | |||
| 51cfd57930 | |||
| efaf29a6d2 | |||
| 7c081e70b5 |
@@ -10,5 +10,8 @@ BRAIN_PATH=C:\Users\Certes\.gemini\antigravity\brain
|
|||||||
# 세션 활성 판단: 마지막 파일 변경으로부터 이 시간(초) 이내면 활성
|
# 세션 활성 판단: 마지막 파일 변경으로부터 이 시간(초) 이내면 활성
|
||||||
ACTIVE_TIMEOUT_SECONDS=300
|
ACTIVE_TIMEOUT_SECONDS=300
|
||||||
|
|
||||||
|
# Project name (used for Discord channel: AG-{PROJECT_NAME})
|
||||||
|
PROJECT_NAME=gravity_control
|
||||||
|
|
||||||
# Watcher Settings
|
# Watcher Settings
|
||||||
DEBOUNCE_SECONDS=2
|
DEBOUNCE_SECONDS=2
|
||||||
|
|||||||
520
bot.py
520
bot.py
@@ -1,15 +1,15 @@
|
|||||||
"""Discord bot — relays Antigravity brain events to Discord channels.
|
"""Discord bot — relays Antigravity brain events to Discord channels.
|
||||||
|
|
||||||
Dynamic channel management:
|
Multi-project channel architecture:
|
||||||
- Creates `AG-{project_name}` channels only when file events arrive
|
- One channel per project: AG-{project_name} (e.g. ag-gravity_control, ag-deriva)
|
||||||
- NO startup channel creation — only reconnects to existing Discord channels
|
- Each conversation maps to a project via conv_to_project dict
|
||||||
- Archives channels after 10 minutes of inactivity
|
- Extension registers projects via bridge/pending/ files
|
||||||
|
- Commands include project_name for routing to correct IDE window
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -77,46 +77,16 @@ class ApprovalView(discord.ui.View):
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
# ─── Project Name Detection ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def detect_project_name(conv_dir: Path) -> str:
|
|
||||||
"""Extract project name from conversation artifacts.
|
|
||||||
Returns: lowercase_with_underscores (e.g. 'gravity_control')
|
|
||||||
Uses FIRST successful extraction and caches it.
|
|
||||||
"""
|
|
||||||
short_id = conv_dir.name[:8]
|
|
||||||
|
|
||||||
def _sanitize(raw: str) -> str:
|
|
||||||
for suffix in ["Task Tracker", "— Task Tracker", "태스크", "구현 계획",
|
|
||||||
"Implementation Plan", "Walkthrough"]:
|
|
||||||
raw = raw.replace(suffix, "")
|
|
||||||
raw = raw.strip(" —-–")
|
|
||||||
raw = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', raw)
|
|
||||||
raw = re.sub(r'[\s\-]+', '_', raw).strip('_').lower()
|
|
||||||
return raw[:30] if raw else ""
|
|
||||||
|
|
||||||
for fname in ["task.md", "implementation_plan.md"]:
|
|
||||||
fpath = conv_dir / fname
|
|
||||||
if fpath.exists():
|
|
||||||
try:
|
|
||||||
first_lines = fpath.read_text(encoding="utf-8").splitlines()[:5]
|
|
||||||
for line in first_lines:
|
|
||||||
match = re.match(r'^#\s+(.+)', line)
|
|
||||||
if match:
|
|
||||||
name = _sanitize(match.group(1))
|
|
||||||
# Require at least 5 chars to avoid short generic names
|
|
||||||
if name and name != "task" and len(name) >= 5:
|
|
||||||
return name
|
|
||||||
except (OSError, UnicodeDecodeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return short_id
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Bot ─────────────────────────────────────────────────────────────
|
# ─── Bot ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class GravityBot(commands.Bot):
|
class GravityBot(commands.Bot):
|
||||||
"""Discord bot for Antigravity session monitoring."""
|
"""Discord bot for Antigravity session monitoring.
|
||||||
|
|
||||||
|
Multi-project architecture:
|
||||||
|
- project_channels: project_name → TextChannel (ag-gravity_control, ag-deriva, etc.)
|
||||||
|
- conv_to_project: conversation_id → project_name (learned from pending approvals)
|
||||||
|
- channel_to_project: channel_id → project_name (for Discord→IDE routing)
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, event_queue: asyncio.Queue):
|
def __init__(self, event_queue: asyncio.Queue):
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
@@ -125,19 +95,78 @@ class GravityBot(commands.Bot):
|
|||||||
super().__init__(command_prefix="!", intents=intents)
|
super().__init__(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
self.event_queue = event_queue
|
self.event_queue = event_queue
|
||||||
self.session_channels: dict[str, discord.TextChannel] = {}
|
self.project_channels: dict[str, discord.TextChannel] = {} # project → channel
|
||||||
self.session_status_messages: dict[str, int] = {}
|
self.conv_to_project: dict[str, str] = {} # conv_id → project
|
||||||
self.session_names: dict[str, str] = {}
|
self.channel_to_project: dict[int, str] = {} # channel.id → project
|
||||||
self._channel_create_lock = asyncio.Lock() # SINGLE global lock
|
self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
|
||||||
self._sent_approval_ids: set[str] = set() # Track sent approvals
|
self._sent_approval_ids: set[str] = set()
|
||||||
|
self._ready_event = asyncio.Event()
|
||||||
|
self._channel_lock = asyncio.Lock()
|
||||||
self.bridge = BridgeProtocol()
|
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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_channel_name(project_name: str) -> str:
|
||||||
|
"""ag-gravity_control, ag-deriva, etc."""
|
||||||
|
return f"{Config.CHANNEL_PREFIX}-{project_name}".lower()
|
||||||
|
|
||||||
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.pending_approval_scanner.start()
|
||||||
logger.info("Bot setup complete, event processor started")
|
self.chat_snapshot_scanner.start()
|
||||||
|
self._register_slash_commands()
|
||||||
|
logger.info("Bot setup complete")
|
||||||
|
|
||||||
|
def _register_slash_commands(self):
|
||||||
|
"""Register Discord slash commands."""
|
||||||
|
|
||||||
|
@self.tree.command(name="stop", description="AI 작업 중지")
|
||||||
|
async def slash_stop(interaction: discord.Interaction):
|
||||||
|
project = self.channel_to_project.get(interaction.channel_id)
|
||||||
|
if not project:
|
||||||
|
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
||||||
|
return
|
||||||
|
self.bridge.write_command(project, "!stop", project_name=project)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title="⏹️ AI 작업 중지",
|
||||||
|
description=f"**{project}** IDE에 중지 요청 전달됨",
|
||||||
|
color=discord.Color.orange(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.tree.command(name="auto", description="자동 승인 토글")
|
||||||
|
async def slash_auto(interaction: discord.Interaction, mode: str):
|
||||||
|
project = self.channel_to_project.get(interaction.channel_id)
|
||||||
|
if not project:
|
||||||
|
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
||||||
|
return
|
||||||
|
enabled = mode.lower() in ("on", "true", "1")
|
||||||
|
self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
|
||||||
|
emoji = "🟢" if enabled else "🔴"
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title=f"{emoji} {'자동 승인' if enabled else '수동 승인'} 모드",
|
||||||
|
description=f"프로젝트: **{project}**",
|
||||||
|
color=discord.Color.green() if enabled else discord.Color.red(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.tree.command(name="send", description="IDE 채팅에 메시지 전송")
|
||||||
|
async def slash_send(interaction: discord.Interaction, message: str):
|
||||||
|
project = self.channel_to_project.get(interaction.channel_id)
|
||||||
|
if not project:
|
||||||
|
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
||||||
|
return
|
||||||
|
self.bridge.write_command(project, message, project_name=project)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=discord.Embed(
|
||||||
|
description=f"📨 → **{project}** IDE에 전달됨\n`{message[:100]}`",
|
||||||
|
color=discord.Color.blurple(),
|
||||||
|
),
|
||||||
|
delete_after=10,
|
||||||
|
)
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
|
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
|
||||||
@@ -160,120 +189,138 @@ class GravityBot(commands.Bot):
|
|||||||
logger.error("No permission to create category!")
|
logger.error("No permission to create category!")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ONLY reconnect to existing Discord channels (NO new creation)
|
# Discover existing project channels
|
||||||
await self._reconnect_existing_channels()
|
await self._discover_channels()
|
||||||
|
|
||||||
async def _reconnect_existing_channels(self):
|
# Load conversation → project registrations from Extension
|
||||||
"""Scan existing Discord channels and map them — MERGE same-name channels."""
|
self._load_registrations()
|
||||||
if not self.session_category:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Group channels by normalized name
|
# Sync slash commands to guild
|
||||||
name_to_channel: dict[str, discord.TextChannel] = {}
|
|
||||||
duplicates: list[discord.TextChannel] = []
|
|
||||||
|
|
||||||
for ch in self.session_category.text_channels:
|
|
||||||
if ch.topic and "Antigravity Session:" in ch.topic:
|
|
||||||
if ch.name in name_to_channel:
|
|
||||||
# DUPLICATE — mark for cleanup
|
|
||||||
duplicates.append(ch)
|
|
||||||
else:
|
|
||||||
name_to_channel[ch.name] = ch
|
|
||||||
|
|
||||||
# Map the primary channel for each name
|
|
||||||
count = 0
|
|
||||||
for ch in name_to_channel.values():
|
|
||||||
conv_id = ch.topic.replace("Antigravity Session:", "").strip()
|
|
||||||
if conv_id:
|
|
||||||
self.session_channels[conv_id] = ch
|
|
||||||
await self._recover_task_message(ch, conv_id)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
# Delete duplicate channels
|
|
||||||
for ch in duplicates:
|
|
||||||
try:
|
|
||||||
await ch.delete(reason="Duplicate channel cleanup")
|
|
||||||
logger.info(f"Deleted duplicate channel: #{ch.name}")
|
|
||||||
except (discord.Forbidden, discord.HTTPException) as e:
|
|
||||||
logger.warning(f"Failed to delete duplicate #{ch.name}: {e}")
|
|
||||||
|
|
||||||
logger.info(f"Reconnected to {count} channels, cleaned {len(duplicates)} duplicates")
|
|
||||||
|
|
||||||
async def _recover_task_message(
|
|
||||||
self, channel: discord.TextChannel, conversation_id: str
|
|
||||||
):
|
|
||||||
if conversation_id in self.session_status_messages:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
async for msg in channel.history(limit=10):
|
self.tree.copy_global_to(guild=self.guild)
|
||||||
if msg.author == self.user and msg.embeds:
|
synced = await self.tree.sync(guild=self.guild)
|
||||||
embed = msg.embeds[0]
|
logger.info(f"Synced {len(synced)} slash commands to guild")
|
||||||
if embed.title and "Task" in embed.title:
|
except Exception as e:
|
||||||
self.session_status_messages[conversation_id] = msg.id
|
logger.warning(f"Slash command sync failed: {e}")
|
||||||
return
|
|
||||||
except (discord.Forbidden, discord.HTTPException):
|
# Open the gate
|
||||||
pass
|
self._ready_event.set()
|
||||||
|
logger.info("Ready gate opened — event processing enabled")
|
||||||
|
|
||||||
|
# Start scanner loops
|
||||||
|
if not self.pending_approval_scanner.is_running():
|
||||||
|
self.pending_approval_scanner.start()
|
||||||
|
if not self.chat_snapshot_scanner.is_running():
|
||||||
|
self.chat_snapshot_scanner.start()
|
||||||
|
logger.info("Scanner loops started")
|
||||||
|
|
||||||
# ─── Channel Management ──────────────────────────────────────────
|
# ─── Channel Management ──────────────────────────────────────────
|
||||||
|
|
||||||
async def _ensure_channel(
|
def _load_registrations(self):
|
||||||
self, conversation_id: str, project_name: str
|
"""Read bridge/register/ to learn conversation → project mappings."""
|
||||||
) -> discord.TextChannel:
|
register_dir = self.bridge.bridge_dir / "register"
|
||||||
"""Get or create a channel. SINGLE channel per project name, guaranteed."""
|
if not register_dir.exists():
|
||||||
channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}"
|
return
|
||||||
target_name = channel_name.lower().replace(" ", "-")
|
|
||||||
|
|
||||||
# Fast path: this conv_id already mapped
|
count = 0
|
||||||
if conversation_id in self.session_channels:
|
for f in register_dir.glob("*.json"):
|
||||||
ch = self.session_channels[conversation_id]
|
|
||||||
# Verify the channel name matches (project name might have changed)
|
|
||||||
if ch.name == target_name:
|
|
||||||
return ch
|
|
||||||
|
|
||||||
async with self._channel_create_lock:
|
|
||||||
# Double-check after lock
|
|
||||||
if conversation_id in self.session_channels:
|
|
||||||
ch = self.session_channels[conversation_id]
|
|
||||||
if ch.name == target_name:
|
|
||||||
return ch
|
|
||||||
|
|
||||||
# Check ALL mapped channels for same name
|
|
||||||
for cid, ch in self.session_channels.items():
|
|
||||||
if ch.name == target_name:
|
|
||||||
self.session_channels[conversation_id] = ch
|
|
||||||
self.session_names[conversation_id] = project_name
|
|
||||||
logger.info(f"Reusing channel #{ch.name} for {conversation_id[:8]}")
|
|
||||||
return ch
|
|
||||||
|
|
||||||
# Create new channel (truly no match anywhere)
|
|
||||||
try:
|
try:
|
||||||
channel = await self.guild.create_text_channel(
|
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 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _discover_channels(self):
|
||||||
|
"""Find existing project channels via Discord API (not cache)."""
|
||||||
|
all_channels = await self.guild.fetch_channels()
|
||||||
|
prefix = Config.CHANNEL_PREFIX.lower() + "-"
|
||||||
|
|
||||||
|
for ch in all_channels:
|
||||||
|
if (isinstance(ch, discord.TextChannel)
|
||||||
|
and ch.category_id == self.session_category.id
|
||||||
|
and ch.name.startswith(prefix)):
|
||||||
|
project = ch.name[len(prefix):]
|
||||||
|
self.project_channels[project] = ch
|
||||||
|
self.channel_to_project[ch.id] = project
|
||||||
|
logger.info(f"Found channel: #{ch.name} → project={project}")
|
||||||
|
|
||||||
|
logger.info(f"Discovered {len(self.project_channels)} project channels")
|
||||||
|
|
||||||
|
async def _get_channel(self, project_name: str) -> discord.TextChannel:
|
||||||
|
"""Get or create a channel for a project. Lock-protected."""
|
||||||
|
if project_name in self.project_channels:
|
||||||
|
return self.project_channels[project_name]
|
||||||
|
|
||||||
|
async with self._channel_lock:
|
||||||
|
# Double-check after lock
|
||||||
|
if project_name in self.project_channels:
|
||||||
|
return self.project_channels[project_name]
|
||||||
|
|
||||||
|
channel_name = self._make_channel_name(project_name)
|
||||||
|
|
||||||
|
# Search existing channels FIRST (prevents duplicates)
|
||||||
|
try:
|
||||||
|
all_channels = await self.guild.fetch_channels()
|
||||||
|
for ch in all_channels:
|
||||||
|
if (isinstance(ch, discord.TextChannel)
|
||||||
|
and ch.name == channel_name
|
||||||
|
and ch.category_id == self.session_category.id):
|
||||||
|
self.project_channels[project_name] = ch
|
||||||
|
self.channel_to_project[ch.id] = project_name
|
||||||
|
logger.info(f"Found existing channel: #{channel_name}")
|
||||||
|
return ch
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"fetch_channels failed: {e}")
|
||||||
|
|
||||||
|
# No existing channel — create new
|
||||||
|
try:
|
||||||
|
ch = await self.guild.create_text_channel(
|
||||||
name=channel_name,
|
name=channel_name,
|
||||||
category=self.session_category,
|
category=self.session_category,
|
||||||
topic=f"Antigravity Session: {conversation_id}",
|
topic=f"Antigravity Bridge — {project_name}",
|
||||||
)
|
)
|
||||||
self.session_channels[conversation_id] = channel
|
self.project_channels[project_name] = ch
|
||||||
self.session_names[conversation_id] = project_name
|
self.channel_to_project[ch.id] = project_name
|
||||||
logger.info(f"Created channel #{channel_name}")
|
logger.info(f"Created channel: #{channel_name}")
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=f"🚀 {project_name}",
|
title=f"🚀 {project_name}",
|
||||||
description=f"Antigravity 세션 연결됨\nSession: `{conversation_id}`",
|
description=f"Antigravity Bridge 연결됨",
|
||||||
color=discord.Color.blue(),
|
color=discord.Color.blue(),
|
||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
await channel.send(embed=embed)
|
await ch.send(embed=embed)
|
||||||
return channel
|
return ch
|
||||||
|
|
||||||
except discord.errors.Forbidden:
|
except discord.errors.Forbidden:
|
||||||
logger.error(f"No permission to create channel: {channel_name}")
|
logger.error(f"No permission to create channel: {channel_name}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ─── Event Processing (SINGLE ROUTE) ─────────────────────────────
|
def _resolve_project(self, conversation_id: str) -> str:
|
||||||
|
"""Get project name for a conversation. Falls back to default."""
|
||||||
|
return self.conv_to_project.get(
|
||||||
|
conversation_id, Config.PROJECT_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Event Processing ─────────────────────────────────────────────
|
||||||
|
|
||||||
async def _process_events(self):
|
async def _process_events(self):
|
||||||
"""Main event loop — ALL events go through here sequentially."""
|
"""Main event loop — ALL events go through here sequentially."""
|
||||||
await self.wait_until_ready()
|
await self.wait_until_ready()
|
||||||
|
await self._ready_event.wait()
|
||||||
|
logger.info("Event processor started (ready gate passed)")
|
||||||
|
|
||||||
while not self.is_closed():
|
while not self.is_closed():
|
||||||
try:
|
try:
|
||||||
@@ -287,17 +334,13 @@ class GravityBot(commands.Bot):
|
|||||||
logger.error(f"Error processing event: {e}", exc_info=True)
|
logger.error(f"Error processing event: {e}", exc_info=True)
|
||||||
|
|
||||||
async def _handle_event(self, event: BrainEvent):
|
async def _handle_event(self, event: BrainEvent):
|
||||||
"""Route brain events to handlers — single entry point."""
|
"""Route brain events to the correct project channel."""
|
||||||
conv_dir = Config.BRAIN_PATH / event.conversation_id
|
project = self._resolve_project(event.conversation_id)
|
||||||
project_name = detect_project_name(conv_dir)
|
channel = await self._get_channel(project)
|
||||||
|
if not channel:
|
||||||
if event.event_type == EventType.SESSION_START:
|
|
||||||
await self._ensure_channel(event.conversation_id, project_name)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# FILE_CREATED or FILE_CHANGED
|
if event.event_type == EventType.SESSION_START:
|
||||||
channel = await self._ensure_channel(event.conversation_id, project_name)
|
|
||||||
if not channel:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -306,22 +349,25 @@ class GravityBot(commands.Bot):
|
|||||||
else:
|
else:
|
||||||
await self._send_artifact_update(channel, event)
|
await self._send_artifact_update(channel, event)
|
||||||
except discord.NotFound:
|
except discord.NotFound:
|
||||||
# Channel was deleted while we held a reference
|
self.project_channels.pop(project, None)
|
||||||
self.session_channels.pop(event.conversation_id, None)
|
logger.warning(f"Channel deleted for project {project}, will recreate")
|
||||||
self.session_status_messages.pop(event.conversation_id, None)
|
|
||||||
logger.warning(f"Channel deleted, cleared: {event.conversation_id[:8]}")
|
|
||||||
|
|
||||||
# ─── Message Senders ─────────────────────────────────────────────
|
# ─── Message Senders ─────────────────────────────────────────────
|
||||||
|
|
||||||
async def _send_task_update(
|
async def _send_task_update(
|
||||||
self, channel: discord.TextChannel, event: BrainEvent
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
):
|
):
|
||||||
"""Send/edit task progress embed (SINGLE message, always edited)."""
|
|
||||||
progress = parse_task_progress(event.content)
|
progress = parse_task_progress(event.content)
|
||||||
|
|
||||||
|
# Full task content (truncated to embed limit)
|
||||||
|
full_content = event.content.strip()
|
||||||
|
description = format_task_embed_text(progress) + "\n\n" + full_content
|
||||||
|
if len(description) > 4000:
|
||||||
|
description = description[:4000] + "\n…(truncated)"
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="📋 Task 진행 현황",
|
title="📋 Task 진행 현황",
|
||||||
description=format_task_embed_text(progress),
|
description=description,
|
||||||
color=discord.Color.gold() if progress.in_progress > 0
|
color=discord.Color.gold() if progress.in_progress > 0
|
||||||
else discord.Color.green() if progress.done == progress.total
|
else discord.Color.green() if progress.done == progress.total
|
||||||
else discord.Color.greyple(),
|
else discord.Color.greyple(),
|
||||||
@@ -329,7 +375,6 @@ class GravityBot(commands.Bot):
|
|||||||
)
|
)
|
||||||
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
||||||
|
|
||||||
# Always try to edit existing message first
|
|
||||||
msg_id = self.session_status_messages.get(event.conversation_id)
|
msg_id = self.session_status_messages.get(event.conversation_id)
|
||||||
if msg_id:
|
if msg_id:
|
||||||
try:
|
try:
|
||||||
@@ -345,7 +390,6 @@ class GravityBot(commands.Bot):
|
|||||||
async def _send_artifact_update(
|
async def _send_artifact_update(
|
||||||
self, channel: discord.TextChannel, event: BrainEvent
|
self, channel: discord.TextChannel, event: BrainEvent
|
||||||
):
|
):
|
||||||
"""Send artifact update as single compact embed (preview only)."""
|
|
||||||
labels = {
|
labels = {
|
||||||
"implementation_plan.md": "📐 구현 계획",
|
"implementation_plan.md": "📐 구현 계획",
|
||||||
"walkthrough.md": "📝 작업 결과 요약",
|
"walkthrough.md": "📝 작업 결과 요약",
|
||||||
@@ -353,42 +397,65 @@ class GravityBot(commands.Bot):
|
|||||||
label = labels.get(event.file_name, f"📄 {event.file_name}")
|
label = labels.get(event.file_name, f"📄 {event.file_name}")
|
||||||
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
|
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
|
||||||
|
|
||||||
# Preview: first 6 non-empty lines only
|
full_content = event.content.strip()
|
||||||
lines = event.content.strip().splitlines()
|
CHUNK_SIZE = 4000 # Discord embed desc limit is 4096
|
||||||
preview = "\n".join(l for l in lines[:6] if l.strip())
|
|
||||||
if len(lines) > 6:
|
|
||||||
preview += f"\n... (+{len(lines) - 6} lines)"
|
|
||||||
|
|
||||||
|
# Split into chunks for long content
|
||||||
|
chunks = []
|
||||||
|
while full_content:
|
||||||
|
chunks.append(full_content[:CHUNK_SIZE])
|
||||||
|
full_content = full_content[CHUNK_SIZE:]
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
chunks = ["(빈 파일)"]
|
||||||
|
|
||||||
|
# First chunk with title
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=f"{label} ({event_label}됨)",
|
title=f"{label} ({event_label}됨)",
|
||||||
description=preview[:1000],
|
description=chunks[0],
|
||||||
color=discord.Color.blue(),
|
color=discord.Color.blue(),
|
||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
|
||||||
await channel.send(embed=embed)
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
|
# Additional chunks if content is long
|
||||||
|
for i, chunk in enumerate(chunks[1:], 2):
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"{label} (계속 {i}/{len(chunks)})",
|
||||||
|
description=chunk,
|
||||||
|
color=discord.Color.blue(),
|
||||||
|
)
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
# ─── Approval Scanner ────────────────────────────────────────────
|
# ─── Approval Scanner ────────────────────────────────────────────
|
||||||
|
|
||||||
@tasks.loop(seconds=3)
|
@tasks.loop(seconds=3)
|
||||||
async def pending_approval_scanner(self):
|
async def pending_approval_scanner(self):
|
||||||
"""Scan bridge/pending/ for new approval requests."""
|
"""Scan bridge/pending/ for new approval requests + reload registrations."""
|
||||||
try:
|
try:
|
||||||
|
# Reload conv→project registrations each cycle
|
||||||
|
self._load_registrations()
|
||||||
|
|
||||||
|
# Ensure channels exist for all registered projects
|
||||||
|
for project in set(self.conv_to_project.values()):
|
||||||
|
if project not in self.project_channels:
|
||||||
|
await self._get_channel(project)
|
||||||
|
logger.info(f"Auto-created channel for registered project: {project}")
|
||||||
|
|
||||||
requests = self.bridge.get_pending_requests()
|
requests = self.bridge.get_pending_requests()
|
||||||
for req in requests:
|
for req in requests:
|
||||||
if req.request_id in self._sent_approval_ids:
|
if req.request_id in self._sent_approval_ids:
|
||||||
continue # Already sent
|
continue
|
||||||
if req.discord_message_id != 0:
|
if req.discord_message_id != 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
channel = self.session_channels.get(req.conversation_id)
|
# Learn project mapping from pending approval
|
||||||
if not channel:
|
project = req.project_name or Config.PROJECT_NAME
|
||||||
conv_dir = Config.BRAIN_PATH / req.conversation_id
|
if req.conversation_id and req.conversation_id != '__global__':
|
||||||
if conv_dir.exists():
|
self.conv_to_project[req.conversation_id] = project
|
||||||
project_name = detect_project_name(conv_dir)
|
|
||||||
channel = await self._ensure_channel(
|
|
||||||
req.conversation_id, project_name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
channel = await self._get_channel(project)
|
||||||
if channel:
|
if channel:
|
||||||
self._sent_approval_ids.add(req.request_id)
|
self._sent_approval_ids.add(req.request_id)
|
||||||
await self._send_approval_request(channel, req)
|
await self._send_approval_request(channel, req)
|
||||||
@@ -416,11 +483,10 @@ class GravityBot(commands.Bot):
|
|||||||
view = ApprovalView(self.bridge, request)
|
view = ApprovalView(self.bridge, request)
|
||||||
msg = await channel.send(embed=embed, view=view)
|
msg = await channel.send(embed=embed, view=view)
|
||||||
|
|
||||||
# Update pending file with discord message id
|
|
||||||
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
|
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
|
||||||
if pending_file.exists():
|
if pending_file.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(pending_file.read_text(encoding="utf-8"))
|
data = json.loads(pending_file.read_text(encoding="utf-8-sig"))
|
||||||
data["discord_message_id"] = msg.id
|
data["discord_message_id"] = msg.id
|
||||||
pending_file.write_text(
|
pending_file.write_text(
|
||||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
@@ -428,47 +494,99 @@ class GravityBot(commands.Bot):
|
|||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"Sent approval request: {request.request_id[:8]}")
|
logger.info(f"Sent approval request: {request.request_id[:12]}")
|
||||||
|
|
||||||
# ─── Discord → Antigravity Text Relay ─────────────────────────────
|
# ─── Discord → IDE Text Relay ─────────────────────────────────────
|
||||||
|
|
||||||
async def on_message(self, message: discord.Message):
|
async def on_message(self, message: discord.Message):
|
||||||
if message.author == self.user:
|
if message.author == self.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not message.channel.name.startswith(Config.CHANNEL_PREFIX.lower() + "-"):
|
# Determine project from channel
|
||||||
|
project = self.channel_to_project.get(message.channel.id)
|
||||||
|
if not project:
|
||||||
|
await self.process_commands(message)
|
||||||
return
|
return
|
||||||
|
|
||||||
conv_id = None
|
|
||||||
for cid, ch in self.session_channels.items():
|
|
||||||
if ch.id == message.channel.id:
|
|
||||||
conv_id = cid
|
|
||||||
break
|
|
||||||
|
|
||||||
text = message.content.strip()
|
text = message.content.strip()
|
||||||
|
|
||||||
# Special command: !auto on/off
|
# Special command: !stop — cancel AI work
|
||||||
if text in ("!auto on", "!auto off"):
|
if text == "!stop":
|
||||||
if conv_id:
|
self.bridge.write_command(project, "!stop", project_name=project)
|
||||||
self.bridge.write_command(conv_id, text)
|
embed = discord.Embed(
|
||||||
enabled = text == "!auto on"
|
title="⏹️ AI 작업 중지",
|
||||||
emoji = "🟢" if enabled else "🔴"
|
description=f"프로젝트: **{project}**\n중지 요청을 Extension에 전달했습니다.",
|
||||||
mode = "자동 승인" if enabled else "수동 승인"
|
color=discord.Color.orange(),
|
||||||
embed = discord.Embed(
|
)
|
||||||
title=f"{emoji} {mode} 모드",
|
await message.channel.send(embed=embed)
|
||||||
description=f"Antigravity IDE 설정이 변경됩니다.\n"
|
|
||||||
f"`chat.tools.autoApprove = {enabled}`\n"
|
|
||||||
f"`chat.agent.autoApprove = {enabled}`",
|
|
||||||
color=discord.Color.green() if enabled else discord.Color.red(),
|
|
||||||
)
|
|
||||||
await message.channel.send(embed=embed)
|
|
||||||
else:
|
|
||||||
await message.reply("⚠️ 채널에 연결된 세션이 없습니다.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# General text relay
|
# Special command: !auto on/off
|
||||||
if conv_id and text:
|
if text in ("!auto on", "!auto off"):
|
||||||
self.bridge.write_command(conv_id, text)
|
self.bridge.write_command(project, text, project_name=project)
|
||||||
|
enabled = text == "!auto on"
|
||||||
|
emoji = "🟢" if enabled else "🔴"
|
||||||
|
mode = "자동 승인" if enabled else "수동 승인"
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"{emoji} {mode} 모드",
|
||||||
|
description=f"프로젝트: **{project}**\n"
|
||||||
|
f"`chat.tools.autoApprove = {enabled}`\n"
|
||||||
|
f"`chat.agent.autoApprove = {enabled}`",
|
||||||
|
color=discord.Color.green() if enabled else discord.Color.red(),
|
||||||
|
)
|
||||||
|
await message.channel.send(embed=embed)
|
||||||
|
return
|
||||||
|
|
||||||
|
# General text relay — routed by project
|
||||||
|
if text:
|
||||||
|
self.bridge.write_command(project, text, project_name=project)
|
||||||
await message.add_reaction("📨")
|
await message.add_reaction("📨")
|
||||||
|
embed = discord.Embed(
|
||||||
|
description=f"📨 → **{project}** IDE에 전달됨\n`{text[:100]}`",
|
||||||
|
color=discord.Color.blurple(),
|
||||||
|
)
|
||||||
|
await message.channel.send(embed=embed, delete_after=10)
|
||||||
|
|
||||||
await self.process_commands(message)
|
await self.process_commands(message)
|
||||||
|
|
||||||
|
# ─── Chat Snapshot Scanner ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@tasks.loop(seconds=5)
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
if content:
|
||||||
|
channel = await self._get_channel(project)
|
||||||
|
if channel:
|
||||||
|
# Split long content
|
||||||
|
CHUNK = 4000
|
||||||
|
chunks = [content[i:i+CHUNK] for i in range(0, len(content), CHUNK)]
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
title = "💬 AI 대화 내용" if i == 0 else f"💬 (계속 {i+1}/{len(chunks)})"
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=title,
|
||||||
|
description=chunk,
|
||||||
|
color=discord.Color.purple(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
|
f.unlink() # Cleanup
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning(f"Bad chat snapshot {f.name}: {e}")
|
||||||
|
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()
|
||||||
|
|||||||
13
bridge.py
13
bridge.py
@@ -43,6 +43,7 @@ class ApprovalRequest:
|
|||||||
timestamp: float
|
timestamp: float
|
||||||
status: str = "pending"
|
status: str = "pending"
|
||||||
discord_message_id: int = 0
|
discord_message_id: int = 0
|
||||||
|
project_name: str = "" # Project routing key
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -72,11 +73,14 @@ class BridgeProtocol:
|
|||||||
def get_pending_requests(self) -> list[ApprovalRequest]:
|
def get_pending_requests(self) -> list[ApprovalRequest]:
|
||||||
"""Read all pending approval requests."""
|
"""Read all pending approval requests."""
|
||||||
requests = []
|
requests = []
|
||||||
|
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
|
||||||
for f in self.pending_dir.glob("*.json"):
|
for f in self.pending_dir.glob("*.json"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(f.read_text(encoding="utf-8"))
|
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||||
if data.get("status") == "pending":
|
if data.get("status") == "pending":
|
||||||
requests.append(ApprovalRequest(**data))
|
# Filter to known fields only
|
||||||
|
filtered = {k: v for k, v in data.items() if k in fields}
|
||||||
|
requests.append(ApprovalRequest(**filtered))
|
||||||
except (json.JSONDecodeError, TypeError, OSError) as e:
|
except (json.JSONDecodeError, TypeError, OSError) as e:
|
||||||
logger.warning(f"Bad pending request {f.name}: {e}")
|
logger.warning(f"Bad pending request {f.name}: {e}")
|
||||||
return requests
|
return requests
|
||||||
@@ -106,7 +110,7 @@ class BridgeProtocol:
|
|||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def write_command(self, conversation_id: str, text: str):
|
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
|
||||||
"""Write a user text command for Antigravity to consume."""
|
"""Write a user text command for Antigravity to consume."""
|
||||||
cmd_id = f"{int(time.time() * 1000)}"
|
cmd_id = f"{int(time.time() * 1000)}"
|
||||||
filepath = self.commands_dir / f"{cmd_id}.json"
|
filepath = self.commands_dir / f"{cmd_id}.json"
|
||||||
@@ -114,6 +118,7 @@ class BridgeProtocol:
|
|||||||
data = {
|
data = {
|
||||||
"id": cmd_id,
|
"id": cmd_id,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
|
"project_name": project_name,
|
||||||
"text": text,
|
"text": text,
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"consumed": False,
|
"consumed": False,
|
||||||
@@ -123,5 +128,5 @@ class BridgeProtocol:
|
|||||||
json.dumps(data, ensure_ascii=False, indent=2),
|
json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
logger.info(f"Command written: {cmd_id} for {conversation_id[:8]}")
|
logger.info(f"Command written: {cmd_id} → project={project_name}")
|
||||||
return cmd_id
|
return cmd_id
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Config:
|
|||||||
|
|
||||||
# Channel naming
|
# Channel naming
|
||||||
CHANNEL_PREFIX: str = "AG"
|
CHANNEL_PREFIX: str = "AG"
|
||||||
|
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "gravity_control")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls) -> list[str]:
|
def validate(cls) -> list[str]:
|
||||||
|
|||||||
234
docs/antigravity-commands.md
Normal file
234
docs/antigravity-commands.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# Antigravity IDE Commands Reference
|
||||||
|
|
||||||
|
> Verified from running Antigravity IDE (v2026-03) via `vscode.commands.getCommands(true)`
|
||||||
|
> Total: **171 antigravity commands** + **6 chat commands**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Chat / Agent Communication
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.sendPromptToAgentPanel` | **Send prompt directly to agent panel** |
|
||||||
|
| `antigravity.sendChatActionMessage` | Send a chat action message |
|
||||||
|
| `antigravity.sendTextToChat` | Send text to chat (may appear as @tagged) |
|
||||||
|
| `antigravity.sendTerminalToSidePanel` | Send terminal content to side panel |
|
||||||
|
| `antigravity.startNewConversation` | Start a new conversation |
|
||||||
|
| `antigravity.setVisibleConversation` | Set the visible conversation |
|
||||||
|
| `antigravity.openConversationPicker` | Open conversation picker |
|
||||||
|
| `antigravity.openConversationWorkspaceQuickPick` | Open conversation workspace quick pick |
|
||||||
|
| `antigravity.toggleChatFocus` | Toggle chat focus |
|
||||||
|
| `antigravity.switchBetweenWorkspaceAndAgent` | Switch between workspace and agent |
|
||||||
|
| `antigravity.prioritized.chat.openNewConversation` | Open new conversation (prioritized) |
|
||||||
|
|
||||||
|
## 🖥️ Agent Panel
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.agentPanel.open` | Open agent panel |
|
||||||
|
| `antigravity.agentPanel.focus` | Focus agent panel |
|
||||||
|
| `antigravity.agentPanel.expandView` | Expand agent panel view |
|
||||||
|
| `antigravity.agentPanel.resetViewLocation` | Reset panel view location |
|
||||||
|
| `antigravity.agentSidePanel.open` | Open agent side panel |
|
||||||
|
| `antigravity.agentSidePanel.focus` | Focus agent side panel |
|
||||||
|
| `antigravity.agentSidePanel.expandView` | Expand side panel view |
|
||||||
|
| `antigravity.agentSidePanel.removeView` | Remove side panel view |
|
||||||
|
| `antigravity.agentSidePanel.resetViewLocation` | Reset side panel location |
|
||||||
|
| `antigravity.agentSidePanel.toggleVisibility` | Toggle side panel visibility |
|
||||||
|
| `antigravity.openAgent` | Open agent |
|
||||||
|
| `antigravity.initializeAgent` | Initialize agent |
|
||||||
|
|
||||||
|
## ✅ Agent Step Control
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.agent.acceptAgentStep` | Accept agent step |
|
||||||
|
| `antigravity.agent.rejectAgentStep` | Reject agent step |
|
||||||
|
| `antigravity.command.accept` | Accept command |
|
||||||
|
| `antigravity.command.reject` | Reject command |
|
||||||
|
| `antigravity.terminalCommand.accept` | Accept terminal command |
|
||||||
|
| `antigravity.terminalCommand.reject` | Reject terminal command |
|
||||||
|
| `antigravity.terminalCommand.run` | Run terminal command |
|
||||||
|
| `antigravity.acceptCompletion` | Accept completion |
|
||||||
|
| `antigravity.prioritized.agentAcceptAllInFile` | Accept all changes in file |
|
||||||
|
| `antigravity.prioritized.agentAcceptFocusedHunk` | Accept focused hunk |
|
||||||
|
| `antigravity.prioritized.agentFocusNextFile` | Focus next file |
|
||||||
|
| `antigravity.prioritized.agentFocusNextHunk` | Focus next hunk |
|
||||||
|
| `antigravity.prioritized.agentFocusPreviousFile` | Focus previous file |
|
||||||
|
| `antigravity.prioritized.agentFocusPreviousHunk` | Focus previous hunk |
|
||||||
|
| `antigravity.prioritized.agentRejectAllInFile` | Reject all changes in file |
|
||||||
|
| `antigravity.prioritized.agentRejectFocusedHunk` | Reject focused hunk |
|
||||||
|
| `antigravity.prioritized.supercompleteAccept` | Supercomplete accept |
|
||||||
|
| `antigravity.prioritized.supercompleteEscape` | Supercomplete escape |
|
||||||
|
|
||||||
|
## 📝 Diff / Editor
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.closeAllDiffZones` | Close all diff zones |
|
||||||
|
| `antigravity.handleDiffZoneEdit` | Handle diff zone edit |
|
||||||
|
| `antigravity.openDiffZones` | Open diff zones |
|
||||||
|
| `antigravity.setDiffZonesState` | Set diff zones state |
|
||||||
|
| `antigravity.sidecar.sendDiffZone` | Send diff zone via sidecar |
|
||||||
|
| `antigravity.openReviewChanges` | Open review changes |
|
||||||
|
|
||||||
|
## 🖥️ Terminal
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.showManagedTerminal` | Show managed terminal |
|
||||||
|
| `antigravity.onManagerTerminalCommandData` | Terminal command data event |
|
||||||
|
| `antigravity.onManagerTerminalCommandFinish` | Terminal command finish event |
|
||||||
|
| `antigravity.onManagerTerminalCommandStart` | Terminal command start event |
|
||||||
|
| `antigravity.onShellCommandCompletion` | Shell command completion event |
|
||||||
|
| `antigravity.updateTerminalLastCommand` | Update terminal last command |
|
||||||
|
| `antigravity.prioritized.terminalCommand.open` | Open terminal command |
|
||||||
|
| `antigravity.prioritized.command.open` | Open command (prioritized) |
|
||||||
|
|
||||||
|
## 🔧 System / Config
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.getDiagnostics` | Get diagnostics |
|
||||||
|
| `antigravity.downloadDiagnostics` | Download diagnostics |
|
||||||
|
| `antigravity.getManagerTrace` | Get manager trace |
|
||||||
|
| `antigravity.getWorkbenchTrace` | Get workbench trace |
|
||||||
|
| `antigravity.enableTracing` | Enable tracing |
|
||||||
|
| `antigravity.clearAndDisableTracing` | Clear and disable tracing |
|
||||||
|
| `antigravity.captureTraces` | Capture traces |
|
||||||
|
| `antigravity.reloadWindow` | Reload window |
|
||||||
|
| `antigravity.restartLanguageServer` | Restart language server |
|
||||||
|
| `antigravity.killLanguageServerAndReloadWindow` | Kill LS and reload window |
|
||||||
|
| `antigravity.killRemoteExtensionHost` | Kill remote extension host |
|
||||||
|
| `antigravity.restartUserStatusUpdater` | Restart user status updater |
|
||||||
|
| `antigravity.handleAuthRefresh` | Handle auth refresh |
|
||||||
|
| `antigravity.cancelLogin` | Cancel login |
|
||||||
|
| `antigravity.editorModeSettings` | Editor mode settings |
|
||||||
|
| `antigravity.setWorkingDirectories` | Set working directories |
|
||||||
|
| `antigravity.simulateSegFault` | Simulate segfault (debug) |
|
||||||
|
|
||||||
|
## 🌐 Browser / MCP
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.openBrowser` | Open browser |
|
||||||
|
| `antigravity.getBrowserOnboardingPort` | Get browser onboarding port |
|
||||||
|
| `antigravity.getChromeDevtoolsMcpUrl` | Get Chrome DevTools MCP URL |
|
||||||
|
| `antigravity.openMcpConfigFile` | Open MCP config file |
|
||||||
|
| `antigravity.openMcpDocsPage` | Open MCP docs page |
|
||||||
|
| `antigravity.pollMcpServerStates` | Poll MCP server states |
|
||||||
|
| `antigravity.showBrowserAllowList` | Show browser allow list |
|
||||||
|
|
||||||
|
## 📦 Workflow / Rules / Artifacts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.createGlobalWorkflow` | Create global workflow |
|
||||||
|
| `antigravity.createRule` | Create rule |
|
||||||
|
| `antigravity.createWorkflow` | Create workflow |
|
||||||
|
| `antigravity.artifacts.startComment` | Start artifact comment |
|
||||||
|
| `antigravity.getCascadePluginTemplate` | Get cascade plugin template |
|
||||||
|
| `antigravity.executeCascadeAction` | Execute cascade action |
|
||||||
|
| `antigravity.explainAndFixProblem` | Explain and fix problem |
|
||||||
|
| `antigravity.prioritized.explainProblem` | Explain problem (prioritized) |
|
||||||
|
|
||||||
|
## 📊 Reporting / Analytics
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.generateCommitMessage` | Generate commit message |
|
||||||
|
| `antigravity.cancelGenerateCommitMessage` | Cancel commit message generation |
|
||||||
|
| `antigravity.sendAnalyticsAction` | Send analytics action |
|
||||||
|
| `antigravity.logObservabilityDataAction` | Log observability data |
|
||||||
|
| `antigravity.tabReporting` | Tab reporting |
|
||||||
|
| `antigravity.trackBackgroundConversationCreated` | Track background conversation |
|
||||||
|
| `antigravity.uploadErrorAction` | Upload error action |
|
||||||
|
| `antigravityAgentManager.clearErrors` | Clear agent manager errors |
|
||||||
|
| `antigravityAgentManager.reportError` | Report error |
|
||||||
|
| `antigravityAgentManager.reportNotification` | Report notification |
|
||||||
|
| `antigravityAgentManager.reportStatus` | Report status |
|
||||||
|
|
||||||
|
## 🎨 UI / Navigation
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.openDocs` | Open docs |
|
||||||
|
| `antigravity.openChangeLog` | Open changelog |
|
||||||
|
| `antigravity.openCustomizationsTab` | Open customizations tab |
|
||||||
|
| `antigravity.openConfigurePluginsPage` | Open configure plugins page |
|
||||||
|
| `antigravity.openGenericUrl` | Open generic URL |
|
||||||
|
| `antigravity.openGlobalRules` | Open global rules |
|
||||||
|
| `antigravity.openInteractiveEditor` | Open interactive editor |
|
||||||
|
| `antigravity.openIssueReporter` | Open issue reporter |
|
||||||
|
| `antigravity.openQuickSettingsPanel` | Open quick settings panel |
|
||||||
|
| `antigravity.openRulesEducationalLink` | Open rules educational link |
|
||||||
|
| `antigravity.openTroubleshooting` | Open troubleshooting |
|
||||||
|
| `antigravity.openInCiderAction.topBar` | Open in Cider action |
|
||||||
|
| `antigravity.customizeAppIcon` | Customize app icon |
|
||||||
|
| `antigravity.hideFullScreenView` | Hide full screen view |
|
||||||
|
|
||||||
|
## 🔌 Import / Migration
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.importCiderSettings` | Import Cider settings |
|
||||||
|
| `antigravity.importCursorExtensions` | Import Cursor extensions |
|
||||||
|
| `antigravity.importCursorSettings` | Import Cursor settings |
|
||||||
|
| `antigravity.importVSCodeExtensions` | Import VS Code extensions |
|
||||||
|
| `antigravity.importVSCodeRecentWorkspaces` | Import VS Code workspaces |
|
||||||
|
| `antigravity.importVSCodeSettings` | Import VS Code settings |
|
||||||
|
| `antigravity.importWindsurfExtensions` | Import Windsurf extensions |
|
||||||
|
| `antigravity.importWindsurfSettings` | Import Windsurf settings |
|
||||||
|
| `antigravity.migrateWindsurfSettings` | Migrate Windsurf settings |
|
||||||
|
|
||||||
|
## 🐳 Dev Containers / SSH / WSL
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity-dev-containers.attachToRunningContainer` | Attach to running container |
|
||||||
|
| `antigravity-dev-containers.openInContainer` | Open in container |
|
||||||
|
| `antigravity-dev-containers.reopenFolderLocally` | Reopen folder locally |
|
||||||
|
| `antigravity-dev-containers.reopenInContainer` | Reopen in container |
|
||||||
|
| `antigravity-dev-containers.showLog` | Show container log |
|
||||||
|
| `antigravitySSHHosts.*` | SSH host management |
|
||||||
|
| `antigravityWslTargets.*` | WSL target management |
|
||||||
|
|
||||||
|
## 🔊 Audio / Demo
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.playAudio` | Play audio |
|
||||||
|
| `antigravity.playNote` | Play note |
|
||||||
|
| `antigravity.startDemoMode` | Start demo mode |
|
||||||
|
| `antigravity.endDemoMode` | End demo mode |
|
||||||
|
|
||||||
|
## 🔍 Debug
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `antigravity.toggleDebugInfoWidget` | Toggle debug info widget |
|
||||||
|
| `antigravity.updateDebugInfoWidget` | Update debug info widget |
|
||||||
|
| `antigravity.toggleManagerDevTools` | Toggle manager dev tools |
|
||||||
|
| `antigravity.toggleSettingsDevTools` | Toggle settings dev tools |
|
||||||
|
| `antigravity.toggleNewConvoStreamFormat` | Toggle new convo stream format |
|
||||||
|
| `antigravity.togglePersistentLanguageServer` | Toggle persistent LS |
|
||||||
|
| `antigravity.toggleRerenderFrequencyAlerts` | Toggle rerender frequency alerts |
|
||||||
|
|
||||||
|
## 💬 Chat Commands (Non-Antigravity)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `chat.inlineResourceAnchor.openToSide` | Open inline resource |
|
||||||
|
| `chat.openFileSnapshot` | Open file snapshot |
|
||||||
|
| `chat.openFileUpdatedBySnapshot` | Open file updated by snapshot |
|
||||||
|
| `workbench.action.chat.openStorageFolder` | Open chat storage folder |
|
||||||
|
| `workbench.action.terminal.chat.openTerminalSettingsLink` | Open terminal chat settings |
|
||||||
|
|
||||||
|
## 🔗 Git (Antigravity)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `git.antigravityClearCloneProgress` | Clear clone progress |
|
||||||
|
| `git.antigravityCloneNonInteractive` | Clone non-interactive |
|
||||||
|
| `git.antigravityGetRemoteUrl` | Get remote URL |
|
||||||
|
| `git.antigravityReportCloneProgress` | Report clone progress |
|
||||||
@@ -10,3 +10,7 @@
|
|||||||
| 6 | 02:25 | Extension 스캐폴드 + VSIX 빌드 | `52fed8c`~`1af5fb7` | ✅ |
|
| 6 | 02:25 | Extension 스캐폴드 + VSIX 빌드 | `52fed8c`~`1af5fb7` | ✅ |
|
||||||
| 7 | 11:40 | 전면 재설계 (채널 스팸/중복 해결) | `e32be6b`~`51ece61` | ✅ |
|
| 7 | 11:40 | 전면 재설계 (채널 스팸/중복 해결) | `e32be6b`~`51ece61` | ✅ |
|
||||||
| 8 | 12:17 | 승인 중복 전송 수정 + E2E 테스트 통과 | `ce2336c` | ✅ |
|
| 8 | 12:17 | 승인 중복 전송 수정 + E2E 테스트 통과 | `ce2336c` | ✅ |
|
||||||
|
| 9 | 13:00 | 채팅 제출 탐색: clipboard paste + Enter 시뮬레이션 | `7f15e98`~`35f39ab` | 🔧 |
|
||||||
|
| 10 | 15:00 | @bridge→@gravity 이름 변경 + 슬래시 명령어 /stop /auto /send | `02e9e4d`~`0bd525a` | ✅ |
|
||||||
|
| 11 | 16:00 | sendTextToChat 탐색 → sendPromptToAgentPanel 발견 | `e4eb756`~`8d5e59c` | ✅ |
|
||||||
|
| 12 | 17:15 | 양방향 통신 완성 + 171개 명령어 문서화 + scanner 시작 수정 | `befa5d7` | ✅ |
|
||||||
|
|||||||
27
docs/devlog/entries/20260307-002.md
Normal file
27
docs/devlog/entries/20260307-002.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Discord ↔ Antigravity 양방향 채팅 통합
|
||||||
|
|
||||||
|
- **시간**: 2026-03-07 13:00~17:15
|
||||||
|
- **Commit**: `7f15e98`~`befa5d7` (15 commits)
|
||||||
|
- **Vikunja**: #223 → 진행중
|
||||||
|
|
||||||
|
## 결정 사항
|
||||||
|
|
||||||
|
### sendPromptToAgentPanel 발견 경위
|
||||||
|
- clipboard paste → Enter 시뮬레이션 (`\r`, `default:Enter`, `\u000d`) → 전부 실패
|
||||||
|
- `antigravity.sendTextToChat(true/false)` → 에러 없이 실행되지만 텍스트 표시 안 됨
|
||||||
|
- `vscode.commands.getCommands(true)` 로 171개 내부 명령어 전수 조사
|
||||||
|
- **`antigravity.sendPromptToAgentPanel`** 발견 → 성공
|
||||||
|
|
||||||
|
### Chat Snapshot 파이프라인 (응답 릴레이)
|
||||||
|
- Extension `onDidChangeTextDocument` → chat scheme 필터 → 2초 debounce → `bridge/chat_snapshots/`
|
||||||
|
- Bot `chat_snapshot_scanner` @tasks.loop(5초) → Discord embed 전송
|
||||||
|
- **`.start()` 누락 발견** — 두 scanner 모두 정의만 되고 시작 안 됐음
|
||||||
|
|
||||||
|
### SDK (antigravity-sdk)
|
||||||
|
- npm 패키지 존재, README에 `sendPrompt`, `sendMessage` 등 광고
|
||||||
|
- 실제 JS 코드에 해당 함수 미구현 (커뮤니티 프로젝트, vaporware)
|
||||||
|
- `state.vscdb` 읽기 + `vscode.commands.executeCommand` 래핑만 구현됨
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
- 응답 릴레이(Antigravity→Discord) 테스트 중
|
||||||
|
- listener leak (351개 누적) — Extension 재로드 시 dispose 문제
|
||||||
Binary file not shown.
BIN
extension/gravity-bridge-0.2.0.vsix
Normal file
BIN
extension/gravity-bridge-0.2.0.vsix
Normal file
Binary file not shown.
@@ -4,13 +4,10 @@
|
|||||||
*
|
*
|
||||||
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
|
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Multi-project routing:
|
||||||
* 1. Extension watches for tool approval notifications in VS Code
|
* - Each workspace has a project name (from settings or workspace folder name)
|
||||||
* 2. Writes pending approval to bridge/pending/
|
* - Extension only processes commands/responses matching its project_name
|
||||||
* 3. Discord bot sends buttons to user
|
* - Pending approvals include project_name for Discord channel routing
|
||||||
* 4. User clicks approve/reject
|
|
||||||
* 5. Bot writes response to bridge/response/
|
|
||||||
* 6. Extension reads response → sends keyboard command to approve/reject
|
|
||||||
*/
|
*/
|
||||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
if (k2 === undefined) k2 = k;
|
if (k2 === undefined) k2 = k;
|
||||||
@@ -54,19 +51,58 @@ const fs = __importStar(require("fs"));
|
|||||||
const path = __importStar(require("path"));
|
const path = __importStar(require("path"));
|
||||||
const os = __importStar(require("os"));
|
const os = __importStar(require("os"));
|
||||||
let watcher = null;
|
let watcher = null;
|
||||||
|
let commandsWatcher = null;
|
||||||
let statusBar;
|
let statusBar;
|
||||||
let bridgePath;
|
let bridgePath;
|
||||||
|
let projectName;
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
// Track pending approvals we've already sent
|
// Track pending approvals we've already sent
|
||||||
const sentPendingIds = new Set();
|
const sentPendingIds = new Set();
|
||||||
|
const cp = __importStar(require("child_process"));
|
||||||
|
/**
|
||||||
|
* Detect project name from workspace.
|
||||||
|
* Priority: settings > git remote repo name > workspace folder name
|
||||||
|
*/
|
||||||
|
function detectProjectName() {
|
||||||
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
||||||
|
const configName = config.get('projectName');
|
||||||
|
if (configName) {
|
||||||
|
return configName;
|
||||||
|
}
|
||||||
|
// Try git remote URL → extract repo name
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
if (folders && folders.length > 0) {
|
||||||
|
const cwd = folders[0].uri.fsPath;
|
||||||
|
try {
|
||||||
|
const remoteUrl = cp.execSync('git remote get-url origin', {
|
||||||
|
cwd, encoding: 'utf-8', timeout: 3000
|
||||||
|
}).trim();
|
||||||
|
// "https://gitea.example.com/Variet/gravity_control.git" → "gravity_control"
|
||||||
|
// "git@github.com:user/repo.git" → "repo"
|
||||||
|
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const repoName = match[1].toLowerCase().replace(/[\s\-]+/g, '_');
|
||||||
|
console.log(`Gravity Bridge: project from git remote → "${repoName}"`);
|
||||||
|
return repoName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// No git or no remote — fall through
|
||||||
|
}
|
||||||
|
// Fallback: workspace folder name
|
||||||
|
return folders[0].name.toLowerCase().replace(/[\s\-]+/g, '_');
|
||||||
|
}
|
||||||
|
return 'unknown_project';
|
||||||
|
}
|
||||||
function activate(context) {
|
function activate(context) {
|
||||||
console.log('Gravity Bridge: activating...');
|
projectName = detectProjectName();
|
||||||
|
console.log(`Gravity Bridge: activating for project "${projectName}"...`);
|
||||||
// Determine bridge path
|
// Determine bridge path
|
||||||
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');
|
||||||
// Ensure bridge directories exist
|
// Ensure bridge directories exist
|
||||||
const dirs = ['pending', 'response', 'commands'];
|
const dirs = ['pending', 'response', 'commands', 'register'];
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const dirPath = path.join(bridgePath, dir);
|
const dirPath = path.join(bridgePath, dir);
|
||||||
if (!fs.existsSync(dirPath)) {
|
if (!fs.existsSync(dirPath)) {
|
||||||
@@ -76,29 +112,48 @@ function activate(context) {
|
|||||||
// Status bar
|
// Status bar
|
||||||
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||||
statusBar.command = 'gravityBridge.start';
|
statusBar.command = 'gravityBridge.start';
|
||||||
statusBar.text = '$(radio-tower) Bridge: Off';
|
statusBar.text = `$(radio-tower) ${projectName}: Off`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Click to start';
|
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
|
||||||
statusBar.show();
|
statusBar.show();
|
||||||
context.subscriptions.push(statusBar);
|
context.subscriptions.push(statusBar);
|
||||||
// Register commands
|
// Register commands
|
||||||
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', startBridge), vscode.commands.registerCommand('gravityBridge.stop', stopBridge), vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)), vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)));
|
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', startBridge), vscode.commands.registerCommand('gravityBridge.stop', stopBridge), vscode.commands.registerCommand('gravityBridge.connect', connectSession), vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)), vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)));
|
||||||
|
// Chat document change listener — captures AI text responses
|
||||||
|
context.subscriptions.push(vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
|
handleChatDocumentChange(event);
|
||||||
|
}));
|
||||||
|
// Register @bridge Chat Participant for history relay
|
||||||
|
try {
|
||||||
|
const participant = vscode.chat.createChatParticipant('gravity-bridge.gravity', bridgeChatHandler);
|
||||||
|
participant.iconPath = new vscode.ThemeIcon('radio-tower');
|
||||||
|
context.subscriptions.push(participant);
|
||||||
|
console.log('Gravity Bridge: @bridge chat participant registered');
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.log('Gravity Bridge: chat participant API not available (OK)');
|
||||||
|
}
|
||||||
|
// Auto-watch brain/ for new conversations → auto-register
|
||||||
|
watchBrainForNewSessions();
|
||||||
// Auto-start
|
// Auto-start
|
||||||
startBridge();
|
startBridge();
|
||||||
}
|
}
|
||||||
function startBridge() {
|
function startBridge() {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
vscode.window.showInformationMessage('Gravity Bridge is already running');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): already running`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isActive = true;
|
isActive = true;
|
||||||
statusBar.text = '$(radio-tower) Bridge: On';
|
statusBar.text = `$(radio-tower) ${projectName}: On`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Active';
|
statusBar.tooltip = `Gravity Bridge — ${projectName} (Active)`;
|
||||||
statusBar.command = 'gravityBridge.stop';
|
statusBar.command = 'gravityBridge.stop';
|
||||||
// Watch bridge/response/ for Discord user responses
|
// Watch bridge/response/ for Discord user responses
|
||||||
const responsePath = path.join(bridgePath, 'response');
|
const responsePath = path.join(bridgePath, 'response');
|
||||||
|
const processedFiles = new Set(); // Debounce
|
||||||
try {
|
try {
|
||||||
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
|
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
|
||||||
if (eventType === 'rename' && filename && filename.endsWith('.json')) {
|
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
||||||
|
processedFiles.add(filename);
|
||||||
|
setTimeout(() => processedFiles.delete(filename), 2000);
|
||||||
handleResponse(path.join(responsePath, filename));
|
handleResponse(path.join(responsePath, filename));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -110,8 +165,10 @@ function startBridge() {
|
|||||||
// Watch for commands (user text input from Discord)
|
// Watch for commands (user text input from Discord)
|
||||||
const commandsPath = path.join(bridgePath, 'commands');
|
const commandsPath = path.join(bridgePath, 'commands');
|
||||||
try {
|
try {
|
||||||
fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
|
commandsWatcher = fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
|
||||||
if (eventType === 'rename' && filename && filename.endsWith('.json')) {
|
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
||||||
|
processedFiles.add(filename);
|
||||||
|
setTimeout(() => processedFiles.delete(filename), 2000);
|
||||||
handleCommand(path.join(commandsPath, filename));
|
handleCommand(path.join(commandsPath, filename));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -120,30 +177,33 @@ function startBridge() {
|
|||||||
catch (err) {
|
catch (err) {
|
||||||
console.error('Gravity Bridge: failed to watch commands dir', err);
|
console.error('Gravity Bridge: failed to watch commands dir', err);
|
||||||
}
|
}
|
||||||
vscode.window.showInformationMessage('Gravity Bridge: Started');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Started`);
|
||||||
console.log(`Gravity Bridge: started, bridge path: ${bridgePath}`);
|
console.log(`Gravity Bridge: started for project "${projectName}", bridge: ${bridgePath}`);
|
||||||
}
|
}
|
||||||
function stopBridge() {
|
function stopBridge() {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isActive = false;
|
isActive = false;
|
||||||
statusBar.text = '$(radio-tower) Bridge: Off';
|
statusBar.text = `$(radio-tower) ${projectName}: Off`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Click to start';
|
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
|
||||||
statusBar.command = 'gravityBridge.start';
|
statusBar.command = 'gravityBridge.start';
|
||||||
if (watcher) {
|
if (watcher) {
|
||||||
watcher.close();
|
watcher.close();
|
||||||
watcher = null;
|
watcher = null;
|
||||||
}
|
}
|
||||||
vscode.window.showInformationMessage('Gravity Bridge: Stopped');
|
if (commandsWatcher) {
|
||||||
|
commandsWatcher.close();
|
||||||
|
commandsWatcher = null;
|
||||||
|
}
|
||||||
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Stopped`);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Handle a response from Discord (approve/reject).
|
* Handle a response from Discord (approve/reject).
|
||||||
* Reads the response JSON and simulates the appropriate action.
|
* Only processes responses — no project filtering needed since request_id is unique.
|
||||||
*/
|
*/
|
||||||
async function handleResponse(filePath) {
|
async function handleResponse(filePath) {
|
||||||
try {
|
try {
|
||||||
// Small delay to ensure file is fully written
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return;
|
return;
|
||||||
@@ -153,10 +213,8 @@ async function handleResponse(filePath) {
|
|||||||
if (response.approved === undefined) {
|
if (response.approved === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`Gravity Bridge: response received — approved=${response.approved}`);
|
console.log(`Gravity Bridge [${projectName}]: response — approved=${response.approved}`);
|
||||||
if (response.approved) {
|
if (response.approved) {
|
||||||
// Simulate pressing Enter or clicking approve
|
|
||||||
// Strategy: Use VS Code command to accept suggestion
|
|
||||||
await simulateApproval();
|
await simulateApproval();
|
||||||
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
|
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
|
||||||
}
|
}
|
||||||
@@ -164,7 +222,6 @@ async function handleResponse(filePath) {
|
|||||||
await simulateRejection();
|
await simulateRejection();
|
||||||
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
|
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
|
||||||
}
|
}
|
||||||
// Cleanup: delete the response file after processing
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
}
|
}
|
||||||
@@ -176,7 +233,7 @@ async function handleResponse(filePath) {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Handle a text command from Discord.
|
* Handle a text command from Discord.
|
||||||
* Types the text into the active editor or chat input.
|
* ONLY processes commands matching this project's name.
|
||||||
*/
|
*/
|
||||||
async function handleCommand(filePath) {
|
async function handleCommand(filePath) {
|
||||||
try {
|
try {
|
||||||
@@ -189,50 +246,118 @@ async function handleCommand(filePath) {
|
|||||||
if (command.consumed || !command.text) {
|
if (command.consumed || !command.text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`Gravity Bridge: command received — "${command.text.substring(0, 50)}..."`);
|
// ★ PROJECT FILTER — only process commands for THIS project
|
||||||
// Type into the active text input (chat panel)
|
const cmdProject = command.project_name || '';
|
||||||
await vscode.commands.executeCommand('workbench.action.chat.open');
|
if (cmdProject && cmdProject !== projectName) {
|
||||||
// Small delay for chat panel to open
|
console.log(`Gravity Bridge [${projectName}]: skipping command for "${cmdProject}"`);
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
return; // Not for us — leave file for the correct Extension instance
|
||||||
// Type the text using clipboard
|
}
|
||||||
const oldClipboard = await vscode.env.clipboard.readText();
|
const text = command.text.trim();
|
||||||
await vscode.env.clipboard.writeText(command.text);
|
console.log(`Gravity Bridge [${projectName}]: command — "${text.substring(0, 50)}"`);
|
||||||
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
|
// Special command: !stop — cancel AI work
|
||||||
await vscode.env.clipboard.writeText(oldClipboard);
|
if (text === '!stop') {
|
||||||
// Mark as consumed
|
try {
|
||||||
|
await vscode.commands.executeCommand('workbench.action.chat.stop');
|
||||||
|
vscode.window.showWarningMessage(`⏹️ [${projectName}] AI 작업 중지됨`);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
vscode.window.showErrorMessage('AI 중지 명령 실행 실패');
|
||||||
|
}
|
||||||
|
command.consumed = true;
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Special command: auto-approve toggle
|
||||||
|
if (text === '!auto on' || text === '!auto off') {
|
||||||
|
const enabled = text === '!auto on';
|
||||||
|
await toggleAutoApprove(enabled);
|
||||||
|
command.consumed = true;
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// General text: send directly to Antigravity agent panel
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', command.text);
|
||||||
|
console.log(`Gravity Bridge: ✅ sent via sendPromptToAgentPanel`);
|
||||||
|
}
|
||||||
|
catch (e1) {
|
||||||
|
console.log(`Gravity Bridge: sendPromptToAgentPanel failed: ${e1}`);
|
||||||
|
// Fallback: try sendChatActionMessage
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('antigravity.sendChatActionMessage', command.text);
|
||||||
|
console.log(`Gravity Bridge: ✅ sent via sendChatActionMessage`);
|
||||||
|
}
|
||||||
|
catch (e2) {
|
||||||
|
console.log(`Gravity Bridge: sendChatActionMessage failed: ${e2}`);
|
||||||
|
// Last resort: focus panel + clipboard paste
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
const oldClip = await vscode.env.clipboard.readText();
|
||||||
|
await vscode.env.clipboard.writeText(command.text);
|
||||||
|
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
|
||||||
|
await vscode.env.clipboard.writeText(oldClip);
|
||||||
|
console.log('Gravity Bridge: clipboard paste fallback');
|
||||||
|
}
|
||||||
|
catch (e3) {
|
||||||
|
console.error('Gravity Bridge: all methods failed', e3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always mark as consumed
|
||||||
command.consumed = true;
|
command.consumed = true;
|
||||||
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
vscode.window.showInformationMessage(`📨 Discord input: ${command.text.substring(0, 50)}...`);
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error('Gravity Bridge: error handling command', err);
|
console.error('Gravity Bridge: error handling command', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Simulate approval — try multiple strategies.
|
* Toggle Antigravity's auto-approve settings.
|
||||||
*/
|
*/
|
||||||
|
async function toggleAutoApprove(enabled) {
|
||||||
|
const config = vscode.workspace.getConfiguration();
|
||||||
|
try {
|
||||||
|
await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
||||||
|
await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
||||||
|
if (enabled) {
|
||||||
|
await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global);
|
||||||
|
}
|
||||||
|
await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global);
|
||||||
|
statusBar.text = enabled
|
||||||
|
? `$(radio-tower) ${projectName}: Auto ✅`
|
||||||
|
: `$(radio-tower) ${projectName}: Manual 🔒`;
|
||||||
|
const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴';
|
||||||
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): ${mode}`);
|
||||||
|
const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`);
|
||||||
|
fs.writeFileSync(statusPath, JSON.stringify({
|
||||||
|
id: `auto-status-${Date.now()}`,
|
||||||
|
project_name: projectName,
|
||||||
|
text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
consumed: true,
|
||||||
|
auto_approve: enabled,
|
||||||
|
}, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Gravity Bridge: failed to toggle auto-approve', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function simulateApproval() {
|
async function simulateApproval() {
|
||||||
try {
|
try {
|
||||||
// Strategy 1: Try executing the accept command if available
|
|
||||||
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
|
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// Strategy 2: Send Enter key via type command
|
|
||||||
try {
|
try {
|
||||||
await vscode.commands.executeCommand('type', { text: '\n' });
|
await vscode.commands.executeCommand('type', { text: '\n' });
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// Strategy 3: Focus terminal and send Enter
|
|
||||||
await vscode.commands.executeCommand('workbench.action.terminal.focus');
|
await vscode.commands.executeCommand('workbench.action.terminal.focus');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Simulate rejection — try multiple strategies.
|
|
||||||
*/
|
|
||||||
async function simulateRejection() {
|
async function simulateRejection() {
|
||||||
try {
|
try {
|
||||||
// Strategy 1: Escape key
|
|
||||||
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
|
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
@@ -240,14 +365,12 @@ async function simulateRejection() {
|
|||||||
await vscode.commands.executeCommand('cancelSelection');
|
await vscode.commands.executeCommand('cancelSelection');
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// Fallback: just notify
|
|
||||||
console.log('Gravity Bridge: rejection sent but no active dialog found');
|
console.log('Gravity Bridge: rejection sent but no active dialog found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Manual approve/reject from command palette.
|
* Manual approve/reject from command palette.
|
||||||
* Writes a pending request for testing purposes.
|
|
||||||
*/
|
*/
|
||||||
function handleManualAction(approved) {
|
function handleManualAction(approved) {
|
||||||
const requestId = `manual-${Date.now()}`;
|
const requestId = `manual-${Date.now()}`;
|
||||||
@@ -268,6 +391,7 @@ function handleManualAction(approved) {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Write a pending approval request to bridge/pending/ for Discord bot to pick up.
|
* Write a pending approval request to bridge/pending/ for Discord bot to pick up.
|
||||||
|
* Includes project_name for correct channel routing.
|
||||||
*/
|
*/
|
||||||
function writePendingApproval(conversationId, command, description) {
|
function writePendingApproval(conversationId, command, description) {
|
||||||
const requestId = `req-${Date.now()}`;
|
const requestId = `req-${Date.now()}`;
|
||||||
@@ -275,6 +399,7 @@ function writePendingApproval(conversationId, command, description) {
|
|||||||
const request = {
|
const request = {
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
project_name: projectName, // ★ Project routing
|
||||||
command: command,
|
command: command,
|
||||||
description: description,
|
description: description,
|
||||||
timestamp: Date.now() / 1000,
|
timestamp: Date.now() / 1000,
|
||||||
@@ -283,9 +408,249 @@ function writePendingApproval(conversationId, command, description) {
|
|||||||
};
|
};
|
||||||
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
|
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
|
||||||
sentPendingIds.add(requestId);
|
sentPendingIds.add(requestId);
|
||||||
console.log(`Gravity Bridge: pending approval written — ${requestId}`);
|
console.log(`Gravity Bridge [${projectName}]: pending approval — ${requestId}`);
|
||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Register a conversation → project mapping in bridge/register/.
|
||||||
|
* The bot reads these files to route brain events to the correct channel.
|
||||||
|
*/
|
||||||
|
function registerConversation(conversationId) {
|
||||||
|
const registerDir = path.join(bridgePath, 'register');
|
||||||
|
if (!fs.existsSync(registerDir)) {
|
||||||
|
fs.mkdirSync(registerDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const filePath = path.join(registerDir, `${conversationId}.json`);
|
||||||
|
const data = {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
project_name: projectName,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: registered ${conversationId.substring(0, 8)}`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Read the title (first # heading) from a conversation's task.md or implementation_plan.md.
|
||||||
|
*/
|
||||||
|
function getConversationTitle(convDir) {
|
||||||
|
for (const fname of ['task.md', 'implementation_plan.md']) {
|
||||||
|
const fpath = path.join(convDir, fname);
|
||||||
|
if (fs.existsSync(fpath)) {
|
||||||
|
try {
|
||||||
|
const lines = fs.readFileSync(fpath, 'utf-8').split('\n').slice(0, 5);
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^#\s+(.+)/);
|
||||||
|
if (match) {
|
||||||
|
return match[1].trim().substring(0, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Manual connect: scan brain/ for recent conversations and let user pick.
|
||||||
|
* Shows task.md titles for readability. Offers auto-connect for new projects.
|
||||||
|
*/
|
||||||
|
async function connectSession() {
|
||||||
|
const brainPath = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
|
||||||
|
if (!fs.existsSync(brainPath)) {
|
||||||
|
vscode.window.showErrorMessage('Brain 디렉토리를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get conversation dirs sorted by modification time (newest first)
|
||||||
|
const dirs = fs.readdirSync(brainPath)
|
||||||
|
.filter(d => {
|
||||||
|
const fullPath = path.join(brainPath, d);
|
||||||
|
return fs.statSync(fullPath).isDirectory() && d.includes('-');
|
||||||
|
})
|
||||||
|
.map(d => {
|
||||||
|
const fullPath = path.join(brainPath, d);
|
||||||
|
return {
|
||||||
|
name: d,
|
||||||
|
mtime: fs.statSync(fullPath).mtimeMs,
|
||||||
|
title: getConversationTitle(fullPath),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
.slice(0, 10);
|
||||||
|
// Build QuickPick items
|
||||||
|
const items = [];
|
||||||
|
// Always offer auto-connect option first
|
||||||
|
items.push({
|
||||||
|
label: '$(sync) 새 대화 자동 연결',
|
||||||
|
description: '다음에 시작하는 대화가 자동으로 이 프로젝트에 연결됩니다',
|
||||||
|
detail: `프로젝트: ${projectName}`,
|
||||||
|
});
|
||||||
|
// Add conversation items with titles
|
||||||
|
for (const d of dirs) {
|
||||||
|
const titleLabel = d.title || '(제목 없음)';
|
||||||
|
const timeStr = new Date(d.mtime).toLocaleString();
|
||||||
|
items.push({
|
||||||
|
label: `$(comment-discussion) ${titleLabel}`,
|
||||||
|
description: d.name.substring(0, 8),
|
||||||
|
detail: `${d.name} · ${timeStr}`,
|
||||||
|
convId: d.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const selected = await vscode.window.showQuickPick(items, {
|
||||||
|
placeHolder: `프로젝트 "${projectName}"에 연결할 세션을 선택하세요`,
|
||||||
|
});
|
||||||
|
if (!selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!('convId' in selected) || !selected.convId) {
|
||||||
|
// Auto-connect mode
|
||||||
|
vscode.window.showInformationMessage(`🔄 ${projectName}: 다음 대화가 자동으로 연결됩니다`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registerConversation(selected.convId);
|
||||||
|
vscode.window.showInformationMessage(`✅ ${selected.description} → ${projectName} 연결됨`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Auto-watch brain/ for new conversation directories → auto-register.
|
||||||
|
*/
|
||||||
|
function watchBrainForNewSessions() {
|
||||||
|
const brainPath = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
|
||||||
|
if (!fs.existsSync(brainPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Track known dirs
|
||||||
|
const knownDirs = new Set(fs.readdirSync(brainPath).filter(d => fs.statSync(path.join(brainPath, d)).isDirectory()));
|
||||||
|
try {
|
||||||
|
fs.watch(brainPath, { persistent: false }, (eventType, filename) => {
|
||||||
|
if (!filename || !filename.includes('-')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fullPath = path.join(brainPath, filename);
|
||||||
|
// Check if it's a new directory
|
||||||
|
if (!knownDirs.has(filename) && fs.existsSync(fullPath) &&
|
||||||
|
fs.statSync(fullPath).isDirectory()) {
|
||||||
|
knownDirs.add(filename);
|
||||||
|
registerConversation(filename);
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: auto-registered new session ${filename.substring(0, 8)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: watching brain/ for new sessions`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Gravity Bridge: failed to watch brain dir', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Monitor text document changes for chat panel content.
|
||||||
|
* VS Code chat documents have special URI schemes (vscode-chat-response, etc.).
|
||||||
|
* We capture significant changes and relay to Discord.
|
||||||
|
*/
|
||||||
|
let lastChatContent = '';
|
||||||
|
let chatDebounceTimer = null;
|
||||||
|
function handleChatDocumentChange(event) {
|
||||||
|
const doc = event.document;
|
||||||
|
const scheme = doc.uri.scheme;
|
||||||
|
// Log ALL schemes to discover chat-related ones (debug mode)
|
||||||
|
if (scheme !== 'file' && scheme !== 'git' && scheme !== 'output' &&
|
||||||
|
scheme !== 'vscode-userdata' && scheme !== 'untitled') {
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: doc change scheme="${scheme}" uri="${doc.uri.toString().substring(0, 80)}"`);
|
||||||
|
}
|
||||||
|
// Capture chat-related documents
|
||||||
|
// Known chat schemes: vscode-chat-response, vscode-copilot-chat, etc.
|
||||||
|
const isChatDoc = scheme.includes('chat') || scheme.includes('copilot') ||
|
||||||
|
scheme.includes('notebook') || doc.uri.path.includes('chat');
|
||||||
|
if (!isChatDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = doc.getText();
|
||||||
|
if (!content || content === lastChatContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Debounce: wait 2s for content to stabilize (AI streams text)
|
||||||
|
if (chatDebounceTimer) {
|
||||||
|
clearTimeout(chatDebounceTimer);
|
||||||
|
}
|
||||||
|
chatDebounceTimer = setTimeout(() => {
|
||||||
|
const finalContent = doc.getText();
|
||||||
|
if (finalContent && finalContent !== lastChatContent && finalContent.length > 20) {
|
||||||
|
lastChatContent = finalContent;
|
||||||
|
writeChatSnapshot(finalContent);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Write a chat content snapshot to bridge for the bot to relay.
|
||||||
|
*/
|
||||||
|
function writeChatSnapshot(content) {
|
||||||
|
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
|
||||||
|
if (!fs.existsSync(snapshotDir)) {
|
||||||
|
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const id = `chat-${Date.now()}`;
|
||||||
|
const filePath = path.join(snapshotDir, `${id}.json`);
|
||||||
|
const data = {
|
||||||
|
id,
|
||||||
|
project_name: projectName,
|
||||||
|
content: content.substring(0, 4000), // Limit size
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: chat snapshot written (${content.length} chars)`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @bridge Chat Participant handler.
|
||||||
|
* Reads conversation history and sends to Discord via bridge.
|
||||||
|
*/
|
||||||
|
const bridgeChatHandler = async (request, context, stream, token) => {
|
||||||
|
const command = request.prompt.trim().toLowerCase();
|
||||||
|
if (command === 'stop' || command === '중지') {
|
||||||
|
// Cancel current AI work
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('workbench.action.chat.stop');
|
||||||
|
stream.markdown('⏹️ AI 작업 중지 요청을 보냈습니다.');
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
stream.markdown('⚠️ 중지 명령을 실행할 수 없습니다.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Collect conversation history
|
||||||
|
const historyLines = [];
|
||||||
|
historyLines.push(`# 대화 히스토리 (${projectName})\n`);
|
||||||
|
for (const entry of context.history) {
|
||||||
|
if (entry instanceof vscode.ChatRequestTurn) {
|
||||||
|
historyLines.push(`## 👤 사용자\n${entry.prompt}\n`);
|
||||||
|
}
|
||||||
|
else if (entry instanceof vscode.ChatResponseTurn) {
|
||||||
|
let responseText = '';
|
||||||
|
for (const part of entry.response) {
|
||||||
|
if (part instanceof vscode.ChatResponseMarkdownPart) {
|
||||||
|
responseText += part.value.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responseText) {
|
||||||
|
historyLines.push(`## 🤖 AI\n${responseText}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (historyLines.length <= 1) {
|
||||||
|
stream.markdown('대화 히스토리가 비어있습니다. AI와 대화를 먼저 진행한 후 `@bridge`를 호출하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Write to bridge for Discord relay
|
||||||
|
const fullHistory = historyLines.join('\n');
|
||||||
|
const cmdId = `bridge-history-${Date.now()}`;
|
||||||
|
const cmdPath = path.join(bridgePath, 'commands', `${cmdId}.json`);
|
||||||
|
const data = {
|
||||||
|
id: cmdId,
|
||||||
|
project_name: projectName,
|
||||||
|
text: `[HISTORY]\n${fullHistory}`,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
consumed: false,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(cmdPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
stream.markdown(`✅ 대화 히스토리 (${context.history.length}개 턴)를 Discord에 전송했습니다.`);
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: sent ${context.history.length} turns to Discord`);
|
||||||
|
};
|
||||||
function deactivate() {
|
function deactivate() {
|
||||||
stopBridge();
|
stopBridge();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
39
extension/package-lock.json
generated
39
extension/package-lock.json
generated
@@ -1,19 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"antigravity-sdk": "^1.6.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/vscode": "^1.80.0",
|
"@types/vscode": "^1.100.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.80.0"
|
"vscode": "^1.100.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
@@ -27,10 +30,30 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/vscode": {
|
"node_modules/@types/vscode": {
|
||||||
"version": "1.109.0",
|
"version": "1.100.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
|
||||||
"integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==",
|
"integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/antigravity-sdk": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/antigravity-sdk/-/antigravity-sdk-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-VdaLlSujbr+9WNCxs57N8nkuWdSmUT4tD5BsO1XGjAKD6f1aPMCtqeMBYP6iWHRUGZZjtGvSQaUGBt/WR/9iAA==",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"sql.js": "^1.14.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/vscode": "^1.85.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sql.js": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"displayName": "Gravity Bridge",
|
"displayName": "Gravity Bridge",
|
||||||
"description": "Antigravity ↔ Discord 브리지 연동 확장",
|
"description": "Antigravity ↔ Discord 브리지 연동 확장",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"publisher": "variet",
|
"publisher": "variet",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.80.0"
|
"vscode": "^1.100.0"
|
||||||
},
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
"Other"
|
"Other",
|
||||||
|
"Chat"
|
||||||
],
|
],
|
||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
"onStartupFinished"
|
"onStartupFinished"
|
||||||
@@ -19,11 +20,20 @@
|
|||||||
"watch": "tsc -watch -p ./"
|
"watch": "tsc -watch -p ./"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/vscode": "^1.80.0",
|
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/vscode": "^1.100.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
"chatParticipants": [
|
||||||
|
{
|
||||||
|
"id": "gravity-bridge.gravity",
|
||||||
|
"name": "gravity",
|
||||||
|
"fullName": "Gravity Bridge",
|
||||||
|
"description": "대화 히스토리를 Discord로 전송 + AI 제어",
|
||||||
|
"isSticky": false
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "gravityBridge.start",
|
"command": "gravityBridge.start",
|
||||||
@@ -40,6 +50,10 @@
|
|||||||
{
|
{
|
||||||
"command": "gravityBridge.reject",
|
"command": "gravityBridge.reject",
|
||||||
"title": "Gravity Bridge: Reject Pending"
|
"title": "Gravity Bridge: Reject Pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gravityBridge.connect",
|
||||||
|
"title": "Gravity Bridge: Connect Session"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configuration": {
|
"configuration": {
|
||||||
@@ -49,8 +63,16 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)"
|
"description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)"
|
||||||
|
},
|
||||||
|
"gravityBridge.projectName": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"description": "프로젝트 이름 (기본: git remote 레포명)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"antigravity-sdk": "^1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,10 @@
|
|||||||
*
|
*
|
||||||
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
|
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Multi-project routing:
|
||||||
* 1. Extension watches for tool approval notifications in VS Code
|
* - Each workspace has a project name (from settings or workspace folder name)
|
||||||
* 2. Writes pending approval to bridge/pending/
|
* - Extension only processes commands/responses matching its project_name
|
||||||
* 3. Discord bot sends buttons to user
|
* - Pending approvals include project_name for Discord channel routing
|
||||||
* 4. User clicks approve/reject
|
|
||||||
* 5. Bot writes response to bridge/response/
|
|
||||||
* 6. Extension reads response → sends keyboard command to approve/reject
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
@@ -18,15 +15,56 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
||||||
let watcher: fs.FSWatcher | null = null;
|
let watcher: fs.FSWatcher | null = null;
|
||||||
|
let commandsWatcher: fs.FSWatcher | null = null;
|
||||||
let statusBar: vscode.StatusBarItem;
|
let statusBar: vscode.StatusBarItem;
|
||||||
let bridgePath: string;
|
let bridgePath: string;
|
||||||
|
let projectName: string;
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
|
|
||||||
// Track pending approvals we've already sent
|
// Track pending approvals we've already sent
|
||||||
const sentPendingIds = new Set<string>();
|
const sentPendingIds = new Set<string>();
|
||||||
|
|
||||||
|
import * as cp from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect project name from workspace.
|
||||||
|
* Priority: settings > git remote repo name > workspace folder name
|
||||||
|
*/
|
||||||
|
function detectProjectName(): string {
|
||||||
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
||||||
|
const configName = config.get<string>('projectName');
|
||||||
|
if (configName) { return configName; }
|
||||||
|
|
||||||
|
// Try git remote URL → extract repo name
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
if (folders && folders.length > 0) {
|
||||||
|
const cwd = folders[0].uri.fsPath;
|
||||||
|
try {
|
||||||
|
const remoteUrl = cp.execSync('git remote get-url origin', {
|
||||||
|
cwd, encoding: 'utf-8', timeout: 3000
|
||||||
|
}).trim();
|
||||||
|
// "https://gitea.example.com/Variet/gravity_control.git" → "gravity_control"
|
||||||
|
// "git@github.com:user/repo.git" → "repo"
|
||||||
|
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const repoName = match[1].toLowerCase().replace(/[\s\-]+/g, '_');
|
||||||
|
console.log(`Gravity Bridge: project from git remote → "${repoName}"`);
|
||||||
|
return repoName;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No git or no remote — fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: workspace folder name
|
||||||
|
return folders[0].name.toLowerCase().replace(/[\s\-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown_project';
|
||||||
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
console.log('Gravity Bridge: activating...');
|
projectName = detectProjectName();
|
||||||
|
console.log(`Gravity Bridge: activating for project "${projectName}"...`);
|
||||||
|
|
||||||
// Determine bridge path
|
// Determine bridge path
|
||||||
const config = vscode.workspace.getConfiguration('gravityBridge');
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
||||||
@@ -34,7 +72,7 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
|
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
|
||||||
|
|
||||||
// Ensure bridge directories exist
|
// Ensure bridge directories exist
|
||||||
const dirs = ['pending', 'response', 'commands'];
|
const dirs = ['pending', 'response', 'commands', 'register'];
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const dirPath = path.join(bridgePath, dir);
|
const dirPath = path.join(bridgePath, dir);
|
||||||
if (!fs.existsSync(dirPath)) {
|
if (!fs.existsSync(dirPath)) {
|
||||||
@@ -45,8 +83,8 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
// Status bar
|
// Status bar
|
||||||
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||||
statusBar.command = 'gravityBridge.start';
|
statusBar.command = 'gravityBridge.start';
|
||||||
statusBar.text = '$(radio-tower) Bridge: Off';
|
statusBar.text = `$(radio-tower) ${projectName}: Off`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Click to start';
|
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
|
||||||
statusBar.show();
|
statusBar.show();
|
||||||
context.subscriptions.push(statusBar);
|
context.subscriptions.push(statusBar);
|
||||||
|
|
||||||
@@ -54,30 +92,57 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand('gravityBridge.start', startBridge),
|
vscode.commands.registerCommand('gravityBridge.start', startBridge),
|
||||||
vscode.commands.registerCommand('gravityBridge.stop', stopBridge),
|
vscode.commands.registerCommand('gravityBridge.stop', stopBridge),
|
||||||
|
vscode.commands.registerCommand('gravityBridge.connect', connectSession),
|
||||||
vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)),
|
vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)),
|
||||||
vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)),
|
vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Chat document change listener — captures AI text responses
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
|
handleChatDocumentChange(event);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register @bridge Chat Participant for history relay
|
||||||
|
try {
|
||||||
|
const participant = vscode.chat.createChatParticipant(
|
||||||
|
'gravity-bridge.gravity',
|
||||||
|
bridgeChatHandler
|
||||||
|
);
|
||||||
|
participant.iconPath = new vscode.ThemeIcon('radio-tower');
|
||||||
|
context.subscriptions.push(participant);
|
||||||
|
console.log('Gravity Bridge: @bridge chat participant registered');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Gravity Bridge: chat participant API not available (OK)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-watch brain/ for new conversations → auto-register
|
||||||
|
watchBrainForNewSessions();
|
||||||
|
|
||||||
// Auto-start
|
// Auto-start
|
||||||
startBridge();
|
startBridge();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startBridge() {
|
function startBridge() {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
vscode.window.showInformationMessage('Gravity Bridge is already running');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): already running`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive = true;
|
isActive = true;
|
||||||
statusBar.text = '$(radio-tower) Bridge: On';
|
statusBar.text = `$(radio-tower) ${projectName}: On`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Active';
|
statusBar.tooltip = `Gravity Bridge — ${projectName} (Active)`;
|
||||||
statusBar.command = 'gravityBridge.stop';
|
statusBar.command = 'gravityBridge.stop';
|
||||||
|
|
||||||
// Watch bridge/response/ for Discord user responses
|
// Watch bridge/response/ for Discord user responses
|
||||||
const responsePath = path.join(bridgePath, 'response');
|
const responsePath = path.join(bridgePath, 'response');
|
||||||
|
const processedFiles = new Set<string>(); // Debounce
|
||||||
try {
|
try {
|
||||||
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
|
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
|
||||||
if (eventType === 'rename' && filename && filename.endsWith('.json')) {
|
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
||||||
|
processedFiles.add(filename);
|
||||||
|
setTimeout(() => processedFiles.delete(filename), 2000);
|
||||||
handleResponse(path.join(responsePath, filename));
|
handleResponse(path.join(responsePath, filename));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -89,8 +154,10 @@ function startBridge() {
|
|||||||
// Watch for commands (user text input from Discord)
|
// Watch for commands (user text input from Discord)
|
||||||
const commandsPath = path.join(bridgePath, 'commands');
|
const commandsPath = path.join(bridgePath, 'commands');
|
||||||
try {
|
try {
|
||||||
fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
|
commandsWatcher = fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
|
||||||
if (eventType === 'rename' && filename && filename.endsWith('.json')) {
|
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
|
||||||
|
processedFiles.add(filename);
|
||||||
|
setTimeout(() => processedFiles.delete(filename), 2000);
|
||||||
handleCommand(path.join(commandsPath, filename));
|
handleCommand(path.join(commandsPath, filename));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -99,33 +166,30 @@ function startBridge() {
|
|||||||
console.error('Gravity Bridge: failed to watch commands dir', err);
|
console.error('Gravity Bridge: failed to watch commands dir', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
vscode.window.showInformationMessage('Gravity Bridge: Started');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Started`);
|
||||||
console.log(`Gravity Bridge: started, bridge path: ${bridgePath}`);
|
console.log(`Gravity Bridge: started for project "${projectName}", bridge: ${bridgePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopBridge() {
|
function stopBridge() {
|
||||||
if (!isActive) { return; }
|
if (!isActive) { return; }
|
||||||
|
|
||||||
isActive = false;
|
isActive = false;
|
||||||
statusBar.text = '$(radio-tower) Bridge: Off';
|
statusBar.text = `$(radio-tower) ${projectName}: Off`;
|
||||||
statusBar.tooltip = 'Gravity Bridge — Click to start';
|
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
|
||||||
statusBar.command = 'gravityBridge.start';
|
statusBar.command = 'gravityBridge.start';
|
||||||
|
|
||||||
if (watcher) {
|
if (watcher) { watcher.close(); watcher = null; }
|
||||||
watcher.close();
|
if (commandsWatcher) { commandsWatcher.close(); commandsWatcher = null; }
|
||||||
watcher = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
vscode.window.showInformationMessage('Gravity Bridge: Stopped');
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Stopped`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a response from Discord (approve/reject).
|
* Handle a response from Discord (approve/reject).
|
||||||
* Reads the response JSON and simulates the appropriate action.
|
* Only processes responses — no project filtering needed since request_id is unique.
|
||||||
*/
|
*/
|
||||||
async function handleResponse(filePath: string) {
|
async function handleResponse(filePath: string) {
|
||||||
try {
|
try {
|
||||||
// Small delay to ensure file is fully written
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) { return; }
|
if (!fs.existsSync(filePath)) { return; }
|
||||||
@@ -135,11 +199,9 @@ async function handleResponse(filePath: string) {
|
|||||||
|
|
||||||
if (response.approved === undefined) { return; }
|
if (response.approved === undefined) { return; }
|
||||||
|
|
||||||
console.log(`Gravity Bridge: response received — approved=${response.approved}`);
|
console.log(`Gravity Bridge [${projectName}]: response — approved=${response.approved}`);
|
||||||
|
|
||||||
if (response.approved) {
|
if (response.approved) {
|
||||||
// Simulate pressing Enter or clicking approve
|
|
||||||
// Strategy: Use VS Code command to accept suggestion
|
|
||||||
await simulateApproval();
|
await simulateApproval();
|
||||||
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
|
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -147,9 +209,7 @@ async function handleResponse(filePath: string) {
|
|||||||
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
|
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup: delete the response file after processing
|
|
||||||
try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
|
try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gravity Bridge: error handling response', err);
|
console.error('Gravity Bridge: error handling response', err);
|
||||||
}
|
}
|
||||||
@@ -157,7 +217,7 @@ async function handleResponse(filePath: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a text command from Discord.
|
* Handle a text command from Discord.
|
||||||
* Supports special commands (!auto on/off) and general text relay.
|
* ONLY processes commands matching this project's name.
|
||||||
*/
|
*/
|
||||||
async function handleCommand(filePath: string) {
|
async function handleCommand(filePath: string) {
|
||||||
try {
|
try {
|
||||||
@@ -170,34 +230,69 @@ async function handleCommand(filePath: string) {
|
|||||||
|
|
||||||
if (command.consumed || !command.text) { return; }
|
if (command.consumed || !command.text) { return; }
|
||||||
|
|
||||||
|
// ★ PROJECT FILTER — only process commands for THIS project
|
||||||
|
const cmdProject = command.project_name || '';
|
||||||
|
if (cmdProject && cmdProject !== projectName) {
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: skipping command for "${cmdProject}"`);
|
||||||
|
return; // Not for us — leave file for the correct Extension instance
|
||||||
|
}
|
||||||
|
|
||||||
const text = command.text.trim();
|
const text = command.text.trim();
|
||||||
console.log(`Gravity Bridge: command received — "${text.substring(0, 50)}"`);
|
console.log(`Gravity Bridge [${projectName}]: command — "${text.substring(0, 50)}"`);
|
||||||
|
|
||||||
|
// Special command: !stop — cancel AI work
|
||||||
|
if (text === '!stop') {
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('workbench.action.chat.stop');
|
||||||
|
vscode.window.showWarningMessage(`⏹️ [${projectName}] AI 작업 중지됨`);
|
||||||
|
} catch {
|
||||||
|
vscode.window.showErrorMessage('AI 중지 명령 실행 실패');
|
||||||
|
}
|
||||||
|
command.consumed = true;
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Special command: auto-approve toggle
|
// Special command: auto-approve toggle
|
||||||
if (text === '!auto on' || text === '!auto off') {
|
if (text === '!auto on' || text === '!auto off') {
|
||||||
const enabled = text === '!auto on';
|
const enabled = text === '!auto on';
|
||||||
await toggleAutoApprove(enabled);
|
await toggleAutoApprove(enabled);
|
||||||
|
|
||||||
// Mark as consumed
|
|
||||||
command.consumed = true;
|
command.consumed = true;
|
||||||
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// General text: type into chat panel
|
// General text: send directly to Antigravity agent panel
|
||||||
await vscode.commands.executeCommand('workbench.action.chat.open');
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', command.text);
|
||||||
|
console.log(`Gravity Bridge: ✅ sent via sendPromptToAgentPanel`);
|
||||||
|
} catch (e1) {
|
||||||
|
console.log(`Gravity Bridge: sendPromptToAgentPanel failed: ${e1}`);
|
||||||
|
// Fallback: try sendChatActionMessage
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('antigravity.sendChatActionMessage', command.text);
|
||||||
|
console.log(`Gravity Bridge: ✅ sent via sendChatActionMessage`);
|
||||||
|
} catch (e2) {
|
||||||
|
console.log(`Gravity Bridge: sendChatActionMessage failed: ${e2}`);
|
||||||
|
// Last resort: focus panel + clipboard paste
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
const oldClip = await vscode.env.clipboard.readText();
|
||||||
|
await vscode.env.clipboard.writeText(command.text);
|
||||||
|
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
|
||||||
|
await vscode.env.clipboard.writeText(oldClip);
|
||||||
|
console.log('Gravity Bridge: clipboard paste fallback');
|
||||||
|
} catch (e3) {
|
||||||
|
console.error('Gravity Bridge: all methods failed', e3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const oldClipboard = await vscode.env.clipboard.readText();
|
// Always mark as consumed
|
||||||
await vscode.env.clipboard.writeText(command.text);
|
|
||||||
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
|
|
||||||
await vscode.env.clipboard.writeText(oldClipboard);
|
|
||||||
|
|
||||||
// Mark as consumed
|
|
||||||
command.consumed = true;
|
command.consumed = true;
|
||||||
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
||||||
|
|
||||||
vscode.window.showInformationMessage(`📨 Discord input: ${command.text.substring(0, 50)}...`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gravity Bridge: error handling command', err);
|
console.error('Gravity Bridge: error handling command', err);
|
||||||
}
|
}
|
||||||
@@ -210,31 +305,25 @@ async function toggleAutoApprove(enabled: boolean) {
|
|||||||
const config = vscode.workspace.getConfiguration();
|
const config = vscode.workspace.getConfiguration();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Core auto-approve settings
|
|
||||||
await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
||||||
await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global);
|
||||||
|
|
||||||
// Terminal auto-execution
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global);
|
await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global);
|
||||||
}
|
}
|
||||||
|
|
||||||
// File edits auto-accept
|
|
||||||
await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global);
|
await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global);
|
||||||
|
|
||||||
// Update status bar
|
|
||||||
statusBar.text = enabled
|
statusBar.text = enabled
|
||||||
? '$(radio-tower) Bridge: Auto ✅'
|
? `$(radio-tower) ${projectName}: Auto ✅`
|
||||||
: '$(radio-tower) Bridge: Manual 🔒';
|
: `$(radio-tower) ${projectName}: Manual 🔒`;
|
||||||
|
|
||||||
const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴';
|
const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴';
|
||||||
vscode.window.showInformationMessage(`Gravity Bridge: ${mode}`);
|
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): ${mode}`);
|
||||||
console.log(`Gravity Bridge: auto-approve set to ${enabled}`);
|
|
||||||
|
|
||||||
// Write status back to bridge for bot to report
|
|
||||||
const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`);
|
const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`);
|
||||||
fs.writeFileSync(statusPath, JSON.stringify({
|
fs.writeFileSync(statusPath, JSON.stringify({
|
||||||
id: `auto-status-${Date.now()}`,
|
id: `auto-status-${Date.now()}`,
|
||||||
|
project_name: projectName,
|
||||||
text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`,
|
text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`,
|
||||||
timestamp: Date.now() / 1000,
|
timestamp: Date.now() / 1000,
|
||||||
consumed: true,
|
consumed: true,
|
||||||
@@ -243,40 +332,28 @@ async function toggleAutoApprove(enabled: boolean) {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gravity Bridge: failed to toggle auto-approve', err);
|
console.error('Gravity Bridge: failed to toggle auto-approve', err);
|
||||||
vscode.window.showErrorMessage(`Auto-approve toggle failed: ${err}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate approval — try multiple strategies.
|
|
||||||
*/
|
|
||||||
async function simulateApproval() {
|
async function simulateApproval() {
|
||||||
try {
|
try {
|
||||||
// Strategy 1: Try executing the accept command if available
|
|
||||||
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
|
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
|
||||||
} catch {
|
} catch {
|
||||||
// Strategy 2: Send Enter key via type command
|
|
||||||
try {
|
try {
|
||||||
await vscode.commands.executeCommand('type', { text: '\n' });
|
await vscode.commands.executeCommand('type', { text: '\n' });
|
||||||
} catch {
|
} catch {
|
||||||
// Strategy 3: Focus terminal and send Enter
|
|
||||||
await vscode.commands.executeCommand('workbench.action.terminal.focus');
|
await vscode.commands.executeCommand('workbench.action.terminal.focus');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate rejection — try multiple strategies.
|
|
||||||
*/
|
|
||||||
async function simulateRejection() {
|
async function simulateRejection() {
|
||||||
try {
|
try {
|
||||||
// Strategy 1: Escape key
|
|
||||||
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
|
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await vscode.commands.executeCommand('cancelSelection');
|
await vscode.commands.executeCommand('cancelSelection');
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: just notify
|
|
||||||
console.log('Gravity Bridge: rejection sent but no active dialog found');
|
console.log('Gravity Bridge: rejection sent but no active dialog found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +361,6 @@ async function simulateRejection() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual approve/reject from command palette.
|
* Manual approve/reject from command palette.
|
||||||
* Writes a pending request for testing purposes.
|
|
||||||
*/
|
*/
|
||||||
function handleManualAction(approved: boolean) {
|
function handleManualAction(approved: boolean) {
|
||||||
const requestId = `manual-${Date.now()}`;
|
const requestId = `manual-${Date.now()}`;
|
||||||
@@ -299,15 +375,13 @@ function handleManualAction(approved: boolean) {
|
|||||||
|
|
||||||
fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8');
|
fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8');
|
||||||
|
|
||||||
if (approved) {
|
if (approved) { simulateApproval(); }
|
||||||
simulateApproval();
|
else { simulateRejection(); }
|
||||||
} else {
|
|
||||||
simulateRejection();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a pending approval request to bridge/pending/ for Discord bot to pick up.
|
* Write a pending approval request to bridge/pending/ for Discord bot to pick up.
|
||||||
|
* Includes project_name for correct channel routing.
|
||||||
*/
|
*/
|
||||||
export function writePendingApproval(
|
export function writePendingApproval(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
@@ -320,6 +394,7 @@ export function writePendingApproval(
|
|||||||
const request = {
|
const request = {
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
project_name: projectName, // ★ Project routing
|
||||||
command: command,
|
command: command,
|
||||||
description: description,
|
description: description,
|
||||||
timestamp: Date.now() / 1000,
|
timestamp: Date.now() / 1000,
|
||||||
@@ -330,10 +405,281 @@ export function writePendingApproval(
|
|||||||
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
|
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
|
||||||
sentPendingIds.add(requestId);
|
sentPendingIds.add(requestId);
|
||||||
|
|
||||||
console.log(`Gravity Bridge: pending approval written — ${requestId}`);
|
console.log(`Gravity Bridge [${projectName}]: pending approval — ${requestId}`);
|
||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a conversation → project mapping in bridge/register/.
|
||||||
|
* The bot reads these files to route brain events to the correct channel.
|
||||||
|
*/
|
||||||
|
function registerConversation(conversationId: string) {
|
||||||
|
const registerDir = path.join(bridgePath, 'register');
|
||||||
|
if (!fs.existsSync(registerDir)) {
|
||||||
|
fs.mkdirSync(registerDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(registerDir, `${conversationId}.json`);
|
||||||
|
const data = {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
project_name: projectName,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: registered ${conversationId.substring(0, 8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the title (first # heading) from a conversation's task.md or implementation_plan.md.
|
||||||
|
*/
|
||||||
|
function getConversationTitle(convDir: string): string {
|
||||||
|
for (const fname of ['task.md', 'implementation_plan.md']) {
|
||||||
|
const fpath = path.join(convDir, fname);
|
||||||
|
if (fs.existsSync(fpath)) {
|
||||||
|
try {
|
||||||
|
const lines = fs.readFileSync(fpath, 'utf-8').split('\n').slice(0, 5);
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^#\s+(.+)/);
|
||||||
|
if (match) { return match[1].trim().substring(0, 50); }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual connect: scan brain/ for recent conversations and let user pick.
|
||||||
|
* Shows task.md titles for readability. Offers auto-connect for new projects.
|
||||||
|
*/
|
||||||
|
async function connectSession() {
|
||||||
|
const brainPath = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
|
||||||
|
if (!fs.existsSync(brainPath)) {
|
||||||
|
vscode.window.showErrorMessage('Brain 디렉토리를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get conversation dirs sorted by modification time (newest first)
|
||||||
|
const dirs = fs.readdirSync(brainPath)
|
||||||
|
.filter(d => {
|
||||||
|
const fullPath = path.join(brainPath, d);
|
||||||
|
return fs.statSync(fullPath).isDirectory() && d.includes('-');
|
||||||
|
})
|
||||||
|
.map(d => {
|
||||||
|
const fullPath = path.join(brainPath, d);
|
||||||
|
return {
|
||||||
|
name: d,
|
||||||
|
mtime: fs.statSync(fullPath).mtimeMs,
|
||||||
|
title: getConversationTitle(fullPath),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Build QuickPick items
|
||||||
|
const items: (vscode.QuickPickItem & { convId?: string })[] = [];
|
||||||
|
|
||||||
|
// Always offer auto-connect option first
|
||||||
|
items.push({
|
||||||
|
label: '$(sync) 새 대화 자동 연결',
|
||||||
|
description: '다음에 시작하는 대화가 자동으로 이 프로젝트에 연결됩니다',
|
||||||
|
detail: `프로젝트: ${projectName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add conversation items with titles
|
||||||
|
for (const d of dirs) {
|
||||||
|
const titleLabel = d.title || '(제목 없음)';
|
||||||
|
const timeStr = new Date(d.mtime).toLocaleString();
|
||||||
|
items.push({
|
||||||
|
label: `$(comment-discussion) ${titleLabel}`,
|
||||||
|
description: d.name.substring(0, 8),
|
||||||
|
detail: `${d.name} · ${timeStr}`,
|
||||||
|
convId: d.name,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = await vscode.window.showQuickPick(items, {
|
||||||
|
placeHolder: `프로젝트 "${projectName}"에 연결할 세션을 선택하세요`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selected) { return; }
|
||||||
|
|
||||||
|
if (!('convId' in selected) || !selected.convId) {
|
||||||
|
// Auto-connect mode
|
||||||
|
vscode.window.showInformationMessage(
|
||||||
|
`🔄 ${projectName}: 다음 대화가 자동으로 연결됩니다`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerConversation(selected.convId);
|
||||||
|
vscode.window.showInformationMessage(
|
||||||
|
`✅ ${selected.description} → ${projectName} 연결됨`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-watch brain/ for new conversation directories → auto-register.
|
||||||
|
*/
|
||||||
|
function watchBrainForNewSessions() {
|
||||||
|
const brainPath = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
|
||||||
|
if (!fs.existsSync(brainPath)) { return; }
|
||||||
|
|
||||||
|
// Track known dirs
|
||||||
|
const knownDirs = new Set(
|
||||||
|
fs.readdirSync(brainPath).filter(d =>
|
||||||
|
fs.statSync(path.join(brainPath, d)).isDirectory()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.watch(brainPath, { persistent: false }, (eventType, filename) => {
|
||||||
|
if (!filename || !filename.includes('-')) { return; }
|
||||||
|
const fullPath = path.join(brainPath, filename);
|
||||||
|
|
||||||
|
// Check if it's a new directory
|
||||||
|
if (!knownDirs.has(filename) && fs.existsSync(fullPath) &&
|
||||||
|
fs.statSync(fullPath).isDirectory()) {
|
||||||
|
knownDirs.add(filename);
|
||||||
|
registerConversation(filename);
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: auto-registered new session ${filename.substring(0, 8)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: watching brain/ for new sessions`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Gravity Bridge: failed to watch brain dir', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor text document changes for chat panel content.
|
||||||
|
* VS Code chat documents have special URI schemes (vscode-chat-response, etc.).
|
||||||
|
* We capture significant changes and relay to Discord.
|
||||||
|
*/
|
||||||
|
let lastChatContent = '';
|
||||||
|
let chatDebounceTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
function handleChatDocumentChange(event: vscode.TextDocumentChangeEvent) {
|
||||||
|
const doc = event.document;
|
||||||
|
const scheme = doc.uri.scheme;
|
||||||
|
|
||||||
|
// Log ALL schemes to discover chat-related ones (debug mode)
|
||||||
|
if (scheme !== 'file' && scheme !== 'git' && scheme !== 'output' &&
|
||||||
|
scheme !== 'vscode-userdata' && scheme !== 'untitled') {
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: doc change scheme="${scheme}" uri="${doc.uri.toString().substring(0, 80)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture chat-related documents
|
||||||
|
// Known chat schemes: vscode-chat-response, vscode-copilot-chat, etc.
|
||||||
|
const isChatDoc = scheme.includes('chat') || scheme.includes('copilot') ||
|
||||||
|
scheme.includes('notebook') || doc.uri.path.includes('chat');
|
||||||
|
|
||||||
|
if (!isChatDoc) { return; }
|
||||||
|
|
||||||
|
const content = doc.getText();
|
||||||
|
if (!content || content === lastChatContent) { return; }
|
||||||
|
|
||||||
|
// Debounce: wait 2s for content to stabilize (AI streams text)
|
||||||
|
if (chatDebounceTimer) { clearTimeout(chatDebounceTimer); }
|
||||||
|
chatDebounceTimer = setTimeout(() => {
|
||||||
|
const finalContent = doc.getText();
|
||||||
|
if (finalContent && finalContent !== lastChatContent && finalContent.length > 20) {
|
||||||
|
lastChatContent = finalContent;
|
||||||
|
writeChatSnapshot(finalContent);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a chat content snapshot to bridge for the bot to relay.
|
||||||
|
*/
|
||||||
|
function writeChatSnapshot(content: string) {
|
||||||
|
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
|
||||||
|
if (!fs.existsSync(snapshotDir)) {
|
||||||
|
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `chat-${Date.now()}`;
|
||||||
|
const filePath = path.join(snapshotDir, `${id}.json`);
|
||||||
|
const data = {
|
||||||
|
id,
|
||||||
|
project_name: projectName,
|
||||||
|
content: content.substring(0, 4000), // Limit size
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: chat snapshot written (${content.length} chars)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @bridge Chat Participant handler.
|
||||||
|
* Reads conversation history and sends to Discord via bridge.
|
||||||
|
*/
|
||||||
|
const bridgeChatHandler: vscode.ChatRequestHandler = async (
|
||||||
|
request: vscode.ChatRequest,
|
||||||
|
context: vscode.ChatContext,
|
||||||
|
stream: vscode.ChatResponseStream,
|
||||||
|
token: vscode.CancellationToken
|
||||||
|
) => {
|
||||||
|
const command = request.prompt.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (command === 'stop' || command === '중지') {
|
||||||
|
// Cancel current AI work
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('workbench.action.chat.stop');
|
||||||
|
stream.markdown('⏹️ AI 작업 중지 요청을 보냈습니다.');
|
||||||
|
} catch {
|
||||||
|
stream.markdown('⚠️ 중지 명령을 실행할 수 없습니다.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect conversation history
|
||||||
|
const historyLines: string[] = [];
|
||||||
|
historyLines.push(`# 대화 히스토리 (${projectName})\n`);
|
||||||
|
|
||||||
|
for (const entry of context.history) {
|
||||||
|
if (entry instanceof vscode.ChatRequestTurn) {
|
||||||
|
historyLines.push(`## 👤 사용자\n${entry.prompt}\n`);
|
||||||
|
} else if (entry instanceof vscode.ChatResponseTurn) {
|
||||||
|
let responseText = '';
|
||||||
|
for (const part of entry.response) {
|
||||||
|
if (part instanceof vscode.ChatResponseMarkdownPart) {
|
||||||
|
responseText += part.value.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responseText) {
|
||||||
|
historyLines.push(`## 🤖 AI\n${responseText}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyLines.length <= 1) {
|
||||||
|
stream.markdown('대화 히스토리가 비어있습니다. AI와 대화를 먼저 진행한 후 `@bridge`를 호출하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to bridge for Discord relay
|
||||||
|
const fullHistory = historyLines.join('\n');
|
||||||
|
const cmdId = `bridge-history-${Date.now()}`;
|
||||||
|
const cmdPath = path.join(bridgePath, 'commands', `${cmdId}.json`);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
id: cmdId,
|
||||||
|
project_name: projectName,
|
||||||
|
text: `[HISTORY]\n${fullHistory}`,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
consumed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(cmdPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
stream.markdown(`✅ 대화 히스토리 (${context.history.length}개 턴)를 Discord에 전송했습니다.`);
|
||||||
|
console.log(`Gravity Bridge [${projectName}]: sent ${context.history.length} turns to Discord`);
|
||||||
|
};
|
||||||
|
|
||||||
export function deactivate() {
|
export function deactivate() {
|
||||||
stopBridge();
|
stopBridge();
|
||||||
}
|
}
|
||||||
|
|||||||
21
watcher.py
21
watcher.py
@@ -52,13 +52,30 @@ class BrainEventHandler(FileSystemEventHandler):
|
|||||||
self._initialize_known_sessions()
|
self._initialize_known_sessions()
|
||||||
|
|
||||||
def _initialize_known_sessions(self):
|
def _initialize_known_sessions(self):
|
||||||
"""Scan existing brain directories to establish baseline (no events emitted)."""
|
"""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
|
brain_path = Config.BRAIN_PATH
|
||||||
|
hash_count = 0
|
||||||
if brain_path.exists():
|
if brain_path.exists():
|
||||||
for entry in brain_path.iterdir():
|
for entry in brain_path.iterdir():
|
||||||
if entry.is_dir() and self._is_conversation_id(entry.name):
|
if entry.is_dir() and self._is_conversation_id(entry.name):
|
||||||
self._known_sessions.add(entry.name)
|
self._known_sessions.add(entry.name)
|
||||||
logger.info(f"Found {len(self._known_sessions)} existing sessions at startup")
|
# 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 _is_conversation_id(self, name: str) -> bool:
|
def _is_conversation_id(self, name: str) -> bool:
|
||||||
parts = name.split("-")
|
parts = name.split("-")
|
||||||
|
|||||||
Reference in New Issue
Block a user