import * as http from 'http'; import * as fs from 'fs'; import { _getBrainDir, findBrainFiles, logError, logInfo, summarizeText } from './utils'; import { getConfig } from './config'; import { IAIService, IBrainService, AIService, BrainService } from './core/services'; import { ISkillInjectionService, FileSystemSkillInjectionService, SkillInjectionError, } from './skills/skillInjectionService'; import { resolveAgentSkillsDir } from './lib/paths'; export interface BridgeInterface { injectSystemMessage(msg: string): void; getHistoryText(): string; sendPromptFromExtension(prompt: string): void; brainEnabled: boolean; findBrainFiles(dir: string): string[]; } /** * BridgeServer: * 외부 툴(EZER, A.U 등)과 Astra 확장을 연결하는 통신 브릿지. * 서비스 레이어(AIService, BrainService)를 통해 비즈니스 로직을 분리하여 유지보수성을 극대화했습니다. */ export class BridgeServer { private server: http.Server | null = null; private aiService: IAIService; private brainService: IBrainService; private skillService: ISkillInjectionService; constructor( private provider: BridgeInterface, aiService?: IAIService, brainService?: IBrainService, skillService?: ISkillInjectionService ) { // 의존성 주입 (DIP): 기본값 제공 및 외부 주입 허용 this.aiService = aiService || new AIService(); this.brainService = brainService || new BrainService(); this.skillService = skillService || new FileSystemSkillInjectionService({ resolveSkillsDir: resolveAgentSkillsDir, onInjected: (result, req) => { this.provider.injectSystemMessage( `**[Skill]** Injected agent skill: ${req.displayName || result.safeName}` ); }, }); } public start(port: number = 4825) { const server = http.createServer((req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } const url = req.url || ''; const method = req.method; // 라우팅 로직 (SRP에 따라 비즈니스 로직은 서비스로 위임) if (method === 'GET' && url === '/ping') { this.handlePing(res); } else if (method === 'POST' && url === '/api/exam') { this.handlePost(req, res, this.processExam.bind(this)); } else if (method === 'POST' && url === '/api/evaluate') { this.handlePost(req, res, this.processEvaluate.bind(this)); } else if (method === 'GET' && url === '/api/evaluate-history') { this.processEvaluateHistory(res); } else if (method === 'POST' && url === '/api/brain-inject') { this.handlePost(req, res, this.processBrainInject.bind(this)); } else if (method === 'POST' && url === '/api/skill-inject') { this.handlePost(req, res, this.processSkillInject.bind(this)); } else { res.writeHead(404); res.end(); } }); // once() 사용: 중복 에러 이벤트 방지 server.once('error', (err: any) => { if (err.code === 'EADDRINUSE') { // INFO 레벨: ERR 콘솔 오염 방지 (Extension Host가 console.error를 ERR로 표시) logInfo(`Bridge Port ${port} already in use. Trying port ${port + 1}... (Current PID: ${process.pid})`); server.close(); if (this.server === server) { this.server = null; } this.start(port + 1); } else { // EADDRINUSE 외 진짜 에러만 logError logInfo(`Bridge server non-fatal error on port ${port}: ${err.code || err.message} (PID: ${process.pid})`); } }); // 성공 시 서버 참조 저장 server.listen(port, '127.0.0.1', () => { this.server = server; logInfo(`Bridge server active on 127.0.0.1:${port} (PID: ${process.pid}).`); }); } private handlePing(res: http.ServerResponse) { const brainDir = _getBrainDir(); const brainCount = fs.existsSync(brainDir) ? findBrainFiles(brainDir).length : 0; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', config: getConfig(), brain: { fileCount: brainCount, enabled: this.provider.brainEnabled } })); } private handlePost(req: http.IncomingMessage, res: http.ServerResponse, processor: (data: any, res: http.ServerResponse) => Promise) { let body = ''; req.on('data', chunk => body += chunk.toString()); req.on('end', async () => { try { const parsed = JSON.parse(body); await processor(parsed, res); } catch (e: any) { logError('Bridge request processor failed.', { url: req.url, error: e.message }); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); } }); } private async processExam(data: any, res: http.ServerResponse) { const prompt = data.prompt || 'Automatic Prompt'; this.provider.sendPromptFromExtension(`[Bridge] ${prompt}`); const result = await this.aiService.call(prompt); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, rawOutput: result })); } private async processEvaluate(data: any, res: http.ServerResponse) { const prompt = data.prompt || ''; this.provider.injectSystemMessage(`**[A.U Evaluation]** Analyzing input...`); const result = await this.aiService.call(`[EVALUATE] ${prompt}`); this.provider.injectSystemMessage(`**[Result]** ${summarizeText(result, 200)}`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ rawOutput: result })); } private async processEvaluateHistory(res: http.ServerResponse) { const historyText = this.provider.getHistoryText(); if (!historyText || historyText.length < 50) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: "Insufficient history" })); return; } const result = await this.aiService.call(`Analyze chat history for metrics (JSON):\n${historyText.slice(-6000)}`); // Try to extract valid JSON from the AI response let parsed: any = null; try { parsed = JSON.parse(result); } catch { // AI may wrap JSON in markdown code fences — try to extract const fenceMatch = result.match(/```(?:json)?\s*([\s\S]*?)```/); if (fenceMatch) { try { parsed = JSON.parse(fenceMatch[1].trim()); } catch { /* fall through */ } } } if (parsed) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(parsed)); } else { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: "Failed to parse AI response as JSON", raw: summarizeText(result, 500) })); } } private async processBrainInject(data: any, res: http.ServerResponse) { const { title, markdown, prompt } = data; await this.brainService.inject(title, markdown); this.provider.injectSystemMessage(`**[Brain]** Knowledge captured: ${title}`); const result = await this.aiService.call(prompt || `Analyze: ${title}`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, rawOutput: result })); } /** * Inject an agent skill (markdown-only — see SkillInjectionService doc) from * an external tool. Routing-only: validation, file IO, and telemetry live in * the service. */ private async processSkillInject(data: any, res: http.ServerResponse) { try { const result = await this.skillService.inject({ name: data?.name, content: data?.content ?? data?.markdown, displayName: data?.displayName, description: data?.description, source: data?.source, }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, safeName: result.safeName, filePath: result.filePath, })); } catch (e: any) { const status = e instanceof SkillInjectionError && (e.code === 'INVALID_NAME' || e.code === 'EMPTY_CONTENT') ? 400 : 500; const code = e instanceof SkillInjectionError ? e.code : 'UNKNOWN'; logError('Skill inject endpoint failed.', { code, error: e?.message ?? String(e) }); res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e?.message ?? String(e), code })); } } }