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);