From 35ee916440fa720e055b05105ac16eaa66aa6ff1 Mon Sep 17 00:00:00 2001 From: CD Date: Sat, 7 Mar 2026 14:45:44 +0900 Subject: [PATCH] feat: chat capture (@bridge participant, onDidChangeTextDocument), !stop command, chat snapshot scanner --- bot.py | 54 +++++++++++++ extension/package.json | 20 +++-- extension/src/extension.ts | 161 +++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 5 deletions(-) diff --git a/bot.py b/bot.py index 9ffeb17..175b054 100644 --- a/bot.py +++ b/bot.py @@ -114,6 +114,7 @@ class GravityBot(commands.Bot): async def setup_hook(self): self.loop.create_task(self._process_events()) self.pending_approval_scanner.start() + self.chat_snapshot_scanner.start() logger.info("Bot setup complete") async def on_ready(self): @@ -417,6 +418,17 @@ class GravityBot(commands.Bot): text = message.content.strip() + # Special command: !stop — cancel AI work + if text == "!stop": + self.bridge.write_command(project, "!stop", project_name=project) + embed = discord.Embed( + title="⏹️ AI 작업 중지", + description=f"프로젝트: **{project}**\n중지 요청을 Extension에 전달했습니다.", + color=discord.Color.orange(), + ) + await message.channel.send(embed=embed) + return + # Special command: !auto on/off if text in ("!auto on", "!auto off"): self.bridge.write_command(project, text, project_name=project) @@ -439,3 +451,45 @@ class GravityBot(commands.Bot): await message.add_reaction("📨") 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() diff --git a/extension/package.json b/extension/package.json index 7b96704..39ad09a 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,13 +2,14 @@ "name": "gravity-bridge", "displayName": "Gravity Bridge", "description": "Antigravity ↔ Discord 브리지 연동 확장", - "version": "0.1.0", + "version": "0.2.0", "publisher": "variet", "engines": { - "vscode": "^1.80.0" + "vscode": "^1.109.0" }, "categories": [ - "Other" + "Other", + "Chat" ], "activationEvents": [ "onStartupFinished" @@ -19,11 +20,20 @@ "watch": "tsc -watch -p ./" }, "devDependencies": { - "@types/vscode": "^1.80.0", "@types/node": "^20.0.0", + "@types/vscode": "^1.109.0", "typescript": "^5.3.0" }, "contributes": { + "chatParticipants": [ + { + "id": "gravity-bridge.bridge", + "name": "bridge", + "fullName": "Gravity Bridge", + "description": "대화 히스토리를 Discord로 전송 + AI 제어", + "isSticky": false + } + ], "commands": [ { "command": "gravityBridge.start", @@ -57,7 +67,7 @@ "gravityBridge.projectName": { "type": "string", "default": "", - "description": "프로젝트 이름 (기본: 워크스페이스 폴더명, 예: gravity_control)" + "description": "프로젝트 이름 (기본: git remote 레포명)" } } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index d758bf0..4032245 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -97,6 +97,26 @@ export function activate(context: vscode.ExtensionContext) { 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.bridge', + 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(); @@ -220,6 +240,19 @@ async function handleCommand(filePath: string) { const text = command.text.trim(); 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 if (text === '!auto on' || text === '!auto off') { const enabled = text === '!auto on'; @@ -503,6 +536,134 @@ function watchBrainForNewSessions() { } } +/** + * 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() { stopBridge(); }