fix(bridge): step structure discovery + approval watcher + AI text relay

- plannerResponse.response = user-facing text field (confirmed)
- step.runCommand.commandLine = command (not toolCall.argumentsJson)
- Add response watcher: bridge/response/ → ResolveOutstandingSteps RPC
- Fix AI text: use modifiedResponse/response, last-wins, dedup
- Fix flooding: slice(-delta) to skip old steps on reload
- Bot: 404 cache invalidation for deleted Discord channels
This commit is contained in:
2026-03-08 02:29:17 +09:00
parent 876143d397
commit 0c3d6cdb6d
6 changed files with 488 additions and 159 deletions

9
bot.py
View File

@@ -579,7 +579,16 @@ class GravityBot(commands.Bot):
color=discord.Color.purple(), color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
try:
await channel.send(embed=embed) await channel.send(embed=embed)
except discord.NotFound:
# Channel was deleted — invalidate cache and retry once
logger.warning(f"Channel deleted for {project}, re-creating...")
self.project_channels.pop(project, None)
channel = await self._get_channel(project)
if channel:
await channel.send(embed=embed)
break
f.unlink() # Cleanup f.unlink() # Cleanup
except (json.JSONDecodeError, OSError) as e: except (json.JSONDecodeError, OSError) as e:

View File

@@ -3,3 +3,4 @@
| # | 시간 | 작업 | 커밋 | 상태 | | # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------| |---|------|------|------|------|
| 1 | 01:00 | Extension↔Bot 프로토콜 불일치 3건 수정 + sql-wasm 번들링 | `e4dc1b1` | 🔧 | | 1 | 01:00 | Extension↔Bot 프로토콜 불일치 3건 수정 + sql-wasm 번들링 | `e4dc1b1` | 🔧 |
| 2 | 01:45~02:25 | Discord Bridge 디버깅: step 구조 파악, 승인 버튼, AI 텍스트 릴레이 | TBD | ✅ |

View File

@@ -0,0 +1,47 @@
# Discord Bridge — Step 구조 파악 및 3대 기능 수정
- **시간**: 2026-03-08 01:45~02:25
- **Commit**: TBD (이 파일과 함께 커밋)
## 결정 사항
### plannerResponse 구조 (확정)
`GetCascadeTrajectorySteps` RPC가 반환하는 `PLANNER_RESPONSE` step의 실제 구조:
```
plannerResponse: {
response: "사용자 대면 텍스트", ← 핵심 필드
modifiedResponse: "수정된 버전",
thinking: "내부 사고 (SKIP)",
thinkingSignature: "암호화 해시 (SKIP)",
messageId: "bot-xxx",
toolCalls: [{name, argumentsJson}],
thinkingDuration: "0.9s",
stopReason: "STOP_REASON_STOP_PATTERN"
}
```
### RUN_COMMAND 구조 (확정)
```
step.runCommand: {
commandLine: "echo ...", ← 핵심 필드
proposedCommandLine: "echo ...",
cwd: "c:\\Users\\...",
waitMsBeforeAsync: "10000",
blocking: true,
autoRunDecision: "AUTO_RUN_DECISION_DEFAULT_DENY"
}
```
- `step.toolCall`은 존재하지 않음 — `step.runCommand`에 직접 필드
### startStepIndex 무시 문제
`GetCascadeTrajectorySteps({ startStepIndex })` — API가 파라미터를 무시하고 전체 step 반환.
워크어라운드: `allSteps.slice(-e.delta)`로 마지막 N개만 처리.
### 승인 RPC
- `HandleCascadeUserInteraction``socket hang up` (실패)
- `ResolveOutstandingSteps` → 성공 (폴백으로 사용)
## 미완료
- 승인 버튼 Discord→Extension→LS 경로: `HandleCascadeUserInteraction` 파라미터 정확히 확인 필요
- AI 텍스트: 긴 응답(4000자+)의 Discord 분할 표시 테스트 안됨
- 대화형 짧은 응답이 정상 전달되는지 최종 확인 필요

View File

@@ -117,13 +117,6 @@ function writeChatSnapshot(text) {
console.log(`Gravity Bridge: snapshot write error: ${e.message}`); console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
} }
} }
function writePendingApproval(data) {
try {
const filePath = path.join(bridgePath, 'response', 'pending_approval.json');
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
catch { }
}
// ─── Command File Watcher (Discord → Antigravity) ─── // ─── Command File Watcher (Discord → Antigravity) ───
function processCommandFile(filePath) { function processCommandFile(filePath) {
try { try {
@@ -227,6 +220,7 @@ async function initSDK(context) {
} }
// Track last seen step per session to avoid re-fetching // Track last seen step per session to avoid re-fetching
const lastSeenStep = new Map(); const lastSeenStep = new Map();
const lastSnapshotText = new Map(); // dedup: last written text per session
function setupMonitor() { function setupMonitor() {
if (!sdk) { if (!sdk) {
return; return;
@@ -235,54 +229,67 @@ function setupMonitor() {
sdk.monitor.onStepCountChanged(async (e) => { sdk.monitor.onStepCountChanged(async (e) => {
console.log(`Gravity Bridge: [SDK] step changed: "${e.title}" step ${e.newCount} (+${e.delta})`); console.log(`Gravity Bridge: [SDK] step changed: "${e.title}" step ${e.newCount} (+${e.delta})`);
try { try {
// Use the correct LS RPC: GetCascadeTrajectorySteps (not GetConversation which doesn't exist) // IMPORTANT: Only fetch NEW steps, never re-fetch history
const fromStep = lastSeenStep.get(e.sessionId) ?? Math.max(0, e.newCount - e.delta); const fromStep = Math.max(lastSeenStep.get(e.sessionId) ?? e.newCount - e.delta, e.newCount - e.delta);
const stepsData = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', { const stepsData = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: e.sessionId, cascadeId: e.sessionId, startStepIndex: fromStep
startStepIndex: fromStep
}); });
lastSeenStep.set(e.sessionId, e.newCount); lastSeenStep.set(e.sessionId, e.newCount);
if (stepsData) { if (stepsData && Array.isArray(stepsData.steps)) {
// Try to extract AI text from the steps response // API may ignore startStepIndex — only process the last e.delta steps
const aiText = extractAIText(stepsData); const allSteps = stepsData.steps;
if (aiText) { const newSteps = allSteps.slice(-e.delta);
const text = `🤖 **${e.title}**\n\n${aiText}`; console.log(`Gravity Bridge: [SDK] processing ${newSteps.length} of ${allSteps.length} steps`);
writeChatSnapshot(text); let lastPlannerText = '';
console.log(`Gravity Bridge: [SDK] relayed AI response (${aiText.length} chars)`); for (const step of newSteps) {
return; const sType = step.type || '';
const sStatus = step.status || '';
// ── RUN_COMMAND + WAITING → Pending Approval ──
if (sType.includes('RUN_COMMAND') && sStatus.includes('WAITING')) {
const rc = step.runCommand || {};
const cmdLine = rc.commandLine || rc.proposedCommandLine || '';
writePendingApproval({
conversation_id: e.sessionId,
command: cmdLine,
description: `💻 ${e.title}\n\`\`\`\n${cmdLine}\n\`\`\`\ncwd: ${rc.cwd || ''}`,
});
console.log(`Gravity Bridge: [SDK] ⏳ pending: "${cmdLine.substring(0, 100)}"`);
continue;
} }
// Log the raw structure for debugging // ── PLANNER_RESPONSE → collect AI text (COMPLETED/DONE only) ──
console.log(`Gravity Bridge: [SDK] steps data keys: ${JSON.stringify(Object.keys(stepsData))}`); if (sType.includes('PLANNER_RESPONSE')) {
if (!sStatus.includes('COMPLETED') && !sStatus.includes('DONE')) {
continue;
}
const pr = step.plannerResponse;
// Use confirmed field: plannerResponse.response or .modifiedResponse
const responseText = pr?.modifiedResponse || pr?.response || '';
if (responseText && typeof responseText === 'string' && responseText.length > 0) {
lastPlannerText = responseText; // Overwrite — last one wins
console.log(`Gravity Bridge: [SDK] 📝 planner response found (${responseText.length} chars)`);
}
continue;
}
}
// Write the LAST planner response as snapshot (with dedup)
if (lastPlannerText && lastPlannerText !== lastSnapshotText.get(e.sessionId)) {
lastSnapshotText.set(e.sessionId, lastPlannerText);
writeChatSnapshot(`🤖 **${e.title}**\n\n${lastPlannerText}`);
console.log(`Gravity Bridge: [SDK] 💬 snapshot written (${lastPlannerText.length} chars)`);
}
return;
} }
} }
catch (err) { catch (err) {
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectorySteps error: ${err.message}`); console.log(`Gravity Bridge: [SDK] steps error: ${err.message}`);
// Fallback: try GetCascadeTrajectory (full trajectory, heavier)
try {
const fullTraj = await sdk.ls.rawRPC('GetCascadeTrajectory', {
cascadeId: e.sessionId
});
if (fullTraj) {
const aiText = extractAIText(fullTraj);
if (aiText) {
writeChatSnapshot(`🤖 **${e.title}**\n\n${aiText}`);
console.log(`Gravity Bridge: [SDK] relayed via GetCascadeTrajectory (${aiText.length} chars)`);
return;
} }
console.log(`Gravity Bridge: [SDK] trajectory keys: ${JSON.stringify(Object.keys(fullTraj))}`); // Fallback
}
}
catch (err2) {
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectory also failed: ${err2.message}`);
}
}
// Fallback: just send the title + step info
lastSeenStep.set(e.sessionId, e.newCount); lastSeenStep.set(e.sessionId, e.newCount);
writeChatSnapshot(`🤖 **${e.title}**\n\n(step ${e.newCount}, +${e.delta})`);
}); });
// New conversation started // New conversation started
sdk.monitor.onNewConversation(() => { sdk.monitor.onNewConversation((e) => {
console.log('Gravity Bridge: [SDK] new conversation detected'); console.log(`Gravity Bridge: [SDK] new conversation: ${e.title}`);
writeChatSnapshot(`🚀 **${e.title}** — 새 대화 시작`);
}); });
// Active session changed // Active session changed
sdk.monitor.onActiveSessionChanged((e) => { sdk.monitor.onActiveSessionChanged((e) => {
@@ -296,50 +303,182 @@ function setupMonitor() {
sdk.monitor.start(3000, 2000); sdk.monitor.start(3000, 2000);
console.log('Gravity Bridge: [SDK] monitor started (USS 3s, trajectory 2s)'); console.log('Gravity Bridge: [SDK] monitor started (USS 3s, trajectory 2s)');
} }
// ─── Response Watcher (Discord approval → Antigravity RPC) ───
let responseWatcher = null;
function setupResponseWatcher() {
const responseDir = path.join(bridgePath, 'response');
if (!fs.existsSync(responseDir)) {
fs.mkdirSync(responseDir, { recursive: true });
}
try {
responseWatcher = fs.watch(responseDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
setTimeout(() => processResponseFile(fp), 300);
}
}
});
console.log('Gravity Bridge: response watcher started');
}
catch (e) {
console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
}
}
async function processResponseFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content);
console.log(`Gravity Bridge: [RESPONSE] request_id=${resp.request_id} approved=${resp.approved}`);
if (!sdk) {
console.log('Gravity Bridge: [RESPONSE] SDK not available');
return;
}
// Find matching pending request for session_id
const pendingDir = path.join(bridgePath, 'pending');
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
let sessionId = '';
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
sessionId = pending.conversation_id || '';
}
catch { }
}
if (sessionId && resp.approved) {
try {
await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
approved: true,
});
console.log('Gravity Bridge: [RESPONSE] ✅ approved via HandleCascadeUserInteraction');
}
catch (e) {
console.log(`Gravity Bridge: [RESPONSE] HandleCascadeUserInteraction failed: ${e.message}`);
try {
await sdk.ls.rawRPC('ResolveOutstandingSteps', { cascadeId: sessionId });
console.log('Gravity Bridge: [RESPONSE] ✅ approved via ResolveOutstandingSteps');
}
catch (e2) {
console.log(`Gravity Bridge: [RESPONSE] ResolveOutstandingSteps also failed: ${e2.message}`);
}
}
}
else {
console.log(`Gravity Bridge: [RESPONSE] ${resp.approved ? '✅' : '❌'} (session=${sessionId || 'unknown'})`);
}
try {
fs.unlinkSync(filePath);
}
catch { }
}
catch (e) {
console.log(`Gravity Bridge: [RESPONSE] error: ${e.message}`);
}
}
/** /**
* Extract AI response text from LS RPC step/trajectory data. * Extract AI text from a PLANNER_RESPONSE step.
* The exact structure depends on the protobuf schema — we try multiple paths. * Known structure: {type, status, metadata, plannerResponse, ephemeralMessage, ...}
* ephemeralMessage = system prompt (SKIP), plannerResponse = AI content
*/ */
function extractAIText(data) { function extractPlannerText(step) {
if (!data) { if (!step) {
return null; return null;
} }
// Try common protobuf response patterns // Fields to SKIP — not user-facing content
// Pattern 1: steps array with content const SKIP_FIELDS = new Set([
const steps = data.steps || data.trajectorySteps || data.cascadeSteps; 'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
if (Array.isArray(steps) && steps.length > 0) { 'ephemeralMessage', 'generatorModel', 'requestedModel',
// Find the last step with AI content 'executionId', 'sourceTrajectoryStepInfo', 'stepIndex',
for (let i = steps.length - 1; i >= 0; i--) { 'viewableAt', 'createdAt', 'finishedGeneratingAt',
const step = steps[i]; 'lastCompletedChunkAt', 'source', 'stepGenerationVersion'
// PlannerResponse / assistant content ]);
const content = step.content || step.text || step.summary || // plannerResponse can be string or object
step.plannerResponse || step.assistantMessage || const pr = step.plannerResponse;
step.response?.content || step.response?.text; if (typeof pr === 'string' && pr.length > 10) {
if (typeof content === 'string' && content.length > 10) { return filterEphemeral(pr);
return content; }
if (pr && typeof pr === 'object') {
// Try known content fields first (NOT thinking/stopReason)
const text = pr.content || pr.text || pr.summary || pr.message || pr.response || pr.output;
if (typeof text === 'string' && text.length > 10) {
return filterEphemeral(text);
}
// Search other fields, but skip non-content ones
for (const key of Object.keys(pr)) {
if (SKIP_FIELDS.has(key))
continue;
const val = pr[key];
if (typeof val === 'string' && val.length > 50) { // Higher threshold
const filtered = filterEphemeral(val);
if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in plannerResponse.${key} (${filtered.length} chars)`);
return filtered;
} }
} }
} }
// Pattern 2: messages array }
const messages = data.messages || data.chatMessages; // Try other step fields (skip known non-content)
if (Array.isArray(messages) && messages.length > 0) { for (const key of Object.keys(step)) {
for (let i = messages.length - 1; i >= 0; i--) { if (SKIP_FIELDS.has(key) || key === 'plannerResponse')
const msg = messages[i]; continue;
if ((msg.role === 'assistant' || msg.type === 'assistant') && msg.content) { const val = step[key];
return msg.content; if (typeof val === 'string' && val.length > 50) {
const filtered = filterEphemeral(val);
if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in step.${key}`);
return filtered;
} }
} }
} }
// Pattern 3: nested trajectory object
if (data.trajectory) {
return extractAIText(data.trajectory);
}
// Pattern 4: single step response
if (data.content && typeof data.content === 'string') {
return data.content;
}
return null; return null;
} }
/** Filter out system ephemeral messages and non-content strings. */
function filterEphemeral(text) {
if (!text || text.length < 10) {
return null;
}
// Skip system prompt metadata
if (text.includes('<EPHEMERAL_MESSAGE>') || text.includes('<ephemeral_message>')) {
return null;
}
if (text.includes('artifact_reminder') || text.includes('active_task_reminder')) {
return null;
}
if (text.includes('no_active_task_reminder')) {
return null;
}
// Skip base64/crypto strings (no spaces, mostly alphanumeric)
if (!text.includes(' ') && /^[A-Za-z0-9+/=_\-]{50,}$/.test(text)) {
return null;
}
return text;
}
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
function writePendingApproval(data) {
try {
const pendingDir = path.join(bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) {
fs.mkdirSync(pendingDir, { recursive: true });
}
const id = Date.now().toString();
const payload = {
request_id: id,
conversation_id: data.conversation_id,
command: data.command,
description: data.description,
timestamp: Date.now() / 1000,
status: 'pending',
discord_message_id: 0,
project_name: projectName,
};
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
}
catch (e) {
console.log(`Gravity Bridge: pending write error: ${e.message}`);
}
}
// ─── Activation ─── // ─── Activation ───
async function activate(context) { async function activate(context) {
console.log('Gravity Bridge: activating...'); console.log('Gravity Bridge: activating...');
@@ -389,6 +528,8 @@ async function activate(context) {
} }
// Watch commands directory // Watch commands directory
watchCommandsDir(); watchCommandsDir();
// Watch response directory for approval interactions
setupResponseWatcher();
// Register basic commands // Register basic commands
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', () => { context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', () => {
isActive = true; isActive = true;

File diff suppressed because one or more lines are too long

View File

@@ -83,12 +83,6 @@ function writeChatSnapshot(text: string) {
} }
} }
function writePendingApproval(data: any) {
try {
const filePath = path.join(bridgePath, 'response', 'pending_approval.json');
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch { }
}
// ─── Command File Watcher (Discord → Antigravity) ─── // ─── Command File Watcher (Discord → Antigravity) ───
@@ -199,6 +193,7 @@ async function initSDK(context: vscode.ExtensionContext): Promise<boolean> {
// Track last seen step per session to avoid re-fetching // Track last seen step per session to avoid re-fetching
const lastSeenStep = new Map<string, number>(); const lastSeenStep = new Map<string, number>();
const lastSnapshotText = new Map<string, string>(); // dedup: last written text per session
function setupMonitor() { function setupMonitor() {
if (!sdk) { return; } if (!sdk) { return; }
@@ -208,58 +203,75 @@ function setupMonitor() {
console.log(`Gravity Bridge: [SDK] step changed: "${e.title}" step ${e.newCount} (+${e.delta})`); console.log(`Gravity Bridge: [SDK] step changed: "${e.title}" step ${e.newCount} (+${e.delta})`);
try { try {
// Use the correct LS RPC: GetCascadeTrajectorySteps (not GetConversation which doesn't exist) // IMPORTANT: Only fetch NEW steps, never re-fetch history
const fromStep = lastSeenStep.get(e.sessionId) ?? Math.max(0, e.newCount - e.delta); const fromStep = Math.max(
lastSeenStep.get(e.sessionId) ?? e.newCount - e.delta,
e.newCount - e.delta
);
const stepsData = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', { const stepsData = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: e.sessionId, cascadeId: e.sessionId, startStepIndex: fromStep
startStepIndex: fromStep
}); });
lastSeenStep.set(e.sessionId, e.newCount); lastSeenStep.set(e.sessionId, e.newCount);
if (stepsData) { if (stepsData && Array.isArray(stepsData.steps)) {
// Try to extract AI text from the steps response // API may ignore startStepIndex — only process the last e.delta steps
const aiText = extractAIText(stepsData); const allSteps = stepsData.steps;
if (aiText) { const newSteps = allSteps.slice(-e.delta);
const text = `🤖 **${e.title}**\n\n${aiText}`; console.log(`Gravity Bridge: [SDK] processing ${newSteps.length} of ${allSteps.length} steps`);
writeChatSnapshot(text);
console.log(`Gravity Bridge: [SDK] relayed AI response (${aiText.length} chars)`); let lastPlannerText = '';
return; for (const step of newSteps) {
const sType = step.type || '';
const sStatus = step.status || '';
// ── RUN_COMMAND + WAITING → Pending Approval ──
if (sType.includes('RUN_COMMAND') && sStatus.includes('WAITING')) {
const rc = step.runCommand || {};
const cmdLine = rc.commandLine || rc.proposedCommandLine || '';
writePendingApproval({
conversation_id: e.sessionId,
command: cmdLine,
description: `💻 ${e.title}\n\`\`\`\n${cmdLine}\n\`\`\`\ncwd: ${rc.cwd || ''}`,
});
console.log(`Gravity Bridge: [SDK] ⏳ pending: "${cmdLine.substring(0, 100)}"`);
continue;
} }
// Log the raw structure for debugging
console.log(`Gravity Bridge: [SDK] steps data keys: ${JSON.stringify(Object.keys(stepsData))}`); // ── PLANNER_RESPONSE → collect AI text (COMPLETED/DONE only) ──
if (sType.includes('PLANNER_RESPONSE')) {
if (!sStatus.includes('COMPLETED') && !sStatus.includes('DONE')) {
continue;
}
const pr = step.plannerResponse;
// Use confirmed field: plannerResponse.response or .modifiedResponse
const responseText = pr?.modifiedResponse || pr?.response || '';
if (responseText && typeof responseText === 'string' && responseText.length > 0) {
lastPlannerText = responseText; // Overwrite — last one wins
console.log(`Gravity Bridge: [SDK] 📝 planner response found (${responseText.length} chars)`);
}
continue;
}
}
// Write the LAST planner response as snapshot (with dedup)
if (lastPlannerText && lastPlannerText !== lastSnapshotText.get(e.sessionId)) {
lastSnapshotText.set(e.sessionId, lastPlannerText);
writeChatSnapshot(`🤖 **${e.title}**\n\n${lastPlannerText}`);
console.log(`Gravity Bridge: [SDK] 💬 snapshot written (${lastPlannerText.length} chars)`);
}
return;
} }
} catch (err: any) { } catch (err: any) {
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectorySteps error: ${err.message}`); console.log(`Gravity Bridge: [SDK] steps error: ${err.message}`);
// Fallback: try GetCascadeTrajectory (full trajectory, heavier)
try {
const fullTraj = await sdk.ls.rawRPC('GetCascadeTrajectory', {
cascadeId: e.sessionId
});
if (fullTraj) {
const aiText = extractAIText(fullTraj);
if (aiText) {
writeChatSnapshot(`🤖 **${e.title}**\n\n${aiText}`);
console.log(`Gravity Bridge: [SDK] relayed via GetCascadeTrajectory (${aiText.length} chars)`);
return;
} }
console.log(`Gravity Bridge: [SDK] trajectory keys: ${JSON.stringify(Object.keys(fullTraj))}`); // Fallback
}
} catch (err2: any) {
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectory also failed: ${err2.message}`);
}
}
// Fallback: just send the title + step info
lastSeenStep.set(e.sessionId, e.newCount); lastSeenStep.set(e.sessionId, e.newCount);
writeChatSnapshot(`🤖 **${e.title}**\n\n(step ${e.newCount}, +${e.delta})`);
}); });
// New conversation started // New conversation started
sdk.monitor.onNewConversation(() => { sdk.monitor.onNewConversation((e: any) => {
console.log('Gravity Bridge: [SDK] new conversation detected'); console.log(`Gravity Bridge: [SDK] new conversation: ${e.title}`);
writeChatSnapshot(`🚀 **${e.title}** — 새 대화 시작`);
}); });
// Active session changed // Active session changed
@@ -277,54 +289,171 @@ function setupMonitor() {
console.log('Gravity Bridge: [SDK] monitor started (USS 3s, trajectory 2s)'); console.log('Gravity Bridge: [SDK] monitor started (USS 3s, trajectory 2s)');
} }
// ─── Response Watcher (Discord approval → Antigravity RPC) ───
let responseWatcher: fs.FSWatcher | null = null;
function setupResponseWatcher() {
const responseDir = path.join(bridgePath, 'response');
if (!fs.existsSync(responseDir)) {
fs.mkdirSync(responseDir, { recursive: true });
}
try {
responseWatcher = fs.watch(responseDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
setTimeout(() => processResponseFile(fp), 300);
}
}
});
console.log('Gravity Bridge: response watcher started');
} catch (e: any) {
console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
}
}
async function processResponseFile(filePath: string) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content);
console.log(`Gravity Bridge: [RESPONSE] request_id=${resp.request_id} approved=${resp.approved}`);
if (!sdk) {
console.log('Gravity Bridge: [RESPONSE] SDK not available');
return;
}
// Find matching pending request for session_id
const pendingDir = path.join(bridgePath, 'pending');
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
let sessionId = '';
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
sessionId = pending.conversation_id || '';
} catch { }
}
if (sessionId && resp.approved) {
try {
await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
approved: true,
});
console.log('Gravity Bridge: [RESPONSE] ✅ approved via HandleCascadeUserInteraction');
} catch (e: any) {
console.log(`Gravity Bridge: [RESPONSE] HandleCascadeUserInteraction failed: ${e.message}`);
try {
await sdk.ls.rawRPC('ResolveOutstandingSteps', { cascadeId: sessionId });
console.log('Gravity Bridge: [RESPONSE] ✅ approved via ResolveOutstandingSteps');
} catch (e2: any) {
console.log(`Gravity Bridge: [RESPONSE] ResolveOutstandingSteps also failed: ${e2.message}`);
}
}
} else {
console.log(`Gravity Bridge: [RESPONSE] ${resp.approved ? '✅' : '❌'} (session=${sessionId || 'unknown'})`);
}
try { fs.unlinkSync(filePath); } catch { }
} catch (e: any) {
console.log(`Gravity Bridge: [RESPONSE] error: ${e.message}`);
}
}
/** /**
* Extract AI response text from LS RPC step/trajectory data. * Extract AI text from a PLANNER_RESPONSE step.
* The exact structure depends on the protobuf schema — we try multiple paths. * Known structure: {type, status, metadata, plannerResponse, ephemeralMessage, ...}
* ephemeralMessage = system prompt (SKIP), plannerResponse = AI content
*/ */
function extractAIText(data: any): string | null { function extractPlannerText(step: any): string | null {
if (!data) { return null; } if (!step) { return null; }
// Try common protobuf response patterns // Fields to SKIP — not user-facing content
// Pattern 1: steps array with content const SKIP_FIELDS = new Set([
const steps = data.steps || data.trajectorySteps || data.cascadeSteps; 'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
if (Array.isArray(steps) && steps.length > 0) { 'ephemeralMessage', 'generatorModel', 'requestedModel',
// Find the last step with AI content 'executionId', 'sourceTrajectoryStepInfo', 'stepIndex',
for (let i = steps.length - 1; i >= 0; i--) { 'viewableAt', 'createdAt', 'finishedGeneratingAt',
const step = steps[i]; 'lastCompletedChunkAt', 'source', 'stepGenerationVersion'
// PlannerResponse / assistant content ]);
const content = step.content || step.text || step.summary ||
step.plannerResponse || step.assistantMessage || // plannerResponse can be string or object
step.response?.content || step.response?.text; const pr = step.plannerResponse;
if (typeof content === 'string' && content.length > 10) { if (typeof pr === 'string' && pr.length > 10) {
return content; return filterEphemeral(pr);
}
if (pr && typeof pr === 'object') {
// Try known content fields first (NOT thinking/stopReason)
const text = pr.content || pr.text || pr.summary || pr.message || pr.response || pr.output;
if (typeof text === 'string' && text.length > 10) {
return filterEphemeral(text);
}
// Search other fields, but skip non-content ones
for (const key of Object.keys(pr)) {
if (SKIP_FIELDS.has(key)) continue;
const val = pr[key];
if (typeof val === 'string' && val.length > 50) { // Higher threshold
const filtered = filterEphemeral(val);
if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in plannerResponse.${key} (${filtered.length} chars)`);
return filtered;
}
} }
} }
} }
// Pattern 2: messages array // Try other step fields (skip known non-content)
const messages = data.messages || data.chatMessages; for (const key of Object.keys(step)) {
if (Array.isArray(messages) && messages.length > 0) { if (SKIP_FIELDS.has(key) || key === 'plannerResponse') continue;
for (let i = messages.length - 1; i >= 0; i--) { const val = step[key];
const msg = messages[i]; if (typeof val === 'string' && val.length > 50) {
if ((msg.role === 'assistant' || msg.type === 'assistant') && msg.content) { const filtered = filterEphemeral(val);
return msg.content; if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in step.${key}`);
return filtered;
} }
} }
} }
// Pattern 3: nested trajectory object
if (data.trajectory) {
return extractAIText(data.trajectory);
}
// Pattern 4: single step response
if (data.content && typeof data.content === 'string') {
return data.content;
}
return null; return null;
} }
/** Filter out system ephemeral messages and non-content strings. */
function filterEphemeral(text: string): string | null {
if (!text || text.length < 10) { return null; }
// Skip system prompt metadata
if (text.includes('<EPHEMERAL_MESSAGE>') || text.includes('<ephemeral_message>')) { return null; }
if (text.includes('artifact_reminder') || text.includes('active_task_reminder')) { return null; }
if (text.includes('no_active_task_reminder')) { return null; }
// Skip base64/crypto strings (no spaces, mostly alphanumeric)
if (!text.includes(' ') && /^[A-Za-z0-9+/=_\-]{50,}$/.test(text)) { return null; }
return text;
}
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
function writePendingApproval(data: { conversation_id: string; command: string; description: string }) {
try {
const pendingDir = path.join(bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
const id = Date.now().toString();
const payload = {
request_id: id,
conversation_id: data.conversation_id,
command: data.command,
description: data.description,
timestamp: Date.now() / 1000,
status: 'pending',
discord_message_id: 0,
project_name: projectName,
};
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
} catch (e: any) {
console.log(`Gravity Bridge: pending write error: ${e.message}`);
}
}
// ─── Activation ─── // ─── Activation ───
export async function activate(context: vscode.ExtensionContext) { export async function activate(context: vscode.ExtensionContext) {
@@ -383,6 +512,8 @@ export async function activate(context: vscode.ExtensionContext) {
// Watch commands directory // Watch commands directory
watchCommandsDir(); watchCommandsDir();
// Watch response directory for approval interactions
setupResponseWatcher();
// Register basic commands // Register basic commands
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand('gravityBridge.start', () => { vscode.commands.registerCommand('gravityBridge.start', () => {