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:
@@ -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;
|
||||
}
|
||||
// ── 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) {
|
||||
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] trajectory keys: ${JSON.stringify(Object.keys(fullTraj))}`);
|
||||
}
|
||||
}
|
||||
catch (err2) {
|
||||
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectory also failed: ${err2.message}`);
|
||||
}
|
||||
console.log(`Gravity Bridge: [SDK] steps error: ${err.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;
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
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] trajectory keys: ${JSON.stringify(Object.keys(fullTraj))}`);
|
||||
}
|
||||
} catch (err2: any) {
|
||||
console.log(`Gravity Bridge: [SDK] GetCascadeTrajectory also failed: ${err2.message}`);
|
||||
}
|
||||
console.log(`Gravity Bridge: [SDK] steps error: ${err.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