feat: LS ConnectRPC bridge for AI response relay to Discord

This commit is contained in:
2026-03-07 18:15:01 +09:00
parent 150967deee
commit 91b3a7ef20
4 changed files with 484 additions and 63 deletions

View File

@@ -118,6 +118,250 @@ function activate(context) {
context.subscriptions.push(statusBar);
// Register commands
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', startBridge), vscode.commands.registerCommand('gravityBridge.stop', stopBridge), vscode.commands.registerCommand('gravityBridge.connect', connectSession), vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)), vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)));
// === LS ConnectRPC Bridge: Relay AI responses to Discord ===
let lsPort = null;
let lsCsrf = '';
let lsPid = null;
let lastStepIndex = {}; // cascadeId → last known step index
async function discoverLS() {
return new Promise((resolve) => {
// Phase 1: Find LS process → PID + CSRF token
cp.exec('powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object {$_.Name -eq \'language_server_exe.exe\' -or ($_.CommandLine -and $_.CommandLine -like \'*language_server*\' -and $_.CommandLine -notlike \'*powershell*\')} | Select-Object ProcessId, CommandLine | ConvertTo-Json"', { maxBuffer: 2 * 1024 * 1024 }, (err, stdout) => {
if (err || !stdout.trim()) {
console.log(`Gravity Bridge: [LS] process not found`);
resolve(false);
return;
}
try {
let procs = JSON.parse(stdout.trim());
if (!Array.isArray(procs)) {
procs = [procs];
}
// Find a process with csrf_token in command line
for (const proc of procs) {
const cmd = proc.CommandLine || '';
const csrfM = cmd.match(/--csrf_token[= ]([^\s"]+)/);
if (csrfM) {
lsPid = proc.ProcessId;
lsCsrf = csrfM[1];
console.log(`Gravity Bridge: [LS] PID=${lsPid}, CSRF=${lsCsrf.substring(0, 12)}...`);
break;
}
}
}
catch (e) {
console.log(`Gravity Bridge: [LS] parse error: ${e}`);
}
if (!lsPid || !lsCsrf) {
resolve(false);
return;
}
// Phase 2: netstat → find LS listening ports
cp.exec(`netstat -ano | findstr "LISTENING" | findstr " ${lsPid}"`, { maxBuffer: 512 * 1024 }, async (err2, stdout2) => {
if (err2 || !stdout2.trim()) {
console.log(`Gravity Bridge: [LS] no listening ports found for PID ${lsPid}`);
resolve(false);
return;
}
// Parse ports
const ports = [];
for (const line of stdout2.split('\n')) {
const m = line.match(/:(\d+)\s+.*LISTENING/);
if (m) {
ports.push(parseInt(m[1]));
}
}
const uniquePorts = [...new Set(ports)].sort((a, b) => a - b);
console.log(`Gravity Bridge: [LS] ports for PID ${lsPid}: ${uniquePorts.join(', ')}`);
// Try ConnectRPC probe on each port (HTTP first, then HTTPS)
for (const port of uniquePorts) {
const ok = await probeLSPort(port);
if (ok) {
lsPort = port;
console.log(`Gravity Bridge: [LS] ✅ ConnectRPC active on port ${port}`);
resolve(true);
return;
}
}
console.log(`Gravity Bridge: [LS] no ConnectRPC port responded`);
resolve(false);
});
});
});
}
function probeLSPort(port) {
return new Promise((resolve) => {
const http = require('http');
const https = require('https');
// Try HTTP first (extension_server uses HTTP)
const tryProto = (proto, useTls) => {
const req = proto.request({
hostname: '127.0.0.1',
port,
path: '/exa.language_server_pb.LanguageServerService/GetTrajectoryDescriptions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-codeium-csrf-token': lsCsrf,
},
rejectUnauthorized: false,
timeout: 2000,
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
// Any response (even error) means ConnectRPC is here
console.log(`Gravity Bridge: [LS] port ${port} (${useTls ? 'https' : 'http'}) status=${res.statusCode} body=${data.substring(0, 200)}`);
resolve(res.statusCode !== 404);
});
});
req.on('error', () => {
if (!useTls) {
// Try HTTPS
tryProto(https, true);
}
else {
resolve(false);
}
});
req.on('timeout', () => { req.destroy(); resolve(false); });
req.write('{}');
req.end();
};
tryProto(http, false);
});
}
async function lsRPC(method, payload = {}) {
if (!lsPort || !lsCsrf) {
return null;
}
return new Promise((resolve) => {
const http = require('http');
const https = require('https');
const proto = lsPort > 40000 ? https : http; // heuristic
const body = JSON.stringify(payload);
const req = proto.request({
hostname: '127.0.0.1',
port: lsPort,
path: `/exa.language_server_pb.LanguageServerService/${method}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-codeium-csrf-token': lsCsrf,
'Content-Length': Buffer.byteLength(body),
},
rejectUnauthorized: false,
timeout: 5000,
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
resolve(JSON.parse(data));
}
catch {
resolve(data);
}
});
});
req.on('error', (e) => {
console.log(`Gravity Bridge: [LS RPC] ${method} error: ${e.message}`);
resolve(null);
});
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
async function pollConversations() {
if (!lsPort) {
return;
}
try {
// Get trajectory descriptions — lightweight list of all conversations
const result = await lsRPC('GetTrajectoryDescriptions');
if (!result) {
return;
}
// Debug: log structure on first call
if (Object.keys(lastStepIndex).length === 0) {
console.log(`Gravity Bridge: [LS] trajectories: ${JSON.stringify(result).substring(0, 500)}`);
}
const trajectories = result.trajectories || result.trajectory_descriptions || [];
if (!Array.isArray(trajectories)) {
return;
}
for (const traj of trajectories) {
const id = traj.googleAgentId || traj.google_agent_id || traj.id || '';
const stepIdx = traj.lastStepIndex ?? traj.last_step_index ?? traj.step_count ?? 0;
const prev = lastStepIndex[id];
if (prev !== undefined && stepIdx > prev) {
// New steps! Fetch full trajectory to get AI response
console.log(`Gravity Bridge: [LS] ${id.substring(0, 8)} new steps: ${prev}${stepIdx}`);
const full = await lsRPC('GetCascadeTrajectory', {
googleAgentId: id,
trajectoryId: traj.trajectoryId || traj.trajectory_id || '',
});
if (full) {
extractAndRelay(full, prev, stepIdx);
}
}
lastStepIndex[id] = stepIdx;
}
}
catch (e) {
console.log(`Gravity Bridge: [LS poll] error: ${e}`);
}
}
function extractAndRelay(trajectory, fromStep, toStep) {
// Extract PlannerResponse or assistant messages from trajectory steps
const steps = trajectory.steps || trajectory.cortex_steps || [];
const messages = [];
for (const step of steps) {
const idx = step.index ?? step.step_index ?? 0;
if (idx <= fromStep) {
continue;
}
const type = step.type || step.step_type || '';
const content = step.content || step.summary || step.text || '';
// PlannerResponse = AI's text output to user
if ((type === 'PlannerResponse' || type === 'planner_response') && content) {
messages.push(content);
}
// Also capture user-facing messages
if (step.data?.content && typeof step.data.content === 'string') {
messages.push(step.data.content);
}
}
// Fallback: if no detailed steps, try messages array
if (messages.length === 0) {
const msgs = trajectory.messages || trajectory.chat_messages || [];
for (const msg of msgs) {
if (msg.role === 'assistant' && msg.content) {
messages.push(msg.content);
}
}
}
if (messages.length > 0) {
const combined = messages.join('\n\n---\n\n');
writeChatSnapshot(combined);
console.log(`Gravity Bridge: [LS] relayed ${messages.length} response(s) to Discord`);
}
}
// Start LS bridge after a delay
setTimeout(async () => {
const found = await discoverLS();
if (found) {
console.log(`Gravity Bridge: [LS] bridge active — polling every 5s`);
// Initialize step counts
await pollConversations();
// Start polling loop
setInterval(pollConversations, 5000);
}
else {
console.log(`Gravity Bridge: [LS] bridge NOT available — AI responses won't relay`);
}
}, 8000);
// Chat document change listener — captures AI text responses
context.subscriptions.push(vscode.workspace.onDidChangeTextDocument((event) => {
handleChatDocumentChange(event);

File diff suppressed because one or more lines are too long