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):
|
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()
|
||||||
|
self.chat_snapshot_scanner.start()
|
||||||
logger.info("Bot setup complete")
|
logger.info("Bot setup complete")
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
@@ -417,6 +418,17 @@ class GravityBot(commands.Bot):
|
|||||||
|
|
||||||
text = message.content.strip()
|
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
|
# Special command: !auto on/off
|
||||||
if text in ("!auto on", "!auto off"):
|
if text in ("!auto on", "!auto off"):
|
||||||
self.bridge.write_command(project, text, project_name=project)
|
self.bridge.write_command(project, text, project_name=project)
|
||||||
@@ -439,3 +451,45 @@ class GravityBot(commands.Bot):
|
|||||||
await message.add_reaction("📨")
|
await message.add_reaction("📨")
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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.109.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.109.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
"chatParticipants": [
|
||||||
|
{
|
||||||
|
"id": "gravity-bridge.bridge",
|
||||||
|
"name": "bridge",
|
||||||
|
"fullName": "Gravity Bridge",
|
||||||
|
"description": "대화 히스토리를 Discord로 전송 + AI 제어",
|
||||||
|
"isSticky": false
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "gravityBridge.start",
|
"command": "gravityBridge.start",
|
||||||
@@ -57,7 +67,7 @@
|
|||||||
"gravityBridge.projectName": {
|
"gravityBridge.projectName": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "프로젝트 이름 (기본: 워크스페이스 폴더명, 예: gravity_control)"
|
"description": "프로젝트 이름 (기본: git remote 레포명)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,26 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
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.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
|
// Auto-watch brain/ for new conversations → auto-register
|
||||||
watchBrainForNewSessions();
|
watchBrainForNewSessions();
|
||||||
|
|
||||||
@@ -220,6 +240,19 @@ async function handleCommand(filePath: string) {
|
|||||||
const text = command.text.trim();
|
const text = command.text.trim();
|
||||||
console.log(`Gravity Bridge [${projectName}]: command — "${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';
|
||||||
@@ -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() {
|
export function deactivate() {
|
||||||
stopBridge();
|
stopBridge();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user