758 lines
37 KiB
TypeScript
758 lines
37 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,
|
||
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 { 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(),
|
||
});
|
||
context.subscriptions.push(
|
||
vscode.commands.registerCommand('g1nation.openChat', () => {
|
||
provider!.openAsPanel(vscode.ViewColumn.Three);
|
||
})
|
||
);
|
||
|
||
// 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();
|
||
}),
|
||
// 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.open', async () => {
|
||
if (!provider) return;
|
||
await provider._openArchitectureDoc();
|
||
}),
|
||
);
|
||
|
||
/** 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;
|
||
}
|
||
|