diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index db0e0a9..12f38b9 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1785981204343, + "createdAt": 1786009859146, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index 1f901b1..6e55f32 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -1,5 +1,5 @@ { - "result": "---\nid: wiki_on\ndate: 2026-08-06T01:53:24.345Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (34ms)\n", - "createdAt": 1785981204345, + "result": "---\nid: wiki_on\ndate: 2026-08-06T09:50:59.150Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (23ms)\n", + "createdAt": 1786009859151, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index b3a1958..90401fe 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,7 +1,7 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-08-06T01:53:24.307Z", + "startTime": "2026-08-06T09:50:59.117Z", "totalElapsedMs": 40, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 34, + "durationMs": 23, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-08-06T01:53:24.341Z" + "ts": "2026-08-06T09:50:59.140Z" }, { "from": "direct", "to": "completed", - "durationMs": 6, + "durationMs": 14, "message": "미션 완료", - "ts": "2026-08-06T01:53:24.347Z" + "ts": "2026-08-06T09:50:59.154Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 95d2577..24e7e00 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1785981213594, + "createdAt": 1786009865515, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index 5a47b21..302b8c0 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1785981213593, + "createdAt": 1786009865514, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index 6794516..458404c 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1785981213590, + "createdAt": 1786009865510, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index d247028..875eff0 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1785981213592, + "createdAt": 1786009865512, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1785981213570.json b/.astra/tests/stress/.astra/missions/stress_conflict_1786009865491.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1785981213570.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1786009865491.json index 6ddcb5b..8a3f983 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1785981213570.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1786009865491.json @@ -1,7 +1,7 @@ { - "missionId": "stress_conflict_1785981213570", + "missionId": "stress_conflict_1786009865491", "status": "completed", - "startTime": "2026-08-06T01:53:33.570Z", + "startTime": "2026-08-06T09:51:05.491Z", "totalElapsedMs": 25, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", @@ -14,30 +14,30 @@ { "from": "idle", "to": "outline", - "durationMs": 19, + "durationMs": 18, "message": "답변 구조 잡는 중...", - "ts": "2026-08-06T01:53:33.589Z" + "ts": "2026-08-06T09:51:05.509Z" }, { "from": "outline", "to": "section", "durationMs": 2, "message": "본문 작성 중...", - "ts": "2026-08-06T01:53:33.591Z" + "ts": "2026-08-06T09:51:05.511Z" }, { "from": "section", "to": "polish", - "durationMs": 1, + "durationMs": 2, "message": "최종 다듬기 중...", - "ts": "2026-08-06T01:53:33.592Z" + "ts": "2026-08-06T09:51:05.513Z" }, { "from": "polish", "to": "completed", "durationMs": 3, "message": "미션 완료", - "ts": "2026-08-06T01:53:33.595Z" + "ts": "2026-08-06T09:51:05.516Z" } ], "resilienceMetrics": { diff --git a/media/sidebar.html b/media/sidebar.html index 04145b7..e55c20b 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -56,6 +56,7 @@ + diff --git a/media/sidebar.js b/media/sidebar.js index 7c485ec..b01c81e 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -1946,6 +1946,8 @@ if (nlmBridgeStartBtn) nlmBridgeStartBtn.onclick = () => vscode.postMessage({ type: 'startNotebookLmBridge' }); const nlmBridgeStopBtn = document.getElementById('nlmBridgeStopBtn'); if (nlmBridgeStopBtn) nlmBridgeStopBtn.onclick = () => vscode.postMessage({ type: 'stopNotebookLmBridge' }); + const nlmLoginBtn = document.getElementById('nlmLoginBtn'); + if (nlmLoginBtn) nlmLoginBtn.onclick = () => vscode.postMessage({ type: 'loginNotebookLm' }); addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' }); // v2.2.67 — 만약 dropdown 이 'new' 상태로 굳어있어도 수정/삭제는 직전 유효 선택(또는 첫 실제 옵션) // 을 기준으로 동작하도록 폴백. 이전엔 'new' 인 순간 그냥 early-return 해서 "수정 안 됨" 버그 발생. diff --git a/package.json b/package.json index 442ac8e..37a05c0 100644 --- a/package.json +++ b/package.json @@ -293,6 +293,11 @@ "default": "", "markdownDescription": "로컬 Datacollector 프로젝트 폴더 경로 (도구 메뉴의 'NotebookLM 백엔드 실행' 버튼이 여기서 `npm run bridge` 를 실행). 비워두면 워크스페이스 주변에서 자동 탐색하고, 못 찾으면 버튼 클릭 시 폴더 선택 창이 떠서 자동 저장됩니다." }, + "g1nation.notebookLmCliPath": { + "type": "string", + "default": "", + "markdownDescription": "NotebookLM CLI(`nlm`) 실행 파일 경로 (도구 메뉴의 'NotebookLM 로그인' 버튼이 여기에 `login` 인자를 붙여 실행). 비워두면 파이썬 Scripts 폴더들에서 자동 탐색하고(버전이 올라가도 찾음), 못 찾으면 버튼 클릭 시 파일 선택 창이 떠서 자동 저장됩니다." + }, "g1nation.datacollectSavePath": { "type": "string", "default": "", diff --git a/src/features/datacollect/bridgeLauncher.ts b/src/features/datacollect/bridgeLauncher.ts index 53edc00..e6e4d9b 100644 --- a/src/features/datacollect/bridgeLauncher.ts +++ b/src/features/datacollect/bridgeLauncher.ts @@ -23,6 +23,7 @@ import { execShell } from '../../lib/execUtil'; */ const TERMINAL_NAME = 'NotebookLM Bridge'; +const LOGIN_TERMINAL_NAME = 'NotebookLM Login'; const BRIDGE_PORT = 3002; /** 자동 탐색 시 찾는 프로젝트 폴더 이름 후보 (맥/윈도우 클론 이름이 다를 수 있음). */ const PROJECT_DIR_NAMES = ['Datacollector_MAC', 'Datacollector', 'Datacollect']; @@ -55,6 +56,71 @@ export function buildPortKillCommand(platform: NodeJS.Platform, port: number): s return `lsof -ti :${port} | xargs kill 2>/dev/null`; } +/** + * `nlm` 실행 파일 후보 경로를 우선순위대로 나열한다 (존재 검증 안 함 — 순수 함수). + * + * nlm 은 pip 로 설치되는 콘솔 스크립트라 PATH 에 없는 경우가 흔하다(실측: 이 PC 도 PATH 에 + * 없고 %APPDATA%\Python\Python314\Scripts 에만 있었다). 파이썬 버전이 올라가면 경로가 + * 바뀌므로 특정 버전을 하드코딩하지 않고 Scripts 디렉터리들을 훑는다. + * 마지막 후보로 맨 이름('nlm')을 두어 PATH 에 있는 환경에서는 그대로 동작하게 한다. + */ +export function candidateNlmPaths( + platform: NodeJS.Platform, + env: Record, + pythonScriptDirs: string[] = [], + cfgPath = '', +): string[] { + const out: string[] = []; + const push = (p: string) => { if (p && !out.includes(p)) out.push(p); }; + if (cfgPath && cfgPath.trim()) push(cfgPath.trim()); + + if (platform === 'win32') { + for (const d of pythonScriptDirs) push(path.join(d, 'nlm.exe')); + const local = env.LOCALAPPDATA; + if (local) push(path.join(local, 'Programs', 'Python', 'Scripts', 'nlm.exe')); + push('nlm.exe'); + } else { + for (const d of pythonScriptDirs) push(path.join(d, 'nlm')); + const home = env.HOME; + if (home) { + push(path.join(home, '.local', 'bin', 'nlm')); + push(path.join(home, 'Library', 'Python', 'bin', 'nlm')); + } + push('/usr/local/bin/nlm'); + } + push('nlm'); // PATH 폴백 + return out; +} + +/** 파이썬 사용자 설치 경로 아래의 Scripts(win) / bin(mac) 디렉터리들을 나열한다. */ +function pythonScriptDirs(): string[] { + const dirs: string[] = []; + const roots = process.platform === 'win32' + ? [process.env.APPDATA ? path.join(process.env.APPDATA, 'Python') : ''] + : [process.env.HOME ? path.join(process.env.HOME, 'Library', 'Python') : '']; + for (const root of roots.filter(Boolean)) { + try { + for (const name of fs.readdirSync(root)) { + const s = path.join(root, name, 'Scripts'); + const b = path.join(root, name, 'bin'); + if (fs.existsSync(s)) dirs.push(s); + if (fs.existsSync(b)) dirs.push(b); + } + } catch { /* 접근 불가 — skip */ } + } + return dirs; +} + +/** 실제 존재하는 nlm 실행 파일 경로. 못 찾으면 null (PATH 폴백은 호출측이 판단). */ +function findNlmExecutable(): string | null { + const cfg = vscode.workspace.getConfiguration('g1nation').get('notebookLmCliPath', '') || ''; + for (const cand of candidateNlmPaths(process.platform, process.env, pythonScriptDirs(), cfg)) { + if (!path.isAbsolute(cand)) continue; // PATH 이름은 존재 검증 대상이 아님 + try { if (fs.existsSync(cand)) return cand; } catch { /* skip */ } + } + return null; +} + function findBridgeProject(): string | null { const cfgPath = vscode.workspace.getConfiguration('g1nation').get('datacollectLocalProjectPath', '') || ''; const wsDirs = (vscode.workspace.workspaceFolders || []) @@ -141,6 +207,48 @@ export async function stopNotebookLmBridge(): Promise { } } +/** + * NotebookLM 재로그인(구글 인증) 원클릭. + * + * 배경: NotebookLM 토큰은 ~90분에 만료되고, 전용 프로필의 구글 **세션 자체**가 끊기면 + * 자동 갱신(Datacollect 의 스케줄 작업)이 매번 실패하면서 로그인 창만 반복해서 띄운다. + * 복구는 `nlm login` 을 한 번 사람이 실행해 브라우저에서 인증하는 것뿐인데, 그러려면 + * 터미널과 실행 파일 경로를 알아야 했다 — 그 한 단계를 버튼으로 없앤다. + * + * 구현: 셸을 거치지 않고 실행 파일을 터미널 프로세스로 직접 띄운다(shellPath/shellArgs). + * 경로에 공백이 있어도 따옴표 처리가 필요 없고, PowerShell/cmd/zsh 차이도 사라진다. + * 인증 결과(성공/실패)는 터미널에 그대로 보이므로 별도 폴링을 하지 않는다. + */ +export async function loginNotebookLm(): Promise { + let exe = findNlmExecutable(); + if (!exe) { + const pick = await vscode.window.showErrorMessage( + 'NotebookLM CLI(nlm) 실행 파일을 찾지 못했습니다. 파일을 직접 선택하면 설정에 저장되어 다음부터는 버튼만 누르면 됩니다.', + '파일 선택' + ); + if (pick !== '파일 선택') return; + const chosen = await vscode.window.showOpenDialog({ + canSelectFiles: true, canSelectFolders: false, canSelectMany: false, + openLabel: 'nlm 실행 파일 선택', + }); + const f = chosen?.[0]?.fsPath; + if (!f) return; + await vscode.workspace.getConfiguration('g1nation') + .update('notebookLmCliPath', f, vscode.ConfigurationTarget.Global); + exe = f; + } + + vscode.window.terminals.find(t => t.name === LOGIN_TERMINAL_NAME)?.dispose(); + const terminal = vscode.window.createTerminal({ + name: LOGIN_TERMINAL_NAME, shellPath: exe, shellArgs: ['login'], + }); + terminal.show(true); + vscode.window.showInformationMessage( + 'NotebookLM 로그인을 시작했습니다 — 브라우저에서 구글 인증을 완료하세요. ' + + '결과는 "NotebookLM Login" 터미널에 표시됩니다.' + ); +} + /** 사이드바 웹뷰 메시지 핸들러 — sidebarProvider 의 핸들러 체인에 등록된다. */ export async function handleBridgeLauncherMessage(_provider: unknown, data: any): Promise { switch (data?.type) { @@ -150,6 +258,9 @@ export async function handleBridgeLauncherMessage(_provider: unknown, data: any) case 'stopNotebookLmBridge': await stopNotebookLmBridge(); return true; + case 'loginNotebookLm': + await loginNotebookLm(); + return true; default: return false; } diff --git a/tests/bridgeLauncher.test.ts b/tests/bridgeLauncher.test.ts index cca8bff..49db8f1 100644 --- a/tests/bridgeLauncher.test.ts +++ b/tests/bridgeLauncher.test.ts @@ -3,7 +3,7 @@ * (터미널 생성·프로세스 kill 같은 부수효과는 VS Code/OS 의존이라 여기선 순수 함수만.) */ import * as path from 'path'; -import { candidateProjectDirs, buildPortKillCommand } from '../src/features/datacollect/bridgeLauncher'; +import { candidateProjectDirs, buildPortKillCommand, candidateNlmPaths } from '../src/features/datacollect/bridgeLauncher'; jest.mock('vscode', () => ({ workspace: { getConfiguration: jest.fn(), workspaceFolders: [] }, @@ -36,6 +36,39 @@ describe('candidateProjectDirs — 프로젝트 자동 탐색 후보 순서', () }); }); +describe('candidateNlmPaths — nlm 실행 파일 탐색 순서', () => { + it('설정값이 있으면 항상 1순위다', () => { + const p = candidateNlmPaths('win32', {}, ['C:/py/Scripts'], 'D:/custom/nlm.exe'); + expect(p[0]).toBe('D:/custom/nlm.exe'); + }); + + it('윈도우는 파이썬 Scripts 폴더의 nlm.exe 를 찾는다 (버전 하드코딩 없이)', () => { + const p = candidateNlmPaths('win32', {}, ['C:/AppData/Python/Python314/Scripts']); + expect(p).toContain(path.join('C:/AppData/Python/Python314/Scripts', 'nlm.exe')); + }); + + it('맥/리눅스는 확장자 없는 nlm 과 ~/.local/bin 을 본다', () => { + const p = candidateNlmPaths('darwin', { HOME: '/Users/me' }, ['/Users/me/Library/Python/3.11/bin']); + expect(p).toContain(path.join('/Users/me/Library/Python/3.11/bin', 'nlm')); + expect(p).toContain(path.join('/Users/me', '.local', 'bin', 'nlm')); + expect(p.some(x => x.endsWith('nlm.exe'))).toBe(false); + }); + + it('마지막 후보는 PATH 폴백용 맨 이름이다', () => { + expect(candidateNlmPaths('win32', {}, []).at(-1)).toBe('nlm'); + expect(candidateNlmPaths('linux', {}, []).at(-1)).toBe('nlm'); + }); + + it('중복 경로는 한 번만 나온다', () => { + const p = candidateNlmPaths('win32', {}, ['C:/s', 'C:/s']); + expect(p.filter(x => x === path.join('C:/s', 'nlm.exe')).length).toBe(1); + }); + + it('설정값 공백은 무시한다', () => { + expect(candidateNlmPaths('win32', {}, [], ' ')[0]).not.toBe(' '); + }); +}); + describe('buildPortKillCommand — OS 자동 감지 분기', () => { it('윈도우는 PowerShell Stop-Process 를 쓴다', () => { const cmd = buildPortKillCommand('win32', 3002);