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:
11
bot.py
11
bot.py
@@ -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),
|
||||||
)
|
)
|
||||||
await channel.send(embed=embed)
|
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
|
f.unlink() # Cleanup
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
|||||||
@@ -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 | ✅ |
|
||||||
|
|||||||
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}`);
|
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;
|
||||||
|
}
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Log the raw structure for debugging
|
// Write the LAST planner response as snapshot (with dedup)
|
||||||
console.log(`Gravity Bridge: [SDK] steps data keys: ${JSON.stringify(Object.keys(stepsData))}`);
|
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))}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
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
|
// 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')
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
continue;
|
||||||
const msg = messages[i];
|
const val = step[key];
|
||||||
if ((msg.role === 'assistant' || msg.type === 'assistant') && msg.content) {
|
if (typeof val === 'string' && val.length > 50) {
|
||||||
return msg.content;
|
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
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Log the raw structure for debugging
|
|
||||||
console.log(`Gravity Bridge: [SDK] steps data keys: ${JSON.stringify(Object.keys(stepsData))}`);
|
// 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))}`);
|
|
||||||
}
|
|
||||||
} catch (err2: any) {
|
|
||||||
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectory also failed: ${err2.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Fallback
|
||||||
// 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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user