feat: chat capture (@bridge participant, onDidChangeTextDocument), !stop command, chat snapshot scanner
This commit is contained in:
54
bot.py
54
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()
|
||||
|
||||
@@ -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 레포명)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user