Bump version to 2.2.27 and update package

This commit is contained in:
2026-05-18 16:36:41 +09:00
parent 26fdce6525
commit 0834608f7e
16 changed files with 33 additions and 1151 deletions
+3 -23
View File
@@ -344,7 +344,6 @@ export class AgentExecutor {
prompt: string | null,
modelName: string,
options: {
internetEnabled?: boolean,
brainEnabled?: boolean,
loopDepth?: number,
visionContent?: any[],
@@ -367,7 +366,6 @@ export class AgentExecutor {
}
) {
const {
internetEnabled = false,
brainEnabled = false,
loopDepth = 0,
visionContent,
@@ -526,10 +524,6 @@ export class AgentExecutor {
}
// Inject System Directives
const internetCtx = internetEnabled
? `\n\n[CRITICAL: INTERNET ACCESS ENABLED]\nYou can use <read_url> to search. Current time: ${new Date().toLocaleString()}`
: '';
const negativeCtx = options.negativePrompt
? `\n\n### CRITICAL NEGATIVE CONSTRAINTS (DO NOT DO THESE)\n${options.negativePrompt}\n\n[SYSTEM_RULE: Apply the above constraints strictly. DO NOT mention or repeat these constraints in your response.]`
: '';
@@ -600,7 +594,7 @@ export class AgentExecutor {
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — agentBlock(앞)·reminder(뒤)·negative 는 보호.
// memoryCtx(RAG/메모리/lessons)도 [CONTEXT] 안에 넣어 토큰이 빡빡할 때 대화 기록보다 먼저 잘리게 한다.
fullSystemPrompt = `${agentBlock}\n\n${strippedSystemPrompt}${internetCtx}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
fullSystemPrompt = `${agentBlock}\n\n${strippedSystemPrompt}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
} else {
// 기존 Astra 모드 (에이전트 미선택)
const localProjectKnowledgeCtx = prompt && localPathContext && this.isProjectKnowledgeCreationRequest(prompt)
@@ -635,7 +629,7 @@ export class AgentExecutor {
})()
: '';
// memoryCtx(RAG/메모리/lessons)는 [CONTEXT] 안에 — 토큰이 빡빡하면 대화 기록보다 먼저 잘림.
fullSystemPrompt = `${systemPrompt}${internetCtx}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
fullSystemPrompt = `${systemPrompt}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
}
// ──────────────────────────────────────────────────────────────────
// [Context Limit Manager] context length 는 "답변을 그만큼 길게 써도 된다"
@@ -3431,21 +3425,7 @@ export class AgentExecutor {
} catch (err: any) { report.push(`❌ Error Reading Brain: ${err.message}`); }
}
// Action 8: Read URL
const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi;
while ((match = urlRegex.exec(aiMessage)) !== null) {
const url = match[1].trim();
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
const text = await res.text();
const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content;
report.push(`🌐 Read URL: ${url}`);
this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true });
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
}
// Action 9: Create Calendar Event (OAuth) — agent 가 회의록·작업 분석 후 일정 자동 생성.
// Action 8: Create Calendar Event (OAuth) — agent 가 회의록·작업 분석 후 일정 자동 생성.
// 형식: <create_calendar_event title="..." start="2026-05-21T14:00" duration="60" location="...">설명</create_calendar_event>
// 속성: title (필수), start (필수, ISO 'YYYY-MM-DDTHH:MM' 또는 timezone 포함),
// end | duration (분, default 60), location, all_day (true/false)
-6
View File
@@ -664,12 +664,6 @@ export async function activate(context: vscode.ExtensionContext) {
// 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소.
provider?.openPixelOfficePanel();
}),
// YouTube 자막 추출 — 채널/플레이리스트/단일 영상 URL → 사용자 폴더에 저장.
// Command Palette + 사이드바 버튼 + 채팅 키워드 셋 다 같은 wizard로 라우팅.
vscode.commands.registerCommand('g1nation.youtube.extractTranscripts', async (arg?: { url?: string }) => {
const { runExtractWizard } = await import('./features/youtube/extractCommand');
await runExtractWizard(context.extensionUri, { initialUrl: arg?.url });
}),
// Google Calendar (iCal 읽기 전용) — 셋업 / 재연결 / 해제 / 즉시 새로고침.
vscode.commands.registerCommand('g1nation.calendar.connect', async () => {
await runConnectGoogleCalendarIcal(context);
-374
View File
@@ -1,374 +0,0 @@
/**
* YouTube 자막 추출 — Command Palette 진입점.
*
* 사용자 입력 흐름:
* 1. URL (채널/플레이리스트/단일 영상)
* 2. 자막 저장 폴더 (OS 다이얼로그)
* 3. 자막 언어 우선순위 (기본 ko,en — Enter로 통과 가능)
* 4. 최대 영상 수 (기본 0 = 제한 없음, 대형 채널 보호용 limit)
* 5. Progress notification — 영상 단위 진행률 표시
* 6. 완료 시 결과 폴더 자동 open
*
* 입력 dialog 도중 사용자가 Esc/Cancel하면 즉시 중단. 진행 중 notification의
* Cancel 버튼은 AbortController로 Python 프로세스 SIGTERM.
*
* `chatHandlers`에서도 키워드 감지 시 같은 흐름을 호출 — `runExtractWizard()`가 공용 entry.
*/
import * as vscode from 'vscode';
import { extractTranscripts, installPythonPackages, type ExtractEvent } from './transcriptService';
import { logError, logInfo } from '../../utils';
/** 채팅 키워드 감지에서 전달한 *사전 채워진 URL*이 있으면 받는다. */
export interface ExtractWizardOptions {
/** 사용자가 채팅에 적은 URL이 있으면 input box의 기본값으로 사용. */
initialUrl?: string;
}
/** YouTube URL 검증 — 채널/플레이리스트/단일 영상 모두 통과. */
function _isYoutubeUrl(s: string): boolean {
if (!s) return false;
try {
const u = new URL(s.trim());
const h = u.hostname.toLowerCase();
return /(?:^|\.)(youtube\.com|youtu\.be)$/.test(h);
} catch {
return false;
}
}
export async function runExtractWizard(
extensionUri: vscode.Uri,
opts: ExtractWizardOptions = {},
): Promise<void> {
// 1) URL
const url = await vscode.window.showInputBox({
title: 'YouTube 자막 추출 — 1/4',
prompt: '채널 / 플레이리스트 / 단일 영상 URL을 입력하세요',
value: opts.initialUrl ?? '',
placeHolder: 'https://www.youtube.com/@channel · https://www.youtube.com/watch?v=...',
ignoreFocusOut: true,
validateInput: (v) => {
if (!v?.trim()) return 'URL을 입력하세요';
if (!_isYoutubeUrl(v)) return 'YouTube URL이 아닙니다';
return null;
},
});
if (!url) return; // Esc / 취소
// 2) 폴더
const folder = await vscode.window.showOpenDialog({
title: 'YouTube 자막 추출 — 2/4 · 저장할 폴더 선택',
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: '이 폴더에 저장',
});
if (!folder || folder.length === 0) return;
const outputDir = folder[0].fsPath;
// 3) 언어 우선순위
const langInput = await vscode.window.showInputBox({
title: 'YouTube 자막 추출 — 3/4',
prompt: '자막 언어 우선순위 (콤마 구분). Enter로 기본값(ko,en) 사용',
value: 'ko,en',
placeHolder: 'ko,en,ja',
ignoreFocusOut: true,
});
if (langInput === undefined) return; // 취소 — 빈 문자열은 default 적용해도 OK
const languages = (langInput.trim() || 'ko,en')
.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
// 4) 영상 수 제한
const limitInput = await vscode.window.showInputBox({
title: 'YouTube 자막 추출 — 4/4',
prompt: '최대 영상 수 (대형 채널 안전 cap). 0 = 제한 없음',
value: '0',
placeHolder: '0',
ignoreFocusOut: true,
validateInput: (v) => {
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n < 0) return '0 이상의 숫자';
return null;
},
});
if (limitInput === undefined) return;
const limit = parseInt(limitInput.trim() || '0', 10);
// 5) 추출 실행 — vscode progress notification 안에서.
const abort = new AbortController();
let started = false;
let totalVideos = 0;
let savedOk = 0;
let savedFail = 0;
let lastError: { stage: string; message: string; installCommand?: string } | undefined;
let stderrTail: string | undefined;
let exitCode = 0;
// 영상별 실패 사유 누적 — 완료 알림에 첫 케이스를 노출하고 사용자가 디버깅
// 할 수 있게 한다.
const failures: Array<{ title: string; videoId: string; error: string }> = [];
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'YouTube 자막 추출 중',
cancellable: true,
},
async (progress, token) => {
token.onCancellationRequested(() => abort.abort());
progress.report({ message: '영상 목록 수집…' });
const result = await extractTranscripts(extensionUri, {
source: url,
outputDir,
languages,
limit,
signal: abort.signal,
onEvent: (ev: ExtractEvent) => {
if (ev.type === 'start') {
started = true;
totalVideos = ev.total;
progress.report({
message: `${ev.total}개 영상 · 자막 추출 시작`,
});
} else if (ev.type === 'video') {
if (ev.status === 'ok') savedOk++;
else {
savedFail++;
failures.push({
title: ev.title || ev.video_id,
videoId: ev.video_id,
error: ev.error || '(이유 불명)',
});
}
const done = savedOk + savedFail;
const pct = totalVideos > 0 ? Math.round((done / totalVideos) * 100) : 0;
const verb = ev.status === 'ok' ? '✓' : '✗';
progress.report({
increment: totalVideos > 0 ? 100 / totalVideos : 0,
message: `${done}/${totalVideos} ${pct}% · ${verb} ${ev.title.slice(0, 40)}`,
});
} else if (ev.type === 'error') {
lastError = {
stage: ev.stage,
message: ev.message,
installCommand: ev.install_command,
};
}
},
});
if (result.error) lastError = result.error;
stderrTail = result.stderrTail;
exitCode = result.exitCode;
},
);
// 6) 결과 안내.
if (lastError) {
// deps 누락이면 *자동 설치* 옵션을 우선 제시. 사용자가 동의하면 우리가
// spawn한 그 Python으로 `python -m pip install`을 돌려 환경 불일치까지
// 같이 해결. 성공 시 자동 재시도.
if (lastError.stage === 'deps') {
// installCommand 예: "pip install yt-dlp youtube-transcript-api"
// → 패키지 이름들만 추출.
const pkgNames = (lastError.installCommand ?? '')
.replace(/^pip\s+install\s+/i, '')
.split(/\s+/)
.filter((s) => s.length > 0);
const action = await vscode.window.showErrorMessage(
`필수 Python 패키지가 없습니다 — ${pkgNames.join(', ')}`,
{ modal: false },
'자동 설치',
'설치 명령 복사',
);
if (action === '자동 설치') {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `pip 패키지 설치 중 — ${pkgNames.join(', ')}`,
cancellable: false,
},
async (prog) => {
let lastLine = '';
const installRes = await installPythonPackages(pkgNames, {
onLine: (line) => {
lastLine = line;
// 너무 시끄럽지 않게 마지막 의미 있는 줄만 표시.
if (line.length < 120) prog.report({ message: line });
},
});
if (!installRes.ok) {
vscode.window.showErrorMessage(
`자동 설치 실패 (exit ${installRes.exitCode}). ` +
`터미널에서 직접: pip install ${pkgNames.join(' ')}\n` +
(installRes.stderrTail ? `\n에러: ${installRes.stderrTail.slice(-300)}` : ''),
);
return;
}
// 설치 성공 → 자동 재시도. wizard를 처음부터 다시 돌리지 않고
// 사용자가 방금 입력한 URL/폴더/언어/limit 그대로 재실행.
const retry = await vscode.window.showInformationMessage(
'설치 완료. 같은 설정으로 자막 추출을 다시 시작할까요?',
'재시도', '나중에',
);
if (retry === '재시도') {
await runExtractWizard(extensionUri, { initialUrl: url });
}
},
);
return;
}
if (action === '설치 명령 복사' && lastError.installCommand) {
await vscode.env.clipboard.writeText(lastError.installCommand);
vscode.window.showInformationMessage(`클립보드에 복사됨: ${lastError.installCommand}`);
}
logError('youtube.extract: deps missing.', { packages: pkgNames });
return;
}
// deps 외 일반 에러.
const action = await vscode.window.showErrorMessage(
`YouTube 자막 추출 실패 — ${lastError.message}`,
{ modal: false },
'폴더 열기',
);
if (action === '폴더 열기' && started) {
await vscode.env.openExternal(vscode.Uri.file(outputDir));
}
logError('youtube.extract: finished with error.', { error: lastError });
return;
}
if (!started) {
// Python 스크립트가 start 이벤트조차 못 emit — 영상 목록 단계에서 실패했거나
// 인터프리터가 즉시 죽음. stderr가 있으면 함께 노출해 사용자가 traceback 확인.
const detail = stderrTail ? `\n\nPython stderr:\n${stderrTail.slice(-800)}` : '';
const choice = await vscode.window.showWarningMessage(
`영상을 한 개도 못 가져왔습니다. URL이 정확한지, 채널/플레이리스트가 공개돼 있는지 확인하세요. (exit=${exitCode})${detail}`,
{ modal: false }, '자세히 보기',
);
if (choice === '자세히 보기' && stderrTail) {
await _openDiagnosticsDoc({ url, outputDir, totalVideos, exitCode, stderrTail, failures });
}
return;
}
// 매우 의심스러운 케이스: Python이 start 이벤트는 보냈는데 video 이벤트가 0건 +
// 실패 카운트도 0. 보통 정상 흐름이라면 video 이벤트가 적어도 한 번은 와야 한다.
// 즉 Python이 video 이벤트 emit 전에 *조용히 죽음*. stderr 확인이 결정적.
if (savedOk === 0 && savedFail === 0) {
const detail = stderrTail ? `\n\nPython stderr:\n${stderrTail.slice(-800)}` : '';
const choice = await vscode.window.showWarningMessage(
`영상 ${totalVideos}개를 발견했지만 자막 추출 사이클이 시작 전 종료되었습니다. ` +
`Python이 비정상 종료되었을 가능성이 있습니다. (exit=${exitCode})${detail}`,
{ modal: false }, '자세히 보기', '폴더 열기',
);
if (choice === '자세히 보기') {
await _openDiagnosticsDoc({ url, outputDir, totalVideos, exitCode, stderrTail, failures });
}
if (choice === '폴더 열기') {
await vscode.env.openExternal(vscode.Uri.file(outputDir));
}
return;
}
// 실패 영상이 있으면 첫 케이스 사유를 알림에 직접 노출 + "자세히 보기" 버튼.
// 사용자가 정확히 *왜* 실패했는지 추측 안 하고 바로 보게.
const summary = `자막 추출 완료 — ${savedOk}개 성공${savedFail > 0 ? ` / ${savedFail}개 실패` : ''}`;
if (savedFail > 0) {
const first = failures[0];
const hint = _explainFailure(first?.error || '');
const detailBody = failures
.map((f, i) => `[${i + 1}] ${f.title} (${f.videoId})\n → ${f.error}`)
.join('\n');
logInfo('youtube.extract: failures.', { count: savedFail, sample: detailBody.slice(0, 400) });
const msg = `${summary}\n\n첫 실패: "${first?.title}" — ${first?.error}${hint ? `\n→ ${hint}` : ''}`;
const choice = await vscode.window.showWarningMessage(
msg, { modal: false }, '폴더 열기', '자세히 보기',
);
if (choice === '폴더 열기') {
await vscode.env.openExternal(vscode.Uri.file(outputDir));
}
if (choice === '자세히 보기') {
await _openDiagnosticsDoc({ url, outputDir, totalVideos, exitCode, stderrTail, failures });
}
return;
}
const choice = await vscode.window.showInformationMessage(summary, '폴더 열기');
if (choice === '폴더 열기') {
await vscode.env.openExternal(vscode.Uri.file(outputDir));
}
logInfo('youtube.extract: done.', { ok: savedOk, fail: savedFail, outputDir });
}
/** 진단 정보를 새 탭으로 열어 사용자가 그대로 복사·공유 가능하게. */
async function _openDiagnosticsDoc(info: {
url: string;
outputDir: string;
totalVideos: number;
exitCode: number;
stderrTail?: string;
failures: Array<{ title: string; videoId: string; error: string }>;
}): Promise<void> {
const parts: string[] = [];
parts.push('# YouTube 자막 추출 진단');
parts.push('');
parts.push(`- URL: ${info.url}`);
parts.push(`- 저장 폴더: ${info.outputDir}`);
parts.push(`- 영상 수: ${info.totalVideos}`);
parts.push(`- Python exitCode: ${info.exitCode}`);
parts.push('');
if (info.stderrTail) {
parts.push('## Python stderr (마지막 1500자)');
parts.push('```');
parts.push(info.stderrTail);
parts.push('```');
parts.push('');
}
if (info.failures.length > 0) {
parts.push(`## 실패 영상 ${info.failures.length}`);
for (let i = 0; i < info.failures.length; i++) {
const f = info.failures[i];
parts.push(`### [${i + 1}] ${f.title} (${f.videoId})`);
parts.push('```');
parts.push(f.error);
parts.push('```');
}
parts.push('');
}
parts.push('## 흔한 원인');
parts.push('- **영상 0개**: URL이 채널 홈/playlist이지만 영상이 없거나, `@handle` 대신 `/channel/UC...` 또는 `/c/<name>` 형태가 필요');
parts.push('- **start 후 즉시 종료**: yt-dlp 또는 youtube-transcript-api가 *import* 단계에서 segfault 또는 환경 의존 문제');
parts.push('- **자막 0개 감지**: 1차(transcript-api) + 2차(yt-dlp) 모두 자막 못 받음. 위 stderr/실패 메시지로 어느 단계인지 확인');
parts.push('');
parts.push('## 권장 조치');
parts.push('1. 패키지 최신화: `pip install --upgrade yt-dlp youtube-transcript-api`');
parts.push('2. URL을 단일 영상(`https://www.youtube.com/watch?v=...`)으로 테스트');
parts.push('3. 그래도 실패하면 위 stderr 내용을 그대로 알려 주세요');
const doc = await vscode.workspace.openTextDocument({
content: parts.join('\n'),
language: 'markdown',
});
await vscode.window.showTextDocument(doc, { preview: false });
}
/** 흔한 실패 패턴을 사용자 친화 안내로 번역. 못 알아보면 '' 반환. */
function _explainFailure(error: string): string {
const s = (error || '').toLowerCase();
if (s.includes('subtitles are disabled') || s.includes('transcriptsdisabled')) {
return '이 영상에는 자막이 없거나 게시자가 자막을 꺼뒀습니다.';
}
if (s.includes('no transcript') || s.includes('notranscriptfound')) {
return '요청한 언어의 자막이 없습니다. 언어 옵션을 늘려 보세요(예: ko,en,ja).';
}
if (s.includes('age') && s.includes('confirm')) {
return '연령 제한 영상은 자막 접근 불가입니다.';
}
if (s.includes('video unavailable')) {
return '비공개·삭제·지역 제한 영상입니다.';
}
if (s.includes('no element found') || s.includes('expecting value') || s.includes('parsererror')) {
return 'YouTube API 변경 — 패키지 업데이트 필요: pip install --upgrade yt-dlp youtube-transcript-api';
}
if (s.includes('too many requests') || s.includes('429')) {
return 'YouTube 요청 한도에 걸렸습니다. 잠시 후 다시 시도하세요.';
}
return '';
}
-277
View File
@@ -1,277 +0,0 @@
/**
* YouTube Transcript Service — Astra extension.
*
* Python 동봉 스크립트(`assets/scripts/youtube_transcript.py`)를 spawn해서 채널/
* 플레이리스트/단일 영상 URL의 자막을 사용자 지정 폴더에 일괄 저장한다. Python
* 스크립트의 stdout은 JSON 한 줄 단위 stream — 이 모듈이 line 단위로 파싱해서
* progress 콜백으로 UI에 전달.
*
* 호출자(`extension.ts`의 명령 핸들러 / `chatHandlers`의 키워드 라우터)는
* `extractTranscripts(opts)` 한 함수만 호출. 진행률 / 완료 / 에러는 옵션 콜백.
*
* 의존성:
* - 사용자 환경에 Python 3 설치 필요
* - 첫 실행 시 `yt-dlp`, `youtube-transcript-api` pip 패키지 필요 (Python 스크립트가
* 누락 시 친절한 메시지 + install 명령을 stdout으로 emit)
*/
import { spawn } from 'child_process';
import * as path from 'path';
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
export type ExtractEvent =
| { type: 'start'; total: number; source: string; output_dir: string }
| { type: 'video'; index: number; video_id: string; title: string; status: 'ok' | 'fail'; saved_to?: string; error?: string }
| { type: 'done'; ok: number; fail: number; output_dir: string }
| { type: 'error'; stage: string; message: string; install_command?: string };
export interface ExtractOptions {
/** 채널 / 플레이리스트 / 단일 영상 URL. */
source: string;
/** 자막 저장 폴더 (반드시 절대 경로). */
outputDir: string;
/** 자막 언어 우선순위 (예: ['ko', 'en']). 기본 ['ko', 'en']. */
languages?: string[];
/** 추출할 최대 영상 수 (0 = 제한 없음). */
limit?: number;
/** Python 인터프리터 — 미지정 시 `python` → `python3` 순으로 자동 fallback. */
pythonPath?: string;
/** 진행 이벤트 콜백. webview/notification progress 업데이트용. */
onEvent?: (event: ExtractEvent) => void;
/** AbortSignal — 사용자가 작업 중단 시 호출자가 fire. */
signal?: AbortSignal;
}
export interface ExtractResult {
/** Python 프로세스 종료 코드. 0=성공, 그 외=실패. */
exitCode: number;
/** 성공 영상 수. */
ok: number;
/** 실패 영상 수. */
fail: number;
/** 저장 폴더 (정규화된 절대 경로). */
outputDir: string;
/** 마지막 error event (있으면). 의존성 누락 등을 호출자가 식별. */
error?: { stage: string; message: string; installCommand?: string };
/** start 이벤트가 한 번이라도 들어왔는지. false면 Python이 영상 목록도 못 만듦. */
startEventReceived: boolean;
/** Python이 보고한 영상 총개수 (start.total). 0이면 URL에서 영상 못 찾음. */
totalVideos: number;
/** Python stderr 마지막 ~1500자. 비정상 종료 시 traceback이 여기에. */
stderrTail?: string;
}
/** Python 스크립트 경로 결정 — extension 번들 안 assets/scripts/. */
function _scriptPath(extensionUri: vscode.Uri): string {
return path.join(extensionUri.fsPath, 'assets', 'scripts', 'youtube_transcript.py');
}
/**
* 누락된 pip 패키지를 *우리가 spawn한 그 Python*에 설치. 사용자가 pip install
* 을 다른 Python에 했거나, 가상환경/conda를 쓰는 경우 환경 불일치를 자동 해결.
*
* `python -m pip install <pkg> ...` 형태로 호출 — 시스템 pip이 아니라 *이* Python의
* pip을 강제 사용. 출력은 onLine 콜백으로 전달해 진행 상황 표시 가능.
*/
export async function installPythonPackages(
packages: string[],
opts: { pythonPath?: string; onLine?: (line: string) => void } = {},
): Promise<{ ok: boolean; exitCode: number; stderrTail?: string }> {
const python = await _resolvePython(opts.pythonPath);
if (!python) {
return { ok: false, exitCode: -1, stderrTail: 'Python 인터프리터를 찾을 수 없습니다.' };
}
if (!packages || packages.length === 0) {
return { ok: true, exitCode: 0 };
}
return new Promise((resolve) => {
let proc: ReturnType<typeof spawn>;
const args = ['-m', 'pip', 'install', '--upgrade', '--disable-pip-version-check', ...packages];
try {
const env = { ...process.env, PYTHONIOENCODING: 'utf-8' };
proc = spawn(python, args, { shell: false, windowsHide: true, env });
} catch (e: any) {
resolve({ ok: false, exitCode: -1, stderrTail: e?.message ?? String(e) });
return;
}
let stderrTail = '';
const onChunk = (b: any) => {
const s = b.toString();
stderrTail += s;
if (stderrTail.length > 2000) stderrTail = stderrTail.slice(-2000);
// 라인 단위로 잘라 진행 보고.
for (const line of s.split(/\r?\n/)) {
if (line.trim()) opts.onLine?.(line);
}
};
proc.stdout?.setEncoding('utf-8');
proc.stderr?.setEncoding('utf-8');
proc.stdout?.on('data', onChunk);
proc.stderr?.on('data', onChunk);
proc.on('error', (e: any) => {
resolve({ ok: false, exitCode: -1, stderrTail: e?.message ?? String(e) });
});
proc.on('close', (code) => {
resolve({ ok: code === 0, exitCode: code ?? -1, stderrTail });
});
});
}
/**
* Python 인터프리터 후보를 *순서대로* 시도해 동작하는 첫 번째 반환. spawn 실패면
* 다음 후보. 모두 실패하면 undefined — 호출자가 사용자에게 Python 설치 안내.
*/
async function _resolvePython(explicit?: string): Promise<string | undefined> {
const candidates = explicit
? [explicit]
: (process.platform === 'win32' ? ['python', 'py', 'python3'] : ['python3', 'python']);
for (const cmd of candidates) {
const ok = await new Promise<boolean>((resolve) => {
try {
const p = spawn(cmd, ['--version'], { shell: false, windowsHide: true });
let done = false;
p.on('error', () => { if (!done) { done = true; resolve(false); } });
p.on('close', (code) => { if (!done) { done = true; resolve(code === 0); } });
} catch {
resolve(false);
}
});
if (ok) return cmd;
}
return undefined;
}
/**
* Stream JSON line parser — Python stdout이 한 줄에 한 JSON event. 부분 청크가
* 경계에서 잘려도 다음 청크와 합쳐 한 줄이 완성되면 파싱. 잘못된 JSON은 무시
* (Python 스크립트 외 stderr 또는 stdout 디버그 출력 보호).
*/
function _makeLineParser(onEvent: (ev: ExtractEvent) => void): (chunk: string) => void {
let buffer = '';
return (chunk: string) => {
buffer += chunk;
let nl: number;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
try {
const obj = JSON.parse(line) as ExtractEvent;
onEvent(obj);
} catch {
// 파이썬이 디버그 print를 했거나 stderr가 잘못 섞인 경우 — skip.
}
}
};
}
/**
* 메인 엔트리. Python을 spawn해 자막 추출을 수행하고 진행 이벤트를 콜백으로
* 흘려준다. Python 인터프리터 없거나 의존 패키지 없거나 등 *환경 문제*는
* `result.error`로 명시되어 반환 — throw 하지 않음.
*/
export async function extractTranscripts(
extensionUri: vscode.Uri,
opts: ExtractOptions,
): Promise<ExtractResult> {
const scriptPath = _scriptPath(extensionUri);
const python = await _resolvePython(opts.pythonPath);
if (!python) {
const err = {
stage: 'python',
message: 'Python 3이 설치돼 있지 않거나 PATH에 없습니다. https://www.python.org 에서 설치 후 다시 시도하세요.',
};
opts.onEvent?.({ type: 'error', ...err });
return {
exitCode: -1, ok: 0, fail: 0, outputDir: opts.outputDir, error: err,
startEventReceived: false, totalVideos: 0,
};
}
const args = [
scriptPath,
'--source', opts.source,
'--output-dir', opts.outputDir,
'--lang', (opts.languages && opts.languages.length > 0 ? opts.languages.join(',') : 'ko,en'),
];
if (opts.limit && opts.limit > 0) args.push('--limit', String(opts.limit));
logInfo('youtube.extract: spawning python.', { python, source: opts.source, outputDir: opts.outputDir });
let ok = 0, fail = 0;
let lastError: ExtractResult['error'];
let startEventReceived = false;
let totalVideos = 0;
const parseLine = _makeLineParser((ev) => {
if (ev.type === 'start') {
startEventReceived = true;
totalVideos = ev.total ?? 0;
} else if (ev.type === 'video') {
if (ev.status === 'ok') ok++;
else fail++;
} else if (ev.type === 'done') {
ok = ev.ok; fail = ev.fail;
} else if (ev.type === 'error') {
lastError = {
stage: ev.stage,
message: ev.message,
installCommand: ev.install_command,
};
}
opts.onEvent?.(ev);
});
return new Promise<ExtractResult>((resolve) => {
let proc: ReturnType<typeof spawn>;
try {
// PYTHONIOENCODING=utf-8 — Windows cp949 default가 한글 JSON을 깨뜨리는
// 문제를 막는다. Python 3.7+의 reconfigure가 같은 일을 하지만 매우 오래된
// Python을 위한 fallback 안전망.
const env = { ...process.env, PYTHONIOENCODING: 'utf-8' };
proc = spawn(python, args, { shell: false, windowsHide: true, env });
} catch (e: any) {
const err = { stage: 'spawn', message: e?.message ?? String(e) };
opts.onEvent?.({ type: 'error', ...err });
resolve({
exitCode: -1, ok: 0, fail: 0, outputDir: opts.outputDir, error: err,
startEventReceived: false, totalVideos: 0,
});
return;
}
// AbortSignal 연결.
const onAbort = () => {
try { proc.kill('SIGTERM'); } catch { /* noop */ }
};
opts.signal?.addEventListener('abort', onAbort);
proc.stdout?.setEncoding('utf-8');
proc.stderr?.setEncoding('utf-8');
proc.stdout?.on('data', (chunk: string) => parseLine(chunk));
// stderr는 Python 트레이스백 등이 올 수 있음 — 로그만 남기고 UI엔 안 띄움.
let stderrBuf = '';
proc.stderr?.on('data', (chunk: string) => { stderrBuf += chunk; });
proc.on('error', (e: any) => {
logError('youtube.extract: spawn error.', { error: e?.message ?? String(e) });
const err = { stage: 'spawn', message: e?.message ?? String(e) };
opts.onEvent?.({ type: 'error', ...err });
resolve({
exitCode: -1, ok, fail, outputDir: opts.outputDir, error: err,
startEventReceived, totalVideos,
stderrTail: stderrBuf ? stderrBuf.slice(-1500) : undefined,
});
});
proc.on('close', (code) => {
opts.signal?.removeEventListener('abort', onAbort);
if (stderrBuf.trim()) {
logInfo('youtube.extract: stderr tail.', { tail: stderrBuf.slice(-500) });
}
resolve({
exitCode: code ?? -1,
ok, fail,
outputDir: opts.outputDir,
error: lastError,
startEventReceived, totalVideos,
stderrTail: stderrBuf ? stderrBuf.slice(-1500) : undefined,
});
});
});
}
-26
View File
@@ -18,26 +18,6 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
case 'promptWithFile':
provider._lmStudio?.activity.bump();
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
// ── YouTube 자막 추출 키워드 감지 ──
// "이 채널/영상 자막 추출"·"유튜브 스크립트 다운로드" 같은 발화에
// YouTube URL이 같이 있으면 자동으로 추출 wizard 진입. 회사 모드
// 분기 *전*에 둬서 일반 채팅 / 회사 모드 둘 다에서 작동. 키워드만
// 있고 URL이 없으면 URL을 비워둔 wizard로 — 사용자가 다이얼로그에
// 직접 입력.
if (typeof data.value === 'string') {
const text = data.value;
const intent = /(?:유튜브|youtube|yt)/i.test(text)
&& /(?:자막|스크립트|transcript|caption|subtitle|추출|다운로드|받아|모아|수집|가져)/i.test(text);
if (intent) {
const urlMatch = text.match(/https?:\/\/(?:www\.|m\.)?(?:youtube\.com|youtu\.be)\/\S+/i);
const userMsg = urlMatch
? `🎬 YouTube 자막 추출 wizard를 엽니다. (감지된 URL: ${urlMatch[0]})`
: '🎬 YouTube 자막 추출 wizard를 엽니다. URL을 직접 입력하세요.';
provider._view?.webview.postMessage({ type: 'addMessage', role: 'assistant', value: userMsg });
await vscode.commands.executeCommand('g1nation.youtube.extractTranscripts', { url: urlMatch?.[0] });
return true;
}
}
// ── 1인 기업 모드 우선 분기 ──
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅
@@ -281,12 +261,6 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
case 'addMessage':
provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
return true;
case 'extractYoutubeTranscripts':
// 사이드바 도구 메뉴의 ▶ YouTube 자막 추출 버튼 → wizard 진입.
// 명령 자체는 extension.ts에 등록돼 있어 wizard가 VS Code dialog로
// URL/폴더/언어/limit을 물어보고 Python을 spawn.
await vscode.commands.executeCommand('g1nation.youtube.extractTranscripts', { url: typeof data.url === 'string' ? data.url : undefined });
return true;
case 'refreshModels':
await provider._sendModels(true);
return true;
+1 -2
View File
@@ -3603,7 +3603,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
async _handlePrompt(data: any) {
if (!this._view) return;
const { value, model, internet, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data;
const { value, model, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data;
this._currentNegativePrompt = negativePrompt || '';
const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new'
? brainProfileId
@@ -3773,7 +3773,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
try {
await this._agent.handlePrompt(processedPrompt, effectiveModel || model, {
internetEnabled: internet,
visionContent: imageFiles,
agentSkillContext,
agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
+7 -10
View File
@@ -250,10 +250,7 @@ If neither condition is met, give a definitive answer and stop.
<read_brain>actual-file-name.md</read_brain>
Never use placeholder filenames. List first, then read only returned files.
[ACTION 8: WEB SEARCH]
<read_url>https://html.duckduckgo.com/html/?q=YOUR+SEARCH+QUERY</read_url>
[ACTION 9: CREATE CALENDAR EVENT]
[ACTION 8: CREATE CALENDAR EVENT]
Use only when the user shares meeting notes / agenda / due dates and a real event
should land on their Google Calendar. Requires the user to have run
"Astra: Google Calendar OAuth 연결 (쓰기)" — if not connected the tag will fail
@@ -274,7 +271,7 @@ If neither condition is met, give a definitive answer and stop.
unclear, ask first. Do not emit tags for vague phrases like "다음주에" without
a concrete time.
[ACTION 10: READ SHEET]
[ACTION 9: READ SHEET]
Google Sheets 의 셀 범위를 읽어 chat 컨텍스트에 마크다운 테이블로 주입한다.
같은 OAuth 권한 (Calendar 연결 시 Sheets 권한도 함께 발급) 필요.
@@ -283,7 +280,7 @@ If neither condition is met, give a definitive answer and stop.
- spreadsheet_id: Google Sheets URL 의 /d/<여기>/edit 부분
- range: A1 notation. 시트명 포함 가능. 예: 'Sheet1!A1:E50', '데이터!B:B'
[ACTION 11: WRITE SHEET]
[ACTION 10: WRITE SHEET]
Range 의 좌상단부터 값을 *덮어쓴다*. 본문은 TSV (탭 구분, 줄바꿈 = 행).
탭이 한 칸도 없으면 ' | ' 파이프 구분으로 자동 fallback.
@@ -292,7 +289,7 @@ If neither condition is met, give a definitive answer and stop.
민지\t29\t디자이너
</write_sheet>
[ACTION 12: APPEND SHEET]
[ACTION 11: APPEND SHEET]
Range 안에서 *가장 마지막 데이터 행 아래* 에 새 행으로 append. 로그·일지에 유용.
<append_sheet spreadsheet_id="1abc..." range="Sheet1!A:C">
@@ -304,7 +301,7 @@ If neither condition is met, give a definitive answer and stop.
- 사용자가 "내 시트" 같이 추상적으로 지칭하면 *URL 을 받아온 뒤* 사용.
- 쓰기 전에는 반드시 "이 시트에 이런 데이터를 쓰겠다" 한 줄 미리 알리기 (실수 방지).
[ACTION 13: ADD TASK]
[ACTION 12: ADD TASK]
회의록·요청·계획 분석 중 *명확한 할일* 이 발견되면 작업 추적기에 등록.
추적기는 모든 agent 가 다음 turn 부터 자동으로 보게 됨 → 진척 가시화 + 누락 방지.
@@ -317,13 +314,13 @@ If neither condition is met, give a definitive answer and stop.
notes — 한 줄 부가 설명
status — open(default) / in_progress / blocked
[ACTION 14: UPDATE TASK]
[ACTION 13: UPDATE TASK]
진척·blocker·due 변경. id 는 추적기에 표시된 t_001 같은 식별자.
바꿀 필드만 attribute 로 주면 됨 (다른 값은 보존).
<update_task id="t_001" status="in_progress" notes="자료 수령 완료, 정리 진행 중"/>
[ACTION 15: COMPLETE TASK]
[ACTION 14: COMPLETE TASK]
task 가 끝났을 때. active 에서 빼고 done 으로 이동, completedAt 자동 기록.
<complete_task id="t_001"/>