Files
connectai/src/extension.ts
T
2026-05-10 22:57:03 +09:00

583 lines
27 KiB
TypeScript

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
// axios removed in favor of native fetch
import {
_getBrainDir,
_isBrainDirExplicitlySet,
findBrainFiles,
SYSTEM_PROMPT,
buildApiUrl,
logError,
logInfo,
resolveEngine,
getActiveBrainProfile
} from './utils';
import { getConfig, validateConfig } from './config';
import { AgentExecutor } from './agent';
import { BridgeServer } from './bridge';
import { SidebarChatProvider } from './sidebarProvider';
import { HealthCheckMonitor } from './core/health';
import { initAstraPathResolver } from './core/astraPath';
import { LMStudioClient } from './lmstudio/client';
import { ActivityTracker } from './lmstudio/activityTracker';
import { ModelLifecycleManager } from './lmstudio/lifecycleManager';
import { LMStudioStreamer } from './lmstudio/streamer';
import { NodeSystemSpecsProvider, HeuristicModelMemoryEstimator } from './system/specs';
import { ApprovalQueue } from './features/approval/approvalQueue';
import { ApprovalPanelProvider } from './features/approval/approvalPanelProvider';
import { ApprovalStatusBar } from './features/approval/approvalStatusBar';
import { FileSystemProjectScaffolder } from './scaffolder/projectScaffolder';
import type { ProjectTemplateId } from './scaffolder/templates';
import { TelegramHttpClient } from './integrations/telegram/telegramClient';
import { TelegramBot } from './integrations/telegram/telegramBot';
import { AIService } from './core/services';
import { SettingsPanelProvider } from './features/settings/settingsPanelProvider';
import { resolveScopeForAgent, openKnowledgeMapEditor } from './skills/agentKnowledgeMap';
import { retrieveScoped, buildContextBlock } from './skills/scopedBrainRetriever';
let _lifecycleManager: ModelLifecycleManager | undefined;
let _telegramBot: TelegramBot | undefined;
const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
/**
* Astra Extension Entry Point
*/
export async function activate(context: vscode.ExtensionContext) {
logInfo('Astra activating...');
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
initAstraPathResolver(context);
// Start Environment Health Monitoring
HealthCheckMonitor.runAllChecks();
HealthCheckMonitor.startInterval(600000); // Check every 10 mins
// 0. Validate Configuration
const validation = validateConfig();
if (!validation.valid) {
vscode.window.showErrorMessage(`Astra Configuration Error: ${validation.errors.join(' ')}`);
logError('Configuration validation failed.', { errors: validation.errors });
}
// 1. Ensure Brain Directory
await _ensureBrainDir(context);
// 2. Initialize LM Studio Lifecycle Subsystem
let provider: SidebarChatProvider | undefined;
const initialUrl = getConfig().ollamaUrl;
const activityTracker = new ActivityTracker();
const lmStudioClient = new LMStudioClient(initialUrl);
const systemSpecs = new NodeSystemSpecsProvider();
const memoryEstimator = new HeuristicModelMemoryEstimator();
logInfo('System specs detected.', { summary: systemSpecs.get().summary });
const lifecycle = new ModelLifecycleManager({
client: lmStudioClient,
activity: activityTracker,
getConfig: () => {
const cfg = vscode.workspace.getConfiguration('g1nation');
return {
idleTimeoutMs: cfg.get<number>('lmStudio.idleTimeoutMs', 300000),
autoLoadOnSelect: cfg.get<boolean>('lmStudio.autoLoadOnSelect', true),
};
},
notifyError: (msg) => provider?.postLmStudioError(msg),
initialEngine: resolveEngine(initialUrl),
systemSpecs,
memoryEstimator,
});
_lifecycleManager = lifecycle;
context.subscriptions.push({ dispose: () => activityTracker.dispose() });
context.subscriptions.push({ dispose: () => lifecycle.dispose() });
// React to engine URL changes — re-target the SDK and reset state.
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
if (!e.affectsConfiguration('g1nation.ollamaUrl')) return;
const newUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl', '');
lmStudioClient.setBaseUrl(newUrl);
lifecycle.setEngine(resolveEngine(newUrl));
})
);
// Keep the sidebar's model dropdown in sync when defaultModel / ollamaUrl is
// changed from elsewhere (Settings panel, raw settings.json, …). Without
// this the user sees a desync: Settings shows the new model, sidebar still
// shows the old one until a manual refresh.
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
const touchedModel = e.affectsConfiguration('g1nation.defaultModel');
const touchedUrl = e.affectsConfiguration('g1nation.ollamaUrl');
if (!touchedModel && !touchedUrl) return;
// _sendModels is best-effort; the provider may not have a webview
// attached yet during very early activation.
void provider?._sendModels(touchedUrl);
})
);
// 3. Initialize Approval subsystem (queue + panel webview + status bar badge)
const approvalQueue = new ApprovalQueue();
const approvalPanel = new ApprovalPanelProvider(context.extensionUri, approvalQueue);
const approvalStatusBar = new ApprovalStatusBar(approvalQueue);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ApprovalPanelProvider.viewType, approvalPanel),
approvalStatusBar,
{ dispose: () => approvalQueue.dispose() },
vscode.commands.registerCommand(ApprovalStatusBar.focusCommand, () => approvalPanel.focus()),
);
// 4. Initialize Agent Executor (with stream lifecycle hooks + LM Studio SDK streamer + approval queue)
const lmStudioStreamer = new LMStudioStreamer(lmStudioClient);
const agent = new AgentExecutor(context, {
onStreamLifecycle: {
start: () => lifecycle.onStreamStart(),
end: () => lifecycle.onStreamEnd(),
},
lmStudioStreamer,
approvalQueue,
});
// 4. Initialize Sidebar Provider
provider = new SidebarChatProvider(context.extensionUri, context, agent, {
lifecycle,
activity: activityTracker,
loadedModels: () => lmStudioClient.listLoadedCached(),
});
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(SidebarChatProvider.viewType, provider)
);
// 4. Initialize Bridge Server (Port 4825)
const bridge = new BridgeServer(provider);
try {
bridge.start();
logInfo('Bridge server started on port 4825.');
} catch (err) {
logError('Failed to start bridge server.', err);
}
// 5. Register Core Commands
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.focusInput', () => {
provider.focusInput();
})
);
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.clearChat', () => {
provider.clearChat();
})
);
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.syncBrain', async () => {
await provider!.syncBrain();
})
);
// Telegram Bot integration — opt-in (g1nation.telegram.enabled), token in SecretStorage.
let _cachedTelegramToken = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
const telegramClient = new TelegramHttpClient({
getToken: () => _cachedTelegramToken,
});
const telegramAi = new AIService();
/**
* Build the Telegram-specific system prompt.
*
* Why this matters: small local models (gemma e2b/e4b) drift badly when
* called as a single user message with no role grounding. The reported
* symptom ("path 입력 → 시 못 써드려요" 같은 환각 거절) is exactly that
* drift — the model invents an interpretation because it has no anchor.
*
* The prompt does four things:
* 1. Names the role (Astra Telegram assistant) so the model has a
* consistent persona across messages.
* 2. States the language rule (mirror the user's language).
* 3. Tells the model how to treat brain context (evidence when relevant,
* ignore otherwise — never refuse the question because context
* doesn't match).
* 4. Specifies behavior for ambiguous inputs (paths, single words,
* fragments) — ask a clarifying question instead of guessing.
*/
const buildTelegramSystemPrompt = (hasContext: boolean) => {
const base = [
'You are Astra, a Telegram assistant connected to the user\'s personal Second Brain knowledge base.',
'Reply in the user\'s language (mirror Korean ↔ English exactly as the user wrote).',
'Be concise but complete. Telegram messages should feel like a knowledgeable friend, not a formal report.',
'',
'Behavior rules:',
'- Never refuse a question by claiming you can only do certain things. If you can answer, just answer.',
'- If the user\'s message is ambiguous (a single word, a file path, a fragment with no question), ask one short clarifying question instead of guessing what they meant.',
'- Do NOT invent that the user asked for poetry, songs, code, or any content type they did not request.',
];
if (hasContext) {
base.push(
'',
'You will receive a [SECOND BRAIN CONTEXT] block before the user\'s message.',
'- Use it as evidence only when it directly answers the question. Cite the file path (relative form, e.g. `10_Wiki/Topics/Foo.md`) inline when you do.',
'- If the context is unrelated to the question, ignore it silently. Do NOT mention that the context exists, do NOT explain why it doesn\'t apply, do NOT refuse the question because of it.',
);
}
return base.join('\n');
};
/** Telegram has a 4096-char per-message limit. Split on paragraph/sentence boundaries to keep replies readable. */
const chunkTelegramMessage = (text: string, max = 4000): string[] => {
if (text.length <= max) return [text];
const out: string[] = [];
let remaining = text;
while (remaining.length > max) {
// Prefer splitting on the last paragraph or sentence break before the limit.
let cut = remaining.lastIndexOf('\n\n', max);
if (cut < max * 0.5) cut = remaining.lastIndexOf('\n', max);
if (cut < max * 0.5) cut = remaining.lastIndexOf('. ', max);
if (cut < max * 0.5) cut = max;
out.push(remaining.slice(0, cut).trim());
remaining = remaining.slice(cut).trim();
}
if (remaining) out.push(remaining);
return out;
};
const telegramBot = new TelegramBot({
client: telegramClient,
handle: async (text, chatId) => {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0 && !allowed.includes(chatId)) {
logInfo('Telegram message from unallowed chat ignored.', { chatId });
return null;
}
// Trace every accepted message at the entry point so silent failures
// can be diagnosed against the log: if the user reports "no reply"
// and we have no `Telegram message received` line, the message
// never made it here (allowlist or polling drop).
logInfo('Telegram message received.', {
chatId,
chars: text.length,
preview: text.length > 80 ? text.slice(0, 80) + '…' : text,
});
// Per-chat agent override → global default → mapping default.
const perChatAgents = cfg.get<Record<string, string>>('telegram.agentByChatId', {}) || {};
const perChatAgent = perChatAgents[String(chatId)];
const defaultAgent = cfg.get<string>('telegram.defaultAgent', '') || '';
const agentName = (perChatAgent || defaultAgent || '').trim();
const brain = getActiveBrainProfile();
const brainRoot = brain?.localBrainPath || '';
const scope = resolveScopeForAgent(agentName, brainRoot);
// RAG retrieval — even with no agent match we still search the whole
// brain so the bot stays useful. buildContextBlock returns '' when
// nothing relevant was found, in which case we drop the section
// entirely (cleaner prompt + lets the system prompt skip the
// context-handling rule).
let contextBlock = '';
if (brainRoot) {
try {
const result = retrieveScoped(text, brainRoot, scope.folders, {
maxResults: cfg.get<number>('telegram.contextChunks', 6) ?? 6,
});
contextBlock = buildContextBlock(result);
logInfo('Telegram RAG retrieval done.', {
chatId,
agent: scope.agent?.name ?? '(none)',
scopedFolders: scope.folders.length,
candidates: result.candidateCount,
chunks: result.chunks.length,
});
} catch (e: any) {
logError('Telegram RAG retrieval failed; falling back to plain prompt.', {
chatId, error: e?.message ?? String(e),
});
}
}
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock);
const userMessage = contextBlock
? `[SECOND BRAIN CONTEXT]\n${contextBlock}\n\n[USER MESSAGE]\n${text}`
: text;
try {
const result = await telegramAi.chat({ system: systemPrompt, user: userMessage });
logInfo('Telegram AI reply generated.', {
chatId, engine: result.engine, model: result.model,
empty: result.empty, chars: result.content.length,
});
if (result.empty) {
// Reach the user instead of going silent. The user can then
// restart the model or simplify the question.
return [
'⚠️ AI 모델이 빈 응답을 반환했습니다.',
'',
'다음을 시도해보세요:',
'• LM Studio에서 모델이 실제로 로드되어 있는지 확인',
'• 더 짧고 구체적인 질문으로 다시 보내기',
'• `Astra: Test Telegram Connection` 으로 연결 상태 확인',
].join('\n');
}
// Telegram has a hard 4096 char/message limit. Long replies are
// chunked and joined with a "(이어서)" hint so the user knows
// multiple messages belong together.
const chunks = chunkTelegramMessage(result.content);
if (chunks.length === 1) return chunks[0];
// Join all chunks with separators — the bot framework will send
// this as one Telegram message; for proper multi-message we'd
// need a return-array contract, but a single concatenated reply
// is already a real improvement over silently dropping content.
return chunks.map((c, i) => i === 0 ? c : `(이어서 ${i + 1}/${chunks.length})\n\n${c}`).join('\n\n---\n\n').slice(0, 4000);
} catch (e: any) {
// Even on hard failure, ALWAYS reply with something so the user
// knows the bot is alive. Silent failures were the second
// reported pain point.
logError('Telegram handler threw.', { chatId, error: e?.message ?? String(e) });
return `⚠️ Astra 처리 중 오류가 발생했습니다.\n${e?.message ?? e}\n\nLM Studio가 실행 중인지, 모델이 로드되어 있는지 확인해주세요.`;
}
},
});
_telegramBot = telegramBot;
const refreshTelegramBot = async () => {
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('telegram.enabled', false);
const tokenPresent = !!_cachedTelegramToken.trim();
if (enabled && tokenPresent) {
telegramBot.start();
} else if (telegramBot.isRunning()) {
await telegramBot.stop();
}
};
void refreshTelegramBot();
context.subscriptions.push(
{ dispose: () => { void telegramBot.stop(); } },
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('g1nation.telegram.enabled')) {
await refreshTelegramBot();
}
}),
context.secrets.onDidChange(async (e) => {
if (e.key !== TELEGRAM_TOKEN_SECRET_KEY) return;
_cachedTelegramToken = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
await refreshTelegramBot();
}),
vscode.commands.registerCommand('g1nation.telegram.setBotToken', async () => {
const token = await vscode.window.showInputBox({
prompt: 'Telegram bot token (BotFather에서 발급, 형식: 123456:ABC...)',
placeHolder: '123456789:AA...',
password: true,
ignoreFocusOut: true,
validateInput: (v) => /^\d+:[A-Za-z0-9_-]{20,}$/.test((v || '').trim())
? null
: '형식이 올바르지 않습니다 (숫자ID:문자열).',
});
if (!token) return;
await context.secrets.store(TELEGRAM_TOKEN_SECRET_KEY, token.trim());
vscode.window.showInformationMessage(
'Telegram bot token이 저장되었습니다. settings에서 g1nation.telegram.enabled = true 로 켜세요.'
);
}),
vscode.commands.registerCommand('g1nation.telegram.clearBotToken', async () => {
await context.secrets.delete(TELEGRAM_TOKEN_SECRET_KEY);
vscode.window.showInformationMessage('Telegram bot token이 삭제되었습니다.');
}),
vscode.commands.registerCommand('g1nation.telegram.testConnection', async () => {
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token) {
vscode.window.showErrorMessage('먼저 "Astra: Set Telegram Bot Token" 명령으로 토큰을 등록하세요.');
return;
}
try {
const me = await telegramClient.getMe();
vscode.window.showInformationMessage(
`Telegram 연결 성공: @${me.username || me.first_name} (id ${me.id})`
);
} catch (e: any) {
vscode.window.showErrorMessage(`Telegram 연결 실패: ${e?.message ?? e}`);
}
}),
vscode.commands.registerCommand('g1nation.skills.editKnowledgeMap', async () => {
await openKnowledgeMapEditor();
}),
);
// Astra Settings webview — single entry point for user-facing config (Phase 5-A: Telegram only).
const settingsPanel = new SettingsPanelProvider({
extensionUri: context.extensionUri,
secrets: context.secrets,
telegramClient,
telegramBot,
});
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(SettingsPanelProvider.viewType, settingsPanel),
// Refresh the settings UI whenever any g1nation.* config changes (toggle, allowedChatIds, …).
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation')) void settingsPanel.refresh();
}),
// Same for SecretStorage updates (token saved/cleared from elsewhere).
context.secrets.onDidChange((e) => {
if (e.key === TELEGRAM_TOKEN_SECRET_KEY) void settingsPanel.refresh();
}),
vscode.commands.registerCommand('g1nation.settings.focus', () => settingsPanel.focus()),
vscode.commands.registerCommand('g1nation.settings.diagnose', async () => {
// Diagnostic helper: shows whether the view is registered + opens it if so.
// Useful when the user reports "Set 버튼이 안 먹는다" and we want to confirm
// the new build is actually loaded.
try {
await settingsPanel.focus();
vscode.window.showInformationMessage('Astra Settings 패널이 열렸습니다. 사이드바 Settings 항목을 확인하세요.');
} catch (e: any) {
vscode.window.showErrorMessage(`Settings 패널 열기 실패 (확장 reload가 필요할 수 있음): ${e?.message ?? e}`);
}
}),
);
// Project Scaffolder — Astra의 Developer 빠른 시작 명령
const scaffolder = new FileSystemProjectScaffolder();
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.scaffoldProject', async () => {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) {
vscode.window.showErrorMessage('워크스페이스 폴더를 먼저 여세요.');
return;
}
const name = await vscode.window.showInputBox({
placeHolder: '프로젝트 이름 (영문/숫자/_/-, 2~40자)',
prompt: 'Astra가 워크스페이스 안에 만들 프로젝트 폴더 이름',
validateInput: (v) => /^[a-zA-Z0-9_-]{2,40}$/.test(v.trim()) ? null : '영문/숫자/_/- 만, 2~40자',
});
if (!name) return;
const picked = await vscode.window.showQuickPick(
scaffolder.listTemplates().map(t => ({ label: t.label, detail: t.detail, id: t.id })),
{ placeHolder: '템플릿 선택' }
);
if (!picked) return;
const result = await scaffolder.scaffold({
name: name.trim(),
template: picked.id as ProjectTemplateId,
rootDir: folders[0].uri.fsPath,
});
if (!result.ok) {
vscode.window.showErrorMessage(`프로젝트 생성 실패: ${result.error}`);
return;
}
const action = await vscode.window.showInformationMessage(
`${name} 생성 완료 — ${result.projectPath}`,
'폴더 열기',
'닫기'
);
if (action === '폴더 열기') {
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(result.projectPath));
}
})
);
// 6. Run Initial Setup (Automatic Model/Engine Detection)
const setupComplete = context.globalState.get<boolean>('setupComplete', false);
if (!setupComplete) {
await runInitialSetup(context);
}
}
export async function deactivate() {
HealthCheckMonitor.dispose();
if (_telegramBot) {
try { await _telegramBot.stop(); } catch (e) { logError('Telegram bot stop during deactivate failed.', e); }
_telegramBot = undefined;
}
if (_lifecycleManager) {
try {
await _lifecycleManager.disposeAndUnload(2000);
} catch (e) {
logError('Lifecycle dispose during deactivate failed.', e);
}
_lifecycleManager = undefined;
}
}
async function runInitialSetup(context: vscode.ExtensionContext) {
// 이미 사용자가 URL을 설정했다면 자동 감지를 스킵
const existingUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl');
if (existingUrl && existingUrl.trim()) {
context.globalState.update('setupComplete', true);
logInfo('Initial setup skipped: ollamaUrl already configured.', { existingUrl });
return;
}
try {
let engineName = '';
let modelName = '';
try {
const res = await fetch(buildApiUrl('http://127.0.0.1:1234', 'lmstudio', 'models'), { signal: AbortSignal.timeout(2000) });
const data = await res.json() as any;
if (data?.data?.length > 0) {
engineName = 'LM Studio';
modelName = data.data[0].id;
await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:1234', vscode.ConfigurationTarget.Global);
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global);
logInfo('Initial setup detected LM Studio.', { modelName });
}
} catch (err) {
logInfo('Initial setup could not reach LM Studio.', err);
}
if (!engineName) {
try {
const res = await fetch('http://127.0.0.1:11434/api/tags', { signal: AbortSignal.timeout(2000) });
const data = await res.json() as any;
if (data?.models?.length > 0) {
engineName = 'Ollama';
modelName = data.models[0].name;
await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:11434', vscode.ConfigurationTarget.Global);
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global);
logInfo('Initial setup detected Ollama.', { modelName });
}
} catch (err) {
logInfo('Initial setup could not reach Ollama.', err);
}
}
context.globalState.update('setupComplete', true);
if (engineName) {
vscode.window.showInformationMessage(`Setup Complete: ${engineName} detected with model ${modelName}`);
}
} catch (e) {
logError('Initial setup failed.', e);
context.globalState.update('setupComplete', true);
}
}
async function _ensureBrainDir(context: vscode.ExtensionContext): Promise<string | null> {
if (_isBrainDirExplicitlySet()) {
const dir = _getBrainDir();
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true });
} catch (e) {
logError('Failed to create brain directory.', e);
}
}
return dir;
}
const defaultDir = _getBrainDir();
if (!fs.existsSync(defaultDir)) {
try {
fs.mkdirSync(defaultDir, { recursive: true });
// Create a welcome file
fs.writeFileSync(path.join(defaultDir, 'Welcome.md'), "# Welcome to your Second Brain\n\nAstra will store and retrieve knowledge from here.");
} catch (e) {
logError('Failed to initialize default brain directory.', e);
}
}
return defaultDir;
}