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:
9
bot.py
9
bot.py
@@ -579,7 +579,16 @@ class GravityBot(commands.Bot):
|
||||
color=discord.Color.purple(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
try:
|
||||
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
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
| # | 시간 | 작업 | 커밋 | 상태 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | 01:00 | Extension↔Bot 프로토콜 불일치 3건 수정 + sql-wasm 번들링 | `e4dc1b1` | 🔧 |
|
||||
| 2 | 01:45~02:25 | Discord Bridge 디버깅: step 구조 파악, 승인 버튼, AI 텍스트 릴레이 | TBD | ✅ |
|
||||
|
||||
47
docs/devlog/entries/20260308-002.md
Normal file
47
docs/devlog/entries/20260308-002.md
Normal 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 분할 표시 테스트 안됨
|
||||
- 대화형 짧은 응답이 정상 전달되는지 최종 확인 필요
|
||||
@@ -117,13 +117,6 @@ function writeChatSnapshot(text) {
|
||||
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) ───
|
||||
function processCommandFile(filePath) {
|
||||
try {
|
||||
@@ -227,6 +220,7 @@ async function initSDK(context) {
|
||||
}
|
||||
// Track last seen step per session to avoid re-fetching
|
||||
const lastSeenStep = new Map();
|
||||
const lastSnapshotText = new Map(); // dedup: last written text per session
|
||||
function setupMonitor() {
|
||||
if (!sdk) {
|
||||
return;
|
||||
@@ -235,54 +229,67 @@ function setupMonitor() {
|
||||
sdk.monitor.onStepCountChanged(async (e) => {
|
||||
console.log(`Gravity Bridge: [SDK] step changed: "${e.title}" step ${e.newCount} (+${e.delta})`);
|
||||
try {
|
||||
// Use the correct LS RPC: GetCascadeTrajectorySteps (not GetConversation which doesn't exist)
|
||||
const fromStep = lastSeenStep.get(e.sessionId) ?? Math.max(0, e.newCount - e.delta);
|
||||
// IMPORTANT: Only fetch NEW steps, never re-fetch history
|
||||
const fromStep = Math.max(lastSeenStep.get(e.sessionId) ?? e.newCount - e.delta, e.newCount - e.delta);
|
||||
const stepsData = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
||||
cascadeId: e.sessionId,
|
||||
startStepIndex: fromStep
|
||||
cascadeId: e.sessionId, startStepIndex: fromStep
|
||||
});
|
||||
lastSeenStep.set(e.sessionId, e.newCount);
|
||||
if (stepsData) {
|
||||
// Try to extract AI text from the steps response
|
||||
const aiText = extractAIText(stepsData);
|
||||
if (aiText) {
|
||||
const text = `🤖 **${e.title}**\n\n${aiText}`;
|
||||
writeChatSnapshot(text);
|
||||
console.log(`Gravity Bridge: [SDK] relayed AI response (${aiText.length} chars)`);
|
||||
return;
|
||||
if (stepsData && Array.isArray(stepsData.steps)) {
|
||||
// API may ignore startStepIndex — only process the last e.delta steps
|
||||
const allSteps = stepsData.steps;
|
||||
const newSteps = allSteps.slice(-e.delta);
|
||||
console.log(`Gravity Bridge: [SDK] processing ${newSteps.length} of ${allSteps.length} steps`);
|
||||
let lastPlannerText = '';
|
||||
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) {
|
||||
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectorySteps 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] steps error: ${err.message}`);
|
||||
}
|
||||
console.log(`Gravity Bridge: [SDK] trajectory keys: ${JSON.stringify(Object.keys(fullTraj))}`);
|
||||
}
|
||||
}
|
||||
catch (err2) {
|
||||
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectory also failed: ${err2.message}`);
|
||||
}
|
||||
}
|
||||
// Fallback: just send the title + step info
|
||||
// Fallback
|
||||
lastSeenStep.set(e.sessionId, e.newCount);
|
||||
writeChatSnapshot(`🤖 **${e.title}**\n\n(step ${e.newCount}, +${e.delta})`);
|
||||
});
|
||||
// New conversation started
|
||||
sdk.monitor.onNewConversation(() => {
|
||||
console.log('Gravity Bridge: [SDK] new conversation detected');
|
||||
sdk.monitor.onNewConversation((e) => {
|
||||
console.log(`Gravity Bridge: [SDK] new conversation: ${e.title}`);
|
||||
writeChatSnapshot(`🚀 **${e.title}** — 새 대화 시작`);
|
||||
});
|
||||
// Active session changed
|
||||
sdk.monitor.onActiveSessionChanged((e) => {
|
||||
@@ -296,50 +303,182 @@ function setupMonitor() {
|
||||
sdk.monitor.start(3000, 2000);
|
||||
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.
|
||||
* The exact structure depends on the protobuf schema — we try multiple paths.
|
||||
* Extract AI text from a PLANNER_RESPONSE step.
|
||||
* Known structure: {type, status, metadata, plannerResponse, ephemeralMessage, ...}
|
||||
* ephemeralMessage = system prompt (SKIP), plannerResponse = AI content
|
||||
*/
|
||||
function extractAIText(data) {
|
||||
if (!data) {
|
||||
function extractPlannerText(step) {
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
// Try common protobuf response patterns
|
||||
// Pattern 1: steps array with content
|
||||
const steps = data.steps || data.trajectorySteps || data.cascadeSteps;
|
||||
if (Array.isArray(steps) && steps.length > 0) {
|
||||
// Find the last step with AI content
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
const step = steps[i];
|
||||
// PlannerResponse / assistant content
|
||||
const content = step.content || step.text || step.summary ||
|
||||
step.plannerResponse || step.assistantMessage ||
|
||||
step.response?.content || step.response?.text;
|
||||
if (typeof content === 'string' && content.length > 10) {
|
||||
return content;
|
||||
// Fields to SKIP — not user-facing content
|
||||
const SKIP_FIELDS = new Set([
|
||||
'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
|
||||
'ephemeralMessage', 'generatorModel', 'requestedModel',
|
||||
'executionId', 'sourceTrajectoryStepInfo', 'stepIndex',
|
||||
'viewableAt', 'createdAt', 'finishedGeneratingAt',
|
||||
'lastCompletedChunkAt', 'source', 'stepGenerationVersion'
|
||||
]);
|
||||
// plannerResponse can be string or object
|
||||
const pr = step.plannerResponse;
|
||||
if (typeof pr === 'string' && pr.length > 10) {
|
||||
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
|
||||
const messages = data.messages || data.chatMessages;
|
||||
if (Array.isArray(messages) && messages.length > 0) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if ((msg.role === 'assistant' || msg.type === 'assistant') && msg.content) {
|
||||
return msg.content;
|
||||
}
|
||||
// Try other step fields (skip known non-content)
|
||||
for (const key of Object.keys(step)) {
|
||||
if (SKIP_FIELDS.has(key) || key === 'plannerResponse')
|
||||
continue;
|
||||
const val = step[key];
|
||||
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;
|
||||
}
|
||||
/** 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 ───
|
||||
async function activate(context) {
|
||||
console.log('Gravity Bridge: activating...');
|
||||
@@ -389,6 +528,8 @@ async function activate(context) {
|
||||
}
|
||||
// Watch commands directory
|
||||
watchCommandsDir();
|
||||
// Watch response directory for approval interactions
|
||||
setupResponseWatcher();
|
||||
// Register basic commands
|
||||
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', () => {
|
||||
isActive = true;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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) ───
|
||||
|
||||
@@ -199,6 +193,7 @@ async function initSDK(context: vscode.ExtensionContext): Promise<boolean> {
|
||||
|
||||
// Track last seen step per session to avoid re-fetching
|
||||
const lastSeenStep = new Map<string, number>();
|
||||
const lastSnapshotText = new Map<string, string>(); // dedup: last written text per session
|
||||
|
||||
function setupMonitor() {
|
||||
if (!sdk) { return; }
|
||||
@@ -208,58 +203,75 @@ function setupMonitor() {
|
||||
console.log(`Gravity Bridge: [SDK] step changed: "${e.title}" step ${e.newCount} (+${e.delta})`);
|
||||
|
||||
try {
|
||||
// Use the correct LS RPC: GetCascadeTrajectorySteps (not GetConversation which doesn't exist)
|
||||
const fromStep = lastSeenStep.get(e.sessionId) ?? Math.max(0, e.newCount - e.delta);
|
||||
|
||||
// IMPORTANT: Only fetch NEW steps, never re-fetch history
|
||||
const fromStep = Math.max(
|
||||
lastSeenStep.get(e.sessionId) ?? e.newCount - e.delta,
|
||||
e.newCount - e.delta
|
||||
);
|
||||
const stepsData = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
||||
cascadeId: e.sessionId,
|
||||
startStepIndex: fromStep
|
||||
cascadeId: e.sessionId, startStepIndex: fromStep
|
||||
});
|
||||
|
||||
lastSeenStep.set(e.sessionId, e.newCount);
|
||||
|
||||
if (stepsData) {
|
||||
// Try to extract AI text from the steps response
|
||||
const aiText = extractAIText(stepsData);
|
||||
if (aiText) {
|
||||
const text = `🤖 **${e.title}**\n\n${aiText}`;
|
||||
writeChatSnapshot(text);
|
||||
console.log(`Gravity Bridge: [SDK] relayed AI response (${aiText.length} chars)`);
|
||||
return;
|
||||
if (stepsData && Array.isArray(stepsData.steps)) {
|
||||
// API may ignore startStepIndex — only process the last e.delta steps
|
||||
const allSteps = stepsData.steps;
|
||||
const newSteps = allSteps.slice(-e.delta);
|
||||
console.log(`Gravity Bridge: [SDK] processing ${newSteps.length} of ${allSteps.length} steps`);
|
||||
|
||||
let lastPlannerText = '';
|
||||
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) {
|
||||
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectorySteps 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] steps error: ${err.message}`);
|
||||
}
|
||||
console.log(`Gravity Bridge: [SDK] trajectory keys: ${JSON.stringify(Object.keys(fullTraj))}`);
|
||||
}
|
||||
} catch (err2: any) {
|
||||
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectory also failed: ${err2.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just send the title + step info
|
||||
// Fallback
|
||||
lastSeenStep.set(e.sessionId, e.newCount);
|
||||
writeChatSnapshot(`🤖 **${e.title}**\n\n(step ${e.newCount}, +${e.delta})`);
|
||||
});
|
||||
|
||||
// New conversation started
|
||||
sdk.monitor.onNewConversation(() => {
|
||||
console.log('Gravity Bridge: [SDK] new conversation detected');
|
||||
sdk.monitor.onNewConversation((e: any) => {
|
||||
console.log(`Gravity Bridge: [SDK] new conversation: ${e.title}`);
|
||||
writeChatSnapshot(`🚀 **${e.title}** — 새 대화 시작`);
|
||||
});
|
||||
|
||||
// Active session changed
|
||||
@@ -277,54 +289,171 @@ function setupMonitor() {
|
||||
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.
|
||||
* The exact structure depends on the protobuf schema — we try multiple paths.
|
||||
* Extract AI text from a PLANNER_RESPONSE step.
|
||||
* Known structure: {type, status, metadata, plannerResponse, ephemeralMessage, ...}
|
||||
* ephemeralMessage = system prompt (SKIP), plannerResponse = AI content
|
||||
*/
|
||||
function extractAIText(data: any): string | null {
|
||||
if (!data) { return null; }
|
||||
function extractPlannerText(step: any): string | null {
|
||||
if (!step) { return null; }
|
||||
|
||||
// Try common protobuf response patterns
|
||||
// Pattern 1: steps array with content
|
||||
const steps = data.steps || data.trajectorySteps || data.cascadeSteps;
|
||||
if (Array.isArray(steps) && steps.length > 0) {
|
||||
// Find the last step with AI content
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
const step = steps[i];
|
||||
// PlannerResponse / assistant content
|
||||
const content = step.content || step.text || step.summary ||
|
||||
step.plannerResponse || step.assistantMessage ||
|
||||
step.response?.content || step.response?.text;
|
||||
if (typeof content === 'string' && content.length > 10) {
|
||||
return content;
|
||||
// Fields to SKIP — not user-facing content
|
||||
const SKIP_FIELDS = new Set([
|
||||
'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
|
||||
'ephemeralMessage', 'generatorModel', 'requestedModel',
|
||||
'executionId', 'sourceTrajectoryStepInfo', 'stepIndex',
|
||||
'viewableAt', 'createdAt', 'finishedGeneratingAt',
|
||||
'lastCompletedChunkAt', 'source', 'stepGenerationVersion'
|
||||
]);
|
||||
|
||||
// plannerResponse can be string or object
|
||||
const pr = step.plannerResponse;
|
||||
if (typeof pr === 'string' && pr.length > 10) {
|
||||
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
|
||||
const messages = data.messages || data.chatMessages;
|
||||
if (Array.isArray(messages) && messages.length > 0) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if ((msg.role === 'assistant' || msg.type === 'assistant') && msg.content) {
|
||||
return msg.content;
|
||||
// Try other step fields (skip known non-content)
|
||||
for (const key of Object.keys(step)) {
|
||||
if (SKIP_FIELDS.has(key) || key === 'plannerResponse') continue;
|
||||
const val = step[key];
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 ───
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
@@ -383,6 +512,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// Watch commands directory
|
||||
watchCommandsDir();
|
||||
|
||||
// Watch response directory for approval interactions
|
||||
setupResponseWatcher();
|
||||
// Register basic commands
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('gravityBridge.start', () => {
|
||||
|
||||
Reference in New Issue
Block a user