feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성

R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules)
  · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등)
  · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks)
  · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등)

R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리)

Stocks feature 신규: /stocks slash command (v2.2.152~158)
  · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신
  · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR)
  · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴
  · LLM Top 5 매력도 분석 + Telegram 자동 보고서
  · KST 09:00/15:00 watcher 자동 모니터링

대화 연속성 (v2.2.150~157):
  · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor
  · thin follow-up 분류 → boilerplate 헤더 suppression
  · slash 명령 결과 chatHistory mirror (capture wrapper)
  · echo/parrot 금지 system prompt rule

기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
+214
View File
@@ -0,0 +1,214 @@
import * as vscode from 'vscode';
/**
* Google Calendar (iCal 읽기 전용) 연결 마법사.
*
* 사용자 흐름:
* 1. 이미 셋업 됐으면 "연결 해제 / URL 변경 / 지금 새로고침 / 취소" 선택지 노출
* 2. 새로 셋업: Google Calendar 설정 페이지 외부 브라우저로 열고 → 비공개 iCal URL 입력
* 3. 입력값을 globalState 에 저장 후 즉시 한 번 새로고침 실행 → 캐시 파일 생성 안내
*
* OAuth 가 아닌 read-only iCal 만 — 셋업 3분, 토큰 관리 없음.
*/
export async function runConnectGoogleCalendarIcal(context: vscode.ExtensionContext) {
const { readCalendarConfig, writeCalendarConfig, refreshCalendarCache } =
await import('../features/calendar');
const cur = readCalendarConfig(context);
if (cur.icalUrl) {
const choice = await vscode.window.showInformationMessage(
`📅 이미 연결됨${cur.lastFetchAt ? ` (마지막 동기화: ${cur.lastFetchAt.slice(0, 16)})` : ''}`,
{ modal: false },
'지금 새로고침',
'URL 변경',
'연결 해제',
'취소',
);
if (!choice || choice === '취소') return;
if (choice === '지금 새로고침') {
const r = await refreshCalendarCache(context);
if (r.ok) vscode.window.showInformationMessage(`📅 ${r.count}개 일정 동기화 완료.`);
else vscode.window.showErrorMessage(r.error || '새로고침 실패');
return;
}
if (choice === '연결 해제') {
await writeCalendarConfig(context, { icalUrl: '', lastFetchAt: undefined });
vscode.window.showInformationMessage('Google Calendar 연결 해제됨. 캐시 파일은 그대로 둡니다.');
return;
}
// URL 변경 → 아래 입력 흐름으로 fall through
} else {
const intro = await vscode.window.showInformationMessage(
'📅 Google Calendar 연결 (읽기 전용, 셋업 3분)\n\n비공개 iCal URL 1개만 있으면 됩니다. OAuth 없음.\n\n계속할까요?',
{ modal: true },
'시작',
'Google Calendar 설정 페이지 열기',
'취소',
);
if (!intro || intro === '취소') return;
if (intro === 'Google Calendar 설정 페이지 열기') {
await vscode.env.openExternal(
vscode.Uri.parse('https://calendar.google.com/calendar/u/0/r/settings'),
);
const back = await vscode.window.showInformationMessage(
'1. 왼쪽에서 본인 캘린더 클릭 → "캘린더 통합" 섹션\n2. "비공개 주소(iCal 형식)" 옆 복사 버튼 클릭\n3. URL 복사한 뒤 ↓',
{ modal: true },
'복사함 — URL 붙여넣기',
'취소',
);
if (back !== '복사함 — URL 붙여넣기') return;
}
}
const url = await vscode.window.showInputBox({
title: 'Google Calendar 비공개 iCal URL',
prompt: 'calendar.google.com/calendar/ical/.../private-XXX/basic.ics 형태',
placeHolder: 'https://calendar.google.com/calendar/ical/...',
value: cur.icalUrl,
password: true,
ignoreFocusOut: true,
validateInput: (v) => {
const t = (v || '').trim();
if (!t) return '비어있어요';
if (!/^https?:\/\//.test(t)) return 'http:// 또는 https:// 로 시작해야 합니다.';
return null;
},
});
if (!url) return;
await writeCalendarConfig(context, { icalUrl: url.trim() });
const r = await refreshCalendarCache(context);
if (r.ok) {
vscode.window.showInformationMessage(
`✅ 연결 완료 — ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.\n\n이제 기업 모드에서 모든 에이전트가 다가오는 일정을 자동으로 참고합니다.`,
);
} else {
vscode.window.showErrorMessage(
`URL 저장은 됐지만 첫 새로고침 실패: ${r.error}\n\nURL 이 정확한지 다시 확인해주세요.`,
);
}
}
/**
* Google Calendar OAuth (쓰기) 연결 마법사.
*
* iCal 마법사와 별도 — 이쪽은 agent 가 회의록 보고 자동으로 일정 *만들* 수 있게 한다.
* 셋업 5~10분: Google Cloud Console 에서 OAuth Client ID/Secret 발급 → 본 마법사가
* loopback OAuth 흐름 실행 → refresh token 받아 globalState 저장.
*/
export async function runConnectGoogleCalendarOAuth(context: vscode.ExtensionContext) {
const { readCalendarConfig, writeCalendarConfig, runOAuthLoopback, fetchUserEmail } =
await import('../features/calendar');
const cur = readCalendarConfig(context);
const already = !!(cur.clientId && cur.clientSecret && cur.refreshToken);
if (already) {
const choice = await vscode.window.showInformationMessage(
`✅ 이미 OAuth 연결됨${cur.connectedAs ? ` (${cur.connectedAs})` : ''}`,
{ modal: false },
'재연결',
'연결 해제',
'취소',
);
if (!choice || choice === '취소') return;
if (choice === '연결 해제') {
await writeCalendarConfig(context, {
clientId: undefined, clientSecret: undefined, refreshToken: undefined,
accessToken: undefined, accessTokenExpiresAt: undefined,
connectedAs: undefined, connectedAt: undefined,
});
vscode.window.showInformationMessage(
'OAuth 연결 해제. https://myaccount.google.com/permissions 에서도 권한을 직접 회수할 수 있습니다.',
);
return;
}
// 재연결 → 아래 flow
} else {
const intro = await vscode.window.showInformationMessage(
'📅 Google Calendar 쓰기 연결 (OAuth, 5~10분)\n\n회의록을 받으면 agent 가 자동으로 일정을 생성하게 됩니다.\n\n1단계: Google Cloud Console 에서 OAuth Client ID 발급 (수동 클릭, 가이드 따라)\n2단계: ID + Secret 붙여넣기\n3단계: 브라우저 로그인',
{ modal: true },
'시작',
'Cloud Console 먼저 열기',
'취소',
);
if (!intro || intro === '취소') return;
if (intro === 'Cloud Console 먼저 열기') {
await vscode.env.openExternal(vscode.Uri.parse('https://console.cloud.google.com/apis/credentials'));
const back = await vscode.window.showInformationMessage(
'아래 절차 마치고 돌아오세요:\n\n1. 새 프로젝트 만들기 (또는 기존)\n2. APIs & Services → Library → "Google Calendar API" 활성화\n3. OAuth 동의 화면 — External, Test users 에 본인 이메일\n4. Credentials → Create OAuth 2.0 Client ID → "Desktop app"\n5. Client ID + Client Secret 복사',
{ modal: true },
'다 됐음 →',
'취소',
);
if (back !== '다 됐음 →') return;
}
}
// Settings 에 이미 채워져 있으면 그대로 쓰겠냐고 물어봄 — 매번 똑같은 값 다시 입력하기 귀찮음.
const haveBoth = !!(cur.clientId && cur.clientSecret);
let clientId: string | undefined = cur.clientId;
let clientSecret: string | undefined = cur.clientSecret;
if (haveBoth) {
const useExisting = await vscode.window.showInformationMessage(
`Settings (g1nation.google) 에 이미 Client ID/Secret 이 있습니다.\nID: ${cur.clientId!.slice(0, 20)}\n\n이 값으로 OAuth 진행할까요?`,
{ modal: false },
'예 (Settings 값 사용)',
'아니오 (새로 입력)',
'취소',
);
if (useExisting === '취소' || !useExisting) return;
if (useExisting === '아니오 (새로 입력)') {
clientId = undefined;
clientSecret = undefined;
}
}
if (!clientId) {
clientId = await vscode.window.showInputBox({
title: 'Google OAuth Client ID',
prompt: 'Credentials 페이지에서 복사한 Client ID — 자동으로 Settings(g1nation.google.clientId)에 저장됨',
placeHolder: 'xxxxxxxx.apps.googleusercontent.com',
value: cur.clientId,
ignoreFocusOut: true,
validateInput: (v) => (v || '').trim() ? null : '비어있어요',
});
if (!clientId) return;
}
if (!clientSecret) {
clientSecret = await vscode.window.showInputBox({
title: 'Google OAuth Client Secret',
prompt: '같은 화면의 Client Secret — Settings(g1nation.google.clientSecret)에 저장됨',
placeHolder: 'GOCSPX-...',
value: cur.clientSecret,
password: true,
ignoreFocusOut: true,
validateInput: (v) => (v || '').trim() ? null : '비어있어요',
});
if (!clientSecret) return;
}
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: '🔐 Google 로그인 대기 중…',
cancellable: true,
}, async (progress, cancelToken) => {
progress.report({ message: '브라우저에서 Google 로그인 진행하세요 (최대 5분 대기)' });
const result = await runOAuthLoopback(clientId.trim(), clientSecret.trim(), cancelToken);
if (!result.ok) {
vscode.window.showErrorMessage(`OAuth 실패: ${result.error}`);
return;
}
const email = await fetchUserEmail(result.accessToken);
await writeCalendarConfig(context, {
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
refreshToken: result.refreshToken,
accessToken: result.accessToken,
accessTokenExpiresAt: result.expiresAt,
calendarId: cur.calendarId ?? 'primary',
defaultDurationMinutes: cur.defaultDurationMinutes ?? 60,
connectedAs: email,
connectedAt: new Date().toISOString(),
});
vscode.window.showInformationMessage(
`✅ Google Calendar 쓰기 연결 완료!${email ? ' (' + email + ')' : ''}\n\n이제 회의록을 보내거나 due 가 있는 작업을 알려주면 agent 가 자동으로 일정을 생성합니다.`,
);
});
}
+60
View File
@@ -0,0 +1,60 @@
import * as vscode from 'vscode';
import { buildApiUrl, logInfo, logError } from '../utils';
/**
* Astra 첫 실행 시 자동 셋업 마법사 — LM Studio/Ollama URL 자동 감지 + 사용자 확인.
* extension.ts 의 `activate()` 가 처음 호출될 때 한 번 실행. 이미 사용자가 URL 을
* 설정해 둔 환경에서는 즉시 skip.
*/
export 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);
}
}
+46
View File
@@ -0,0 +1,46 @@
import * as vscode from 'vscode';
import { openKnowledgeMapEditor } from '../skills/agentKnowledgeMap';
import { createLessonCard, manageLessons } from './lessons';
import type { AgentExecutor } from '../agent';
/**
* Experience Memory 명령 묶음 — knowledge map / lesson cards 의 CRUD entrypoint.
* activate() 안에 있던 4개 command 를 한 곳으로 모음. `fromConversation` 만
* agent 의 history 를 읽기 때문에 deps 가 필요. 나머지는 stateless.
*
* agent 는 getter 로 받음 — activate() 가 agent 인스턴스 변수에 할당한 *후* 이
* commands 가 호출되므로 등록 시점엔 아직 undefined 일 수 있다. getter 가 호출
* 시점의 최신 값을 반환.
*/
export interface LessonCommandsDeps {
getAgent: () => AgentExecutor;
}
export function registerLessonCommands(deps: LessonCommandsDeps): vscode.Disposable[] {
return [
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 = deps.getAgent().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()),
];
}
+135
View File
@@ -0,0 +1,135 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { findBrainFiles, getActiveBrainProfile, openInEditorGroup } from '../utils';
import { getBrainTokenIndex } from '../retrieval';
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from '../retrieval/lessonHelpers';
/**
* Experience Memory — lesson card 의 *대화형* 명령 (create / manage).
*
* 옛 코드: extension.ts 의 `activate()` 안 nested function 3개 (listLessonFiles,
* createLessonCard, manageLessons). 그러나 셋 다 `activate()` 클로저에서 캡처하는
* 게 하나도 없었음 (모두 module-level import 만 사용) — nested 인 이유 없음.
* 분리해 (a) extension.ts 의 activate() 길이 축소, (b) lesson 워크플로우의 단위
* 테스트 가능, (c) 다른 곳 (예: chat handler) 에서도 직접 호출 가능.
*/
/** 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. */
export 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. */
export 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();
}
+136
View File
@@ -0,0 +1,136 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import type { SidebarChatProvider } from '../sidebarProvider';
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './calendarSetup';
/**
* Provider 위에 얇게 얹힌 명령 묶음 — architecture / company / calendar / devil.
* 4 클러스터 모두 `provider._X()` 메서드를 직접 호출하거나 외부 마법사를 부르는
* thin shell 이라 deps 가 거의 동일 (`context` + `provider getter`).
*
* 옛 코드: extension.ts 의 `activate()` 안 100줄 inline command block. 분리해
* (a) activate() 길이 축소, (b) command 추가/수정 시 한 파일만 수정, (c) 새 명령
* cluster 추가 시 같은 패턴 (`registerXCommands(context, deps)`) 으로 일관.
*
* `provider` 는 getter 로 받음 — activate() 가 provider 인스턴스를 변수에 할당
* 한 후 commands 가 호출되므로, 등록 시점엔 아직 undefined 일 수 있다. getter 가
* 호출 시점의 최신 값을 반환.
*/
export interface ProviderCommandsDeps {
/** activate() 의 `let provider` 를 호출 시점에 회수. registration 시점엔 undefined 일 수 있음. */
getProvider: () => SidebarChatProvider | undefined;
}
export function registerProviderCommands(
context: vscode.ExtensionContext,
deps: ProviderCommandsDeps,
): vscode.Disposable[] {
return [
// ── Project Architecture (Feature 2) ─────────────────────────────────
// Thin shells — 모든 state mutation 은 provider 가 갖고 있게 (chip / watcher).
vscode.commands.registerCommand('g1nation.architecture.refresh', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._refreshArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture context refreshed.');
}),
vscode.commands.registerCommand('g1nation.architecture.detach', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._detachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.');
}),
vscode.commands.registerCommand('g1nation.architecture.attach', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._attachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned on.');
}),
vscode.commands.registerCommand('g1nation.architecture.open', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._openArchitectureDoc();
}),
// Active subproject 재해상 — 사용자가 서브폴더 사이 editor 를 옮기면 chip 갱신.
// 400ms debounce — 빠른 editor flick 이 watcher 를 churn 시키지 않게.
// resync 자체는 idempotent — 활성 서브프로젝트가 안 바뀌면 noop.
(() => {
let timer: NodeJS.Timeout | undefined;
return vscode.window.onDidChangeActiveTextEditor(() => {
const provider = deps.getProvider();
if (!provider) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
provider._sendArchitectureStatus().catch(() => { /* chip 은 best-effort */ });
}, 400);
});
})(),
// ── 1인 기업 (Company) Mode ──────────────────────────────────────────
vscode.commands.registerCommand('g1nation.company.toggle', async () => {
const provider = deps.getProvider();
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 () => {
const provider = deps.getProvider();
if (!provider) return;
await vscode.commands.executeCommand('g1nation-v2-view.focus');
provider._view?.webview.postMessage({ type: 'openCompanyManageOverlay' });
await provider._sendCompanyAgents();
await provider._sendCompanyResumable();
}),
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}`);
}
}),
vscode.commands.registerCommand('g1nation.company.pixelOffice.open', () => {
// 사이드바 mini 패널과 별도로 editor area 에 전체 사무실 뷰. 같은
// pixelOfficeUpdate 스트림 공유 → 백엔드 변경 최소.
deps.getProvider()?.openPixelOfficePanel();
}),
// ── Google Calendar (iCal 읽기 / OAuth 쓰기) ────────────────────────
vscode.commands.registerCommand('g1nation.calendar.connect', async () => {
await runConnectGoogleCalendarIcal(context);
}),
vscode.commands.registerCommand('g1nation.calendar.refresh', async () => {
const { refreshCalendarCache } = await import('../features/calendar');
const r = await refreshCalendarCache(context);
if (r.ok) {
vscode.window.showInformationMessage(`📅 캘린더 ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.`);
} else {
vscode.window.showErrorMessage(r.error || 'Calendar 새로고침 실패');
}
}),
vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => {
await runConnectGoogleCalendarOAuth(context);
}),
// ── Devil Agent (도현) — 답변 직후 비판적 반박 토글 ─────────────────
vscode.commands.registerCommand('g1nation.devilAgent.toggle', async () => {
const { isDevilAgentEnabled, setDevilAgentEnabled, DEVIL_PERSONA_NAME } =
await import('../features/devilAgent');
const wasOn = isDevilAgentEnabled();
await setDevilAgentEnabled(!wasOn);
const nowOn = !wasOn;
vscode.window.showInformationMessage(
nowOn
? `🎭 ${DEVIL_PERSONA_NAME} 활성화됨 — 이제 매 답변 뒤에 비판적 반박 카드가 떠요.`
: `🎭 ${DEVIL_PERSONA_NAME} 비활성화됨.`,
);
}),
];
}
+50
View File
@@ -0,0 +1,50 @@
import * as vscode from 'vscode';
import { FileSystemProjectScaffolder } from '../scaffolder/projectScaffolder';
import type { ProjectTemplateId } from '../scaffolder/templates';
/**
* Project Scaffolder — Astra 의 Developer 빠른 시작 명령 (`g1nation.scaffoldProject`).
*
* activate() 안에 inline 으로 있던 ~35줄 wizard 를 별도 모듈로 분리. scaffolder
* 인스턴스는 명령 등록 시 1회 생성 — 매 실행마다 new 할 필요 없고 외부에서 참조도
* 안 됨 → 모듈 안에 가둠.
*/
export function registerScaffoldCommand(): vscode.Disposable {
const scaffolder = new FileSystemProjectScaffolder();
return 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));
}
});
}
+64
View File
@@ -0,0 +1,64 @@
import * as vscode from 'vscode';
import { SettingsPanelProvider } from '../features/settings/settingsPanelProvider';
import { clearBrainTokenIndex } from '../retrieval';
import { TELEGRAM_TOKEN_SECRET_KEY } from './telegramCommands';
import type { TelegramBot } from '../integrations/telegram/telegramBot';
import type { TelegramHttpClient } from '../integrations/telegram/telegramClient';
export interface SettingsSetupDeps {
telegramClient: TelegramHttpClient;
telegramBot: TelegramBot;
}
/**
* Astra Settings 웹뷰 패널 + 그에 매달린 listener / 명령 묶음. activate() 안에
* inline 으로 있던 ~40줄 (panel 생성 + 3 listener + 2 command) 을 한 곳으로 모음.
*
* panel 인스턴스는 호출자가 `openAsPanel()` 호출용으로 필요해서 반환값에 포함.
* disposable 들은 호출자가 `context.subscriptions.push(...)` 로 등록.
*
* - config change listener — `g1nation.*` 변경 시 UI refresh
* - brain config change listener — `brainProfiles` / `activeBrainId` 변경 시
* in-memory 토큰 인덱스 폐기 (stale path 보호)
* - secrets change listener — Telegram 토큰 SecretStorage 변경 시 UI refresh
* - g1nation.settings.focus — 패널 활성화
* - g1nation.settings.diagnose — 패널 등록 여부 확인 (Set 버튼 미동작 신고용)
*/
export function setupSettingsPanel(
context: vscode.ExtensionContext,
deps: SettingsSetupDeps,
): { settingsPanel: SettingsPanelProvider; disposables: vscode.Disposable[] } {
const settingsPanel = new SettingsPanelProvider({
extensionUri: context.extensionUri,
context,
secrets: context.secrets,
telegramClient: deps.telegramClient,
telegramBot: deps.telegramBot,
});
const disposables: vscode.Disposable[] = [
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation')) void settingsPanel.refresh();
}),
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation.brainProfiles')
|| e.affectsConfiguration('g1nation.activeBrainId')) {
clearBrainTokenIndex();
}
}),
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 () => {
try {
await settingsPanel.focus();
vscode.window.showInformationMessage('Astra Settings 패널이 열렸습니다. 사이드바 Settings 항목을 확인하세요.');
} catch (e: any) {
vscode.window.showErrorMessage(`Settings 패널 열기 실패 (확장 reload가 필요할 수 있음): ${e?.message ?? e}`);
}
}),
];
return { settingsPanel, disposables };
}
+103
View File
@@ -0,0 +1,103 @@
import * as vscode from 'vscode';
import type { TelegramBot } from '../integrations/telegram/telegramBot';
import type { TelegramHttpClient } from '../integrations/telegram/telegramClient';
/** SecretStorage key for the bot token. Shared with Settings panel listener. */
export const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
/**
* Single-element token cache shared by extension.ts and this module.
*
* `telegramClient` is constructed with `getToken: () => tokenStore.current`,
* so when this module updates `tokenStore.current` in response to a secrets
* change the client picks up the new value on its next HTTP call — no need
* to rebuild the client.
*/
export interface TelegramTokenStore { current: string }
export interface TelegramCommandsDeps {
telegramBot: TelegramBot;
telegramClient: TelegramHttpClient;
tokenStore: TelegramTokenStore;
}
/**
* Telegram bot 라이프사이클 + 명령 묶음. activate() 안에 inline 으로 있던 58줄
* (refresh helper + dispose hook + 2 listener + 3 command) 을 한 모듈로 모음.
*
* 반환된 disposable 들은 호출자가 `context.subscriptions.push(...)` 로 등록.
*
* - refreshTelegramBot() — config `telegram.enabled` + token 둘 다 있을 때만 start
* - dispose hook — 확장 종료 시 bot 정지
* - config change listener — `telegram.enabled` 토글 → refresh
* - secrets change listener — 토큰 외부 변경 → cache 갱신 + refresh
* - g1nation.telegram.setBotToken — InputBox 로 토큰 등록
* - g1nation.telegram.clearBotToken
* - g1nation.telegram.testConnection — `getMe` 호출로 토큰 유효성 확인
*/
export function registerTelegramCommands(
context: vscode.ExtensionContext,
deps: TelegramCommandsDeps,
): vscode.Disposable[] {
const { telegramBot, telegramClient, tokenStore } = deps;
const refreshTelegramBot = async () => {
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('telegram.enabled', false);
const tokenPresent = !!tokenStore.current.trim();
if (enabled && tokenPresent) {
telegramBot.start();
} else if (telegramBot.isRunning()) {
await telegramBot.stop();
}
};
void refreshTelegramBot();
return [
{ 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;
tokenStore.current = (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}`);
}
}),
];
}