Files
connectai/src/extension.ts
T

967 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
openInEditorGroup
} 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 type { CompanyState } from './features/company';
import { SettingsPanelProvider } from './features/settings/settingsPanelProvider';
import { resolveScopeForAgent, openKnowledgeMapEditor } from './skills/agentKnowledgeMap';
import { getBrainTokenIndex } from './retrieval';
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers';
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)
// Astra 2.81: sidebar view container is gone; all webviews open in editor
// column 3 instead. We don't register a WebviewViewProvider — panels are
// created on-demand via openAsPanel().
const approvalQueue = new ApprovalQueue();
const approvalPanel = new ApprovalPanelProvider(context.extensionUri, approvalQueue);
const approvalStatusBar = new ApprovalStatusBar(approvalQueue);
context.subscriptions.push(
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 Chat Provider (renders into an editor column, not a sidebar view)
provider = new SidebarChatProvider(context.extensionUri, context, agent, {
lifecycle,
activity: activityTracker,
loadedModels: () => lmStudioClient.listLoadedCached(),
});
// One-time repair: rewrite any chronicle projects that were saved with the
// workspace parent as their `projectRoot` (a side-effect of the old
// pre-multi-subproject activation code). Idempotent and silent when there's
// nothing to fix.
void provider._repairCorruptedChronicleProjectRoots().catch((e: any) => {
logError('architecture: chronicle repair failed.', { error: e?.message ?? String(e) });
});
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.openChat', () => {
provider!.openAsPanel(vscode.ViewColumn.Three);
})
);
// ── Activity Bar launcher view ────────────────────────────────────────
// Adds a sparkle (✦) icon to VS Code's left activity bar. Clicking it
// opens a small sidebar with action buttons (Open Chat / New Chat /
// Settings / Company / Architecture). Solves the user-reported pain
// point: when the Astra Chat editor tab is accidentally closed, there
// was no one-click way to reopen it short of restarting the extension.
//
// The view itself has no items — VS Code renders the `viewsWelcome`
// content from package.json instead, which is just a list of command
// links. Cheap, theme-aware, no webview to maintain.
const astraLauncherProvider: vscode.TreeDataProvider<never> = {
getTreeItem: () => new vscode.TreeItem(''),
getChildren: () => [],
};
context.subscriptions.push(
vscode.window.registerTreeDataProvider('astra-launcher', astraLauncherProvider),
);
// 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. */
/**
* Cheap heuristic: does the message look like a *work order* the user
* wants the company to execute? Triggers company-turn routing.
*
* Conservative matches only — we'd rather miss a borderline case
* (user retries with clearer wording) than mis-route a question
* into a company turn (which spends LLM calls + writes to disk).
*
* Positive signals:
* • Explicit dispatch prefix: "CEO한테", "회사한테", "팀한테"
* • Korean imperative verbs at sentence end: 만들어/해줘/작성해줘/
* 짜줘/구현해/만들어줘/돌려줘/실행해줘/분석해줘/정리해줘
* • English imperatives: "make X", "build X", "create X", "implement"
*
* Negative signals (override → treat as question, not order):
* • Ends with "?" — pure question
* • Contains "알려줘 / 어디 / 뭐야 / what / where" — informational
*/
function _looksLikeWorkOrder(text: string): boolean {
const t = (text || '').trim();
if (!t) return false;
// Explicit dispatch prefix wins regardless of other signals.
if (/(CEO|회사|팀)\s*(한테|에게|보고|에)/i.test(t)) return true;
// Strong informational signals — *not* a work order.
if (/[?]$/.test(t)) return false;
if (/(어디(에|에서|야)|뭐야|얼마|언제|왜|^(누구|어떻게|뭐))/i.test(t)) return false;
// Korean imperative tails (한국어 청유·명령형 종결).
if (/(만들어|짜줘|작성해|구현해|돌려줘|실행해|분석해|정리해|보고해|해줘|짜봐|만들어줘)/i.test(t)) return true;
// English imperative leads.
if (/^\s*(make|build|create|implement|run|analyze|generate|write|fix|add|remove)\b/i.test(t)) return true;
return false;
}
/**
* Build a `[COMPANY CONTEXT]` block describing the workspace, the
* current company state, and the most recent session directory. Lets
* the bot answer questions like "어디에 저장했어?" by reading its own
* mirror history *plus* the resolved absolute path on disk.
*
* Returns '' when company mode is off, so the prompt stays minimal
* for users who only use the Telegram bot for RAG-chat.
*/
function _buildTelegramCompanyContext(state: CompanyState, ctx: vscode.ExtensionContext): string {
if (!state.enabled) return '';
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const lines: string[] = [`[COMPANY CONTEXT]`];
lines.push(`회사명: ${state.companyName || '1인 기업'}`);
if (ws) lines.push(`작업 폴더 (워크스페이스 루트): ${ws}`);
// Surface the most recent session dir so follow-up questions
// ("폴더 어디에 있어?", "방금 만든 파일 경로") have a concrete answer.
const latestSession = _latestCompanySessionDir(ctx);
if (latestSession) {
lines.push(`최근 작업 세션 폴더: ${latestSession}`);
lines.push(`(이 안에 _brief.md, _report.md, 각 에이전트별 산출물이 저장됨)`);
}
lines.push('');
lines.push('당신의 역할: 이 회사의 비서(Secretary). 사용자(사장님)의 질문에 답할 때 위 경로 정보를 *그대로* 활용하세요.');
lines.push('"실제 파일 시스템에 접근할 수 없다" 같은 답변은 잘못된 것입니다 — 위 경로가 실제 시스템 경로입니다.');
return lines.join('\n');
}
/** Find the newest `<workspace>/.astra/company/sessions/<ts>/` directory, or '' if none. */
function _latestCompanySessionDir(ctx: vscode.ExtensionContext): string {
try {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const baseDir = ws
? path.join(ws, '.astra', 'company', 'sessions')
: path.join(ctx.globalStorageUri.fsPath, 'company', 'sessions');
if (!fs.existsSync(baseDir)) return '';
const dirs = fs.readdirSync(baseDir)
.filter((n) => fs.statSync(path.join(baseDir, n)).isDirectory())
.sort()
.reverse();
return dirs[0] ? path.join(baseDir, dirs[0]) : '';
} catch { return ''; }
}
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,
});
// ── 1인 기업 모드 라우팅 ────────────────────────────────────────
// When company mode is on AND the message looks like a work
// *order* (imperative verbs like "만들어줘 / 해줘 / 작성해줘" or
// an explicit "CEO한테 …" prefix), route through the company
// dispatcher instead of the simple RAG-chat path. The dispatcher
// emits a Telegram mirror at the end, so the user gets a proper
// report back. This fixes the previous symptom where the bot
// refused to "deliver messages to CEO" — that was a routing gap,
// not a missing capability.
const { readCompanyState } = await import('./features/company');
const companyState = readCompanyState(context);
if (companyState.enabled && _looksLikeWorkOrder(text)) {
const { appendTelegramMessage } = await import('./integrations/telegram/conversationHistory');
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
logInfo('Telegram: routing to company turn.', { chatId, preview: text.slice(0, 60) });
// Fire-and-forget: the dispatcher's secretary mirror sends
// the final report. We return an immediate ack so the user
// sees the bot acknowledged the order.
void (async () => {
try {
await provider!._runCompanyTurn(text);
} catch (e: any) {
logError('Telegram → company turn failed.', { error: e?.message ?? String(e) });
}
})();
const ack = '🧭 CEO에게 전달했어요. 작업 끝나면 보고드릴게요.';
appendTelegramMessage({ chatId, role: 'assistant', text: ack, kind: 'reply' });
return ack;
}
// 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),
});
}
}
// Build the system prompt with company-aware context. The bot
// is the *secretary* of a virtual company when company mode is
// on — telling it so lets it answer "where did you save X?"
// properly instead of falling back to "I don't have file
// system access".
const companyContextBlock = _buildTelegramCompanyContext(companyState, context);
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock)
+ (companyContextBlock ? `\n\n${companyContextBlock}` : '');
// Per-chat conversation history — without this every inbound
// is a fresh turn, so the user "tells the bot something" and
// it gets immediately forgotten. We inline the recent N
// exchanges into the user message because the AI service's
// {system, user} surface doesn't carry a messages array.
const { appendTelegramMessage, getRecentMessages, formatHistoryForPrompt } =
await import('./integrations/telegram/conversationHistory');
const history = getRecentMessages(chatId, 10);
const historyBlock = formatHistoryForPrompt(history);
const pieces: string[] = [];
if (contextBlock) pieces.push(`[SECOND BRAIN CONTEXT]\n${contextBlock}`);
if (historyBlock) pieces.push(historyBlock);
pieces.push(`[USER MESSAGE]\n${text}`);
const userMessage = pieces.join('\n\n');
// Persist the user's message *before* the AI call so failures
// still leave a trail (next inbound will see what they said).
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
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');
}
// Persist the assistant's reply so the *next* inbound sees
// what we just said. Without this, the bot would forget its
// own answer the moment the user follows up.
appendTelegramMessage({ chatId, role: 'assistant', text: result.content, kind: 'reply' });
// 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();
}),
// Experience Memory — create / browse lesson cards in the active brain.
vscode.commands.registerCommand('g1nation.lesson.create', () => createLessonCard()),
vscode.commands.registerCommand('g1nation.lesson.fromConversation', () => {
// Pre-fill the Situation section from the most recent user request + assistant reply.
const history = agent.getHistory().filter((m: any) => !m.internal);
const lastUser = [...history].reverse().find((m: any) => m.role === 'user');
const lastAssistant = [...history].reverse().find((m: any) => m.role === 'assistant');
if (!lastUser && !lastAssistant) {
vscode.window.showInformationMessage('현재 대화 내용이 없습니다. 먼저 대화를 한 뒤 사용하세요. (빈 교훈을 만들려면 "Astra: New Lesson" 사용)');
return;
}
const clip = (s: any, n: number) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; };
const situation = [
lastUser ? `요청: ${clip(lastUser.content, 600)}` : '',
lastAssistant ? `Astra 답변(요약): ${clip(lastAssistant.content, 800)}` : '',
'',
'<위 작업에서 무엇이 잘못됐거나 위험했는지를 아래 Mistake/Root Cause 에 적으세요>',
].filter(Boolean).join('\n');
return createLessonCard(situation);
}),
vscode.commands.registerCommand('g1nation.lesson.manage', () => manageLessons()),
// ── Project Architecture commands (Feature 2) ─────────────────────────
// Thin shells that defer to the sidebar provider so all state mutations
// go through one code path (chip state, watcher lifecycle, etc.).
vscode.commands.registerCommand('g1nation.architecture.refresh', async () => {
if (!provider) return;
await provider._refreshArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture context refreshed.');
}),
vscode.commands.registerCommand('g1nation.architecture.detach', async () => {
if (!provider) return;
await provider._detachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.');
}),
vscode.commands.registerCommand('g1nation.architecture.attach', async () => {
if (!provider) return;
await provider._attachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned on.');
}),
vscode.commands.registerCommand('g1nation.architecture.open', async () => {
if (!provider) return;
await provider._openArchitectureDoc();
}),
// Re-resolve the active subproject when the user switches between files
// in different subfolders. Debounced so rapid editor flicks don't churn
// the chip / watcher. The actual resync is idempotent — if the active
// subproject didn't change, nothing visible happens.
(() => {
let timer: NodeJS.Timeout | undefined;
return vscode.window.onDidChangeActiveTextEditor(() => {
if (!provider) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
provider!._sendArchitectureStatus().catch(() => { /* swallow — chip is best-effort */ });
}, 400);
});
})(),
// ── 1인 기업 (Company) Mode commands ──────────────────────────────────
// Thin shells over sidebar-provider methods so the runtime owns all
// state mutation (chip status, watcher lifecycle, agent persistence).
vscode.commands.registerCommand('g1nation.company.toggle', async () => {
if (!provider) return;
const { readCompanyState, setCompanyEnabled } = await import('./features/company');
const cur = readCompanyState(context);
const next = await setCompanyEnabled(context, !cur.enabled);
await provider._sendCompanyStatus();
vscode.window.showInformationMessage(`Astra: 1인 기업 모드 ${next.enabled ? 'ON' : 'OFF'}`);
}),
vscode.commands.registerCommand('g1nation.company.manage', async () => {
if (!provider) return;
// Reveal the sidebar then ask the webview to open the overlay.
await vscode.commands.executeCommand('g1nation-v2-view.focus');
provider._view?.webview.postMessage({ type: 'openCompanyManageOverlay' });
await provider._sendCompanyAgents();
}),
vscode.commands.registerCommand('g1nation.company.openSessions', async () => {
const { resolveCompanyBase } = await import('./features/company');
const base = resolveCompanyBase(context);
const target = path.join(base, 'sessions');
try {
if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
await vscode.env.openExternal(vscode.Uri.file(target));
} catch (e: any) {
vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`);
}
}),
);
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
* filter (cheap when warm), then reads only those few files for their frontmatter title/occurrences. */
function listLessonFiles(brainDir: string): Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> {
const out: Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> = [];
let files: string[] = [];
try { files = findBrainFiles(brainDir); } catch { return out; }
for (const d of getBrainTokenIndex(brainDir, files)) {
if (!d.kind) continue;
let content = '';
try { content = fs.readFileSync(d.filePath, 'utf8').slice(0, 4000); } catch { continue; }
const fm = parseLessonFrontmatter(content);
out.push({ filePath: d.filePath, rel: d.relativePath, title: (fm.title || d.title).trim(), kind: d.kind, occurrences: fm.occurrences ?? 1 });
}
return out.sort((a, b) => a.rel.localeCompare(b.rel));
}
/** Shared lesson-card creator used by the lesson commands. Dedup-merges into an existing lesson with the same title. */
async function createLessonCard(situation?: string): Promise<void> {
const brain = getActiveBrainProfile();
const brainDir = brain?.localBrainPath;
if (!brainDir || !path.isAbsolute(brainDir)) {
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다. Settings에서 localBrainPath / brainProfiles를 먼저 설정하세요.');
return;
}
const title = (await vscode.window.showInputBox({
title: 'New Lesson — Experience Memory',
prompt: '이 교훈의 제목 (예: "Telegram 원격 실행은 allowlist 필수")',
placeHolder: '한 줄 요약 — 다음에 같은 실수를 안 하려면 뭘 기억해야 하나',
ignoreFocusOut: true,
}))?.trim();
if (!title) return;
const today = new Date().toISOString().slice(0, 10);
// Dedup-merge: a recurring mistake should get LOUDER (occurrences++), not spawn a duplicate card.
const norm = normalizeLessonTitle(title);
const existing = norm ? listLessonFiles(brainDir).find((l) => normalizeLessonTitle(l.title) === norm) : undefined;
if (existing) {
const pick = await vscode.window.showInformationMessage(
`이미 같은 제목의 교훈이 있습니다: "${existing.title}" (occurrences: ${existing.occurrences}). 갱신할까요?`,
{ modal: false },
'갱신 (occurrences +1)', '새로 만들기',
);
if (!pick) return;
if (pick === '갱신 (occurrences +1)') {
try {
const cur = fs.readFileSync(existing.filePath, 'utf8');
fs.writeFileSync(existing.filePath, bumpLessonOccurrences(cur, today), 'utf8');
} catch (e: any) {
vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`);
return;
}
await openInEditorGroup(existing.filePath);
vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`);
return;
}
// else fall through and create a new one
}
const dir = path.join(brainDir, 'lessons');
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* fall through to error below */ }
let filePath = path.join(dir, `${today}-${lessonSlug(title)}.md`);
let n = 2;
while (fs.existsSync(filePath)) { filePath = path.join(dir, `${today}-${lessonSlug(title)}-${n++}.md`); }
try {
fs.writeFileSync(filePath, lessonTemplate(title, today, situation), 'utf8');
} catch (e: any) {
vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`);
return;
}
await openInEditorGroup(filePath);
vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.');
}
/** Browse lesson cards: open one, or delete one (trash button). Also the "manage" surface for ignoring bad lessons. */
async function manageLessons(): Promise<void> {
const brain = getActiveBrainProfile();
const brainDir = brain?.localBrainPath;
if (!brainDir || !path.isAbsolute(brainDir)) {
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다.');
return;
}
const lessons = listLessonFiles(brainDir);
if (lessons.length === 0) {
const make = await vscode.window.showInformationMessage('아직 교훈 카드가 없습니다.', '새 교훈 만들기');
if (make) await createLessonCard();
return;
}
const deleteBtn: vscode.QuickInputButton = { iconPath: new vscode.ThemeIcon('trash'), tooltip: '이 교훈 삭제' };
const qp = vscode.window.createQuickPick<vscode.QuickPickItem & { _file: string }>();
qp.title = 'Lessons — Experience Memory';
qp.placeholder = '교훈을 선택하면 열립니다. 휴지통 아이콘으로 삭제. (삭제 = 더 이상 주입 안 됨)';
qp.items = lessons.map((l) => ({
label: `$(${l.kind === 'playbook' ? 'book' : l.kind === 'qa-finding' ? 'bug' : 'lightbulb'}) ${l.title}`,
description: l.occurrences > 1 ? `×${l.occurrences}` : '',
detail: l.rel,
buttons: [deleteBtn],
_file: l.filePath,
}));
qp.onDidTriggerItemButton(async (e) => {
const file = e.item._file;
const ok = await vscode.window.showWarningMessage(`교훈 "${e.item.label}" 을(를) 삭제할까요?`, { modal: true }, '삭제');
if (ok === '삭제') {
try { fs.unlinkSync(file); } catch (err: any) { vscode.window.showErrorMessage(`삭제 실패: ${err?.message ?? err}`); return; }
qp.items = qp.items.filter((it) => it._file !== file);
vscode.window.showInformationMessage('교훈을 삭제했습니다.');
if (qp.items.length === 0) qp.hide();
}
});
qp.onDidAccept(async () => {
const sel = qp.selectedItems[0];
qp.hide();
if (sel) {
await openInEditorGroup(sel._file);
}
});
qp.onDidHide(() => qp.dispose());
qp.show();
}
// 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(
// 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);
}
// 7. Auto-open all three Astra webviews as tabs in editor column 3.
// The sidebar/activity-bar entry point was removed in 2.81 — all three views
// (Chat, Approvals, Settings) now stack as tabs in the third editor column.
// Order matters: Chat opens last so it ends up as the active tab.
try {
approvalPanel.openAsPanel(vscode.ViewColumn.Three);
await settingsPanel.openAsPanel(vscode.ViewColumn.Three);
provider!.openAsPanel(vscode.ViewColumn.Three);
} catch (e) {
logError('Failed to auto-open Astra panels.', e);
}
}
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;
}