#
tokens: 43770/50000 6/33 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 2. Use http://codebase.md/pleaseprompto/notebooklm-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .gitignore
├── CHANGELOG.md
├── docs
│   ├── configuration.md
│   ├── tools.md
│   ├── troubleshooting.md
│   └── usage-guide.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── auth
│   │   └── auth-manager.ts
│   ├── config.ts
│   ├── errors.ts
│   ├── index.ts
│   ├── library
│   │   ├── notebook-library.ts
│   │   └── types.ts
│   ├── resources
│   │   └── resource-handlers.ts
│   ├── session
│   │   ├── browser-session.ts
│   │   ├── session-manager.ts
│   │   └── shared-context-manager.ts
│   ├── tools
│   │   ├── definitions
│   │   │   ├── ask-question.ts
│   │   │   ├── notebook-management.ts
│   │   │   ├── session-management.ts
│   │   │   └── system.ts
│   │   ├── definitions.ts
│   │   ├── handlers.ts
│   │   └── index.ts
│   ├── types.ts
│   └── utils
│       ├── cleanup-manager.ts
│       ├── cli-handler.ts
│       ├── logger.ts
│       ├── page-utils.ts
│       ├── settings-manager.ts
│       └── stealth-utils.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/src/utils/stealth-utils.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Stealth utilities for human-like browser behavior
  3 |  *
  4 |  * This module provides functions to simulate realistic human interactions:
  5 |  * - Human-like typing (speed configurable via CONFIG.typingWpmMin/Max)
  6 |  * - Realistic mouse movements (Bezier curves with jitter)
  7 |  * - Random delays (normal distribution)
  8 |  * - Smooth scrolling
  9 |  * - Reading pauses
 10 |  *
 11 |  * Based on the Python implementation from stealth_utils.py
 12 |  */
 13 | 
 14 | import type { Page } from "patchright";
 15 | import { CONFIG } from "../config.js";
 16 | 
 17 | // ============================================================================
 18 | // Helper Functions
 19 | // ============================================================================
 20 | 
 21 | /**
 22 |  * Sleep for specified milliseconds
 23 |  */
 24 | export async function sleep(ms: number): Promise<void> {
 25 |   return new Promise((resolve) => setTimeout(resolve, ms));
 26 | }
 27 | 
 28 | /**
 29 |  * Generate random integer between min and max (inclusive)
 30 |  */
 31 | export function randomInt(min: number, max: number): number {
 32 |   return Math.floor(Math.random() * (max - min + 1)) + min;
 33 | }
 34 | 
 35 | /**
 36 |  * Generate random float between min and max
 37 |  */
 38 | export function randomFloat(min: number, max: number): number {
 39 |   return Math.random() * (max - min) + min;
 40 | }
 41 | 
 42 | /**
 43 |  * Generate random character (for typos)
 44 |  */
 45 | export function randomChar(): string {
 46 |   const chars = "qwertyuiopasdfghjklzxcvbnm";
 47 |   return chars[randomInt(0, chars.length - 1)];
 48 | }
 49 | 
 50 | /**
 51 |  * Generate Gaussian (normal) distributed random number
 52 |  * Uses Box-Muller transform
 53 |  */
 54 | export function gaussian(mean: number, stdDev: number): number {
 55 |   const u1 = Math.random();
 56 |   const u2 = Math.random();
 57 |   const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
 58 |   return z0 * stdDev + mean;
 59 | }
 60 | 
 61 | // ============================================================================
 62 | // Random Delays
 63 | // ============================================================================
 64 | 
 65 | /**
 66 |  * Add a random delay to simulate human thinking/reaction time
 67 |  * Uses normal distribution for more realistic delays
 68 |  *
 69 |  * @param minMs Minimum delay in milliseconds (default: from CONFIG)
 70 |  * @param maxMs Maximum delay in milliseconds (default: from CONFIG)
 71 |  */
 72 | export async function randomDelay(
 73 |   minMs?: number,
 74 |   maxMs?: number
 75 | ): Promise<void> {
 76 |   minMs = minMs ?? CONFIG.minDelayMs;
 77 |   maxMs = maxMs ?? CONFIG.maxDelayMs;
 78 | 
 79 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthRandomDelays) {
 80 |     // Fixed delay (average)
 81 |     const target = minMs === maxMs ? minMs : (minMs + maxMs) / 2;
 82 |     if (target > 0) {
 83 |       await sleep(target);
 84 |     }
 85 |     return;
 86 |   }
 87 | 
 88 |   // Use normal distribution for more realistic delays
 89 |   // Mean at 60% of range, standard deviation of 20% of range
 90 |   const mean = minMs + (maxMs - minMs) * 0.6;
 91 |   const stdDev = (maxMs - minMs) * 0.2;
 92 | 
 93 |   let delay = gaussian(mean, stdDev);
 94 |   delay = Math.max(minMs, Math.min(maxMs, delay)); // Clamp to range
 95 | 
 96 |   await sleep(delay);
 97 | }
 98 | 
 99 | // ============================================================================
100 | // Human Typing
101 | // ============================================================================
102 | 
103 | /**
104 |  * Type text in a human-like manner with variable speed and optional typos
105 |  *
106 |  * Simulates realistic typing patterns:
107 |  * - Variable speed (45-65 WPM by default)
108 |  * - Occasional typos (2% chance)
109 |  * - Longer pauses after punctuation
110 |  * - Realistic character delays
111 |  *
112 |  * @param page Playwright page instance
113 |  * @param selector CSS selector of input element
114 |  * @param text Text to type
115 |  * @param options Typing options (wpm, withTypos)
116 |  */
117 | export async function humanType(
118 |   page: Page,
119 |   selector: string,
120 |   text: string,
121 |   options: {
122 |     wpm?: number;
123 |     withTypos?: boolean;
124 |   } = {}
125 | ): Promise<void> {
126 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthHumanTyping) {
127 |     // Fast typing without stealth
128 |     await page.fill(selector, text);
129 |     return;
130 |   }
131 | 
132 |   const wpm = options.wpm ?? randomInt(CONFIG.typingWpmMin, CONFIG.typingWpmMax);
133 |   const withTypos = options.withTypos ?? true;
134 | 
135 |   // Calculate average delay per character (in ms)
136 |   // WPM = (characters / 5) / minutes
137 |   // Average word length ~5 characters
138 |   const charsPerMinute = wpm * 5;
139 |   const avgDelayMs = (60 * 1000) / charsPerMinute;
140 | 
141 |   // Clear existing text first
142 |   await page.fill(selector, "");
143 |   await randomDelay(30, 80);
144 | 
145 |   // Click to focus
146 |   await page.click(selector);
147 |   await randomDelay(20, 60);
148 | 
149 |   // Type each character
150 |   let currentText = "";
151 |   let i = 0;
152 | 
153 |   while (i < text.length) {
154 |     const char = text[i];
155 | 
156 |     // Simulate very rare typo (0.3% chance) and shorter correction
157 |     if (withTypos && Math.random() < 0.003 && i > 0) {
158 |       // Type wrong character
159 |       const wrongChar = randomChar();
160 |       currentText += wrongChar;
161 |       await page.fill(selector, currentText);
162 | 
163 |       // Shorter notice window for faster typing
164 |       const noticeDelay = randomFloat(avgDelayMs * 0.6, avgDelayMs * 1.1);
165 |       await sleep(noticeDelay);
166 | 
167 |       // Backspace
168 |       currentText = currentText.slice(0, -1);
169 |       await page.fill(selector, currentText);
170 |       await randomDelay(20, 60);
171 |     }
172 | 
173 |     // Type correct character
174 |     currentText += char;
175 |     await page.fill(selector, currentText);
176 | 
177 |     // Variable delay between characters – tuned for faster but still human-like typing
178 |     let delay: number;
179 |     if (char === "." || char === "!" || char === "?") {
180 |       delay = randomFloat(avgDelayMs * 1.05, avgDelayMs * 1.4);
181 |     } else if (char === " ") {
182 |       delay = randomFloat(avgDelayMs * 0.5, avgDelayMs * 0.9);
183 |     } else if (char === ",") {
184 |       delay = randomFloat(avgDelayMs * 0.9, avgDelayMs * 1.2);
185 |     } else {
186 |       // Normal character
187 |       const variation = randomFloat(0.5, 0.9);
188 |       delay = avgDelayMs * variation;
189 |     }
190 | 
191 |     await sleep(delay);
192 |     i++;
193 |   }
194 | 
195 |   // Small delay after finishing typing
196 |   await randomDelay(50, 120);
197 | }
198 | 
199 | // ============================================================================
200 | // Mouse Movement
201 | // ============================================================================
202 | 
203 | /**
204 |  * Move mouse in a realistic curved path to target coordinates
205 |  * Uses Bezier-like curves with jitter for natural movement
206 |  *
207 |  * @param page Playwright page instance
208 |  * @param targetX Target X coordinate (default: random)
209 |  * @param targetY Target Y coordinate (default: random)
210 |  * @param steps Number of steps in movement (default: random 10-25)
211 |  */
212 | export async function randomMouseMovement(
213 |   page: Page,
214 |   targetX?: number,
215 |   targetY?: number,
216 |   steps?: number
217 | ): Promise<void> {
218 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
219 |     return;
220 |   }
221 | 
222 |   const viewport = page.viewportSize() || CONFIG.viewport;
223 | 
224 |   targetX = targetX ?? randomInt(100, viewport.width - 100);
225 |   targetY = targetY ?? randomInt(100, viewport.height - 100);
226 |   steps = steps ?? randomInt(10, 25);
227 | 
228 |   // Start from a random position (we don't know current position)
229 |   const startX = randomInt(0, viewport.width);
230 |   const startY = randomInt(0, viewport.height);
231 | 
232 |   // Generate curved path using Bezier-like curve
233 |   for (let step = 0; step < steps; step++) {
234 |     const progress = step / steps;
235 | 
236 |     // Add some randomness to create a natural curve
237 |     const curveOffsetX = Math.sin(progress * Math.PI) * randomInt(-50, 50);
238 |     const curveOffsetY = Math.cos(progress * Math.PI) * randomInt(-30, 30);
239 | 
240 |     let currentX = startX + (targetX - startX) * progress + curveOffsetX;
241 |     let currentY = startY + (targetY - startY) * progress + curveOffsetY;
242 | 
243 |     // Add micro-jitter (humans never move in perfectly straight lines)
244 |     const jitterX = randomFloat(-3, 3);
245 |     const jitterY = randomFloat(-3, 3);
246 | 
247 |     currentX = Math.max(0, Math.min(viewport.width, currentX + jitterX));
248 |     currentY = Math.max(0, Math.min(viewport.height, currentY + jitterY));
249 | 
250 |     await page.mouse.move(currentX, currentY);
251 | 
252 |     // Variable delay between movements (faster in middle, slower at ends)
253 |     const delay = 10 + 20 * Math.abs(0.5 - progress);
254 |     await sleep(delay);
255 |   }
256 | }
257 | 
258 | // ============================================================================
259 | // Realistic Click
260 | // ============================================================================
261 | 
262 | /**
263 |  * Click an element with realistic human behavior
264 |  * Includes mouse movement, pause, and click
265 |  *
266 |  * @param page Playwright page instance
267 |  * @param selector CSS selector of element to click
268 |  * @param withMouseMovement Whether to move mouse first (default: true)
269 |  */
270 | export async function realisticClick(
271 |   page: Page,
272 |   selector: string,
273 |   withMouseMovement: boolean = true
274 | ): Promise<void> {
275 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
276 |     await page.click(selector);
277 |     return;
278 |   }
279 | 
280 |   if (withMouseMovement) {
281 |     // Move mouse to element
282 |     const element = await page.$(selector);
283 |     if (element) {
284 |       const box = await element.boundingBox();
285 |       if (box) {
286 |         // Don't click exactly in center (humans are imperfect)
287 |         const offsetX = randomFloat(-box.width * 0.2, box.width * 0.2);
288 |         const offsetY = randomFloat(-box.height * 0.2, box.height * 0.2);
289 | 
290 |         const targetX = box.x + box.width / 2 + offsetX;
291 |         const targetY = box.y + box.height / 2 + offsetY;
292 | 
293 |         await randomMouseMovement(page, targetX, targetY);
294 |       }
295 |     }
296 |   }
297 | 
298 |   // Small pause before clicking
299 |   await randomDelay(100, 300);
300 | 
301 |   // Click
302 |   await page.click(selector);
303 | 
304 |   // Small pause after clicking
305 |   await randomDelay(150, 400);
306 | }
307 | 
308 | // ============================================================================
309 | // Smooth Scrolling
310 | // ============================================================================
311 | 
312 | /**
313 |  * Scroll the page smoothly like a human
314 |  * Uses multiple small steps for smooth animation
315 |  *
316 |  * @param page Playwright page instance
317 |  * @param amount Scroll amount in pixels (default: random 100-400)
318 |  * @param direction Scroll direction ("down" or "up")
319 |  */
320 | export async function smoothScroll(
321 |   page: Page,
322 |   amount?: number,
323 |   direction: "up" | "down" = "down"
324 | ): Promise<void> {
325 |   amount = amount ?? randomInt(100, 400);
326 |   amount = Math.abs(amount);
327 |   if (direction === "up") {
328 |     amount = -amount;
329 |   }
330 | 
331 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
332 |     await page.evaluate((scrollAmount) => {
333 |       // @ts-expect-error - window exists in browser context
334 |       window.scrollBy({ top: scrollAmount, behavior: "auto" });
335 |     }, amount);
336 |     return;
337 |   }
338 | 
339 |   // Scroll in multiple small steps for smoothness
340 |   const steps = randomInt(8, 15);
341 |   const stepAmount = amount / steps;
342 | 
343 |   for (let i = 0; i < steps; i++) {
344 |     await page.evaluate((step) => {
345 |       // @ts-expect-error - window exists in browser context
346 |       window.scrollBy({ top: step, behavior: "smooth" });
347 |     }, stepAmount);
348 |     await sleep(randomFloat(20, 50));
349 |   }
350 | 
351 |   // Pause after scrolling (humans look at content)
352 |   await randomDelay(300, 800);
353 | }
354 | 
355 | // ============================================================================
356 | // Reading Simulation
357 | // ============================================================================
358 | 
359 | /**
360 |  * Pause as if reading text, based on length
361 |  * Calculates realistic reading time based on text length and WPM
362 |  *
363 |  * @param textLength Number of characters to "read"
364 |  * @param wpm Reading speed in words per minute (default: random 200-250)
365 |  */
366 | export async function readingPause(textLength: number, wpm?: number): Promise<void> {
367 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthRandomDelays) {
368 |     return;
369 |   }
370 | 
371 |   wpm = wpm ?? randomInt(200, 250);
372 | 
373 |   // Calculate reading time
374 |   // Average word length ~5 characters
375 |   const wordCount = textLength / 5;
376 |   const minutes = wordCount / wpm;
377 |   let seconds = minutes * 60;
378 | 
379 |   // Add some randomness (humans don't read at constant speed)
380 |   seconds *= randomFloat(0.8, 1.2);
381 | 
382 |   // Cap at reasonable maximum (3 seconds)
383 |   seconds = Math.min(seconds, 3.0);
384 | 
385 |   await sleep(seconds * 1000);
386 | }
387 | 
388 | // ============================================================================
389 | // Random Mouse Jitter
390 | // ============================================================================
391 | 
392 | /**
393 |  * Add small random mouse movements to simulate natural fidgeting
394 |  *
395 |  * @param page Playwright page instance
396 |  * @param iterations Number of small movements (default: 3)
397 |  */
398 | export async function randomMouseJitter(
399 |   page: Page,
400 |   iterations: number = 3
401 | ): Promise<void> {
402 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
403 |     return;
404 |   }
405 | 
406 |   const viewport = page.viewportSize() || CONFIG.viewport;
407 | 
408 |   for (let i = 0; i < iterations; i++) {
409 |     const targetX = randomInt(0, viewport.width);
410 |     const targetY = randomInt(0, viewport.height);
411 |     await page.mouse.move(targetX, targetY, { steps: randomInt(2, 4) });
412 |     await sleep(randomFloat(100, 300));
413 |   }
414 | }
415 | 
416 | // ============================================================================
417 | // Hover Element
418 | // ============================================================================
419 | 
420 | /**
421 |  * Hover over an element with realistic mouse movement
422 |  *
423 |  * @param page Playwright page instance
424 |  * @param selector CSS selector of element to hover
425 |  */
426 | export async function hoverElement(page: Page, selector: string): Promise<void> {
427 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
428 |     await page.hover(selector);
429 |     return;
430 |   }
431 | 
432 |   const element = await page.$(selector);
433 |   if (element) {
434 |     const box = await element.boundingBox();
435 |     if (box) {
436 |       // Move to center of element
437 |       const targetX = box.x + box.width / 2;
438 |       const targetY = box.y + box.height / 2;
439 | 
440 |       await randomMouseMovement(page, targetX, targetY);
441 |       await page.hover(selector);
442 |       await randomDelay(200, 500);
443 |     }
444 |   }
445 | }
446 | 
447 | // ============================================================================
448 | // Simulate Reading Page
449 | // ============================================================================
450 | 
451 | /**
452 |  * Simulate reading a page with scrolling and pauses
453 |  * Adds realistic behavior of scrolling and reading content
454 |  *
455 |  * @param page Playwright page instance
456 |  */
457 | export async function simulateReadingPage(page: Page): Promise<void> {
458 |   if (!CONFIG.stealthEnabled || !CONFIG.stealthRandomDelays) {
459 |     return;
460 |   }
461 | 
462 |   // Random number of scroll actions
463 |   const scrollCount = randomInt(1, 3);
464 | 
465 |   for (let i = 0; i < scrollCount; i++) {
466 |     // Scroll down
467 |     await smoothScroll(page, undefined, "down");
468 | 
469 |     // "Read" the visible content
470 |     await randomDelay(800, 1500);
471 | 
472 |     // Sometimes scroll up a bit (humans do this)
473 |     if (Math.random() < 0.3) {
474 |       await smoothScroll(page, randomInt(50, 150), "up");
475 |       await randomDelay(400, 800);
476 |     }
477 |   }
478 | }
479 | 
480 | // ============================================================================
481 | // Exports
482 | // ============================================================================
483 | 
484 | export default {
485 |   sleep,
486 |   randomInt,
487 |   randomFloat,
488 |   randomChar,
489 |   gaussian,
490 |   randomDelay,
491 |   humanType,
492 |   randomMouseMovement,
493 |   realisticClick,
494 |   smoothScroll,
495 |   readingPause,
496 |   randomMouseJitter,
497 |   hoverElement,
498 |   simulateReadingPage,
499 | };
500 | 
```

--------------------------------------------------------------------------------
/src/session/shared-context-manager.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Shared Context Manager with Persistent Chrome Profile
  3 |  *
  4 |  * Manages ONE global persistent BrowserContext for ALL sessions.
  5 |  * This is critical for avoiding bot detection:
  6 |  *
  7 |  * - Google tracks browser fingerprints (Canvas, WebGL, Audio, Fonts, etc.)
  8 |  * - Multiple contexts = Multiple fingerprints = Suspicious!
  9 |  * - ONE persistent context = ONE consistent fingerprint = Normal user
 10 |  * - Persistent user_data_dir = SAME fingerprint across all app restarts!
 11 |  *
 12 |  * Based on the Python implementation from shared_context_manager.py
 13 |  */
 14 | 
 15 | import type { BrowserContext } from "patchright";
 16 | import { chromium } from "patchright";
 17 | import { CONFIG } from "../config.js";
 18 | import { log } from "../utils/logger.js";
 19 | import { AuthManager } from "../auth/auth-manager.js";
 20 | import fs from "fs";
 21 | import path from "path";
 22 | 
 23 | /**
 24 |  * Shared Context Manager
 25 |  *
 26 |  * Benefits:
 27 |  * 1. ONE consistent browser fingerprint for all sessions
 28 |  * 2. Fingerprint persists across app restarts (user_data_dir)
 29 |  * 3. Mimics real user behavior (one browser, multiple tabs)
 30 |  * 4. Google sees: "Same browser since day 1"
 31 |  */
 32 | export class SharedContextManager {
 33 |   private authManager: AuthManager;
 34 |   private globalContext: BrowserContext | null = null;
 35 |   private contextCreatedAt: number | null = null;
 36 |   private currentProfileDir: string | null = null;
 37 |   private isIsolatedProfile: boolean = false;
 38 |   private currentHeadlessMode: boolean | null = null;
 39 | 
 40 |   constructor(authManager: AuthManager) {
 41 |     this.authManager = authManager;
 42 | 
 43 |     log.info("🌐 SharedContextManager initialized (PERSISTENT MODE)");
 44 |     log.info(`  Chrome Profile: ${CONFIG.chromeProfileDir}`);
 45 |     log.success("  Fingerprint: PERSISTENT across restarts! 🎯");
 46 | 
 47 |     // Cleanup old isolated profiles at startup (best-effort)
 48 |     if (CONFIG.cleanupInstancesOnStartup) {
 49 |       void this.pruneIsolatedProfiles("startup");
 50 |     }
 51 |   }
 52 | 
 53 |   /**
 54 |    * Get the global shared persistent context, or create new if needed
 55 |    *
 56 |    * Context is recreated only when:
 57 |    * - First time (no context exists in this app instance)
 58 |    * - Context was closed/invalid
 59 |    *
 60 |    * Note: Auth expiry does NOT recreate context - we reuse the SAME
 61 |    * fingerprint and just re-login!
 62 |    *
 63 |    * @param overrideHeadless Optional override for headless mode (true = show browser)
 64 |    */
 65 |   async getOrCreateContext(overrideHeadless?: boolean): Promise<BrowserContext> {
 66 |     // Check if headless mode needs to be changed (e.g., show_browser=true)
 67 |     // If yes, close the browser so it gets recreated with the new mode
 68 |     if (this.needsHeadlessModeChange(overrideHeadless)) {
 69 |       log.warning("🔄 Headless mode change detected - recreating browser context...");
 70 |       await this.closeContext();
 71 |     }
 72 | 
 73 |     if (await this.needsRecreation()) {
 74 |       log.warning("🔄 Creating/Loading persistent context...");
 75 |       await this.recreateContext(overrideHeadless);
 76 |     } else {
 77 |       log.success("♻️  Reusing existing persistent context");
 78 |     }
 79 | 
 80 |     return this.globalContext!;
 81 |   }
 82 | 
 83 |   /**
 84 |    * Check if global context needs to be recreated
 85 |    */
 86 |   private async needsRecreation(): Promise<boolean> {
 87 |     // No context exists yet (first time or after manual close)
 88 |     if (!this.globalContext) {
 89 |       log.info("  ℹ️  No context exists yet");
 90 |       return true;
 91 |     }
 92 | 
 93 |     // Async validity check (will throw if closed)
 94 |     try {
 95 |       await this.globalContext.cookies();
 96 |       log.dim("  ✅ Context still valid (browser open)");
 97 |       return false;
 98 |     } catch (error) {
 99 |       log.warning("  ⚠️  Context appears closed - will recreate");
100 |       this.globalContext = null;
101 |       this.contextCreatedAt = null;
102 |       this.currentHeadlessMode = null;
103 |       return true;
104 |     }
105 |   }
106 | 
107 |   /**
108 |    * Create/Load the global PERSISTENT context with Chrome user_data_dir
109 |    *
110 |    * This is THE KEY to fingerprint persistence!
111 |    *
112 |    * First time:
113 |    * - Chrome creates new profile in user_data_dir
114 |    * - Generates fingerprint (Canvas, WebGL, Audio, etc.)
115 |    * - Saves everything to disk
116 |    *
117 |    * Subsequent starts:
118 |    * - Chrome loads profile from user_data_dir
119 |    * - SAME fingerprint as before! ✅
120 |    * - Google sees: "Same browser since day 1"
121 |    *
122 |    * @param overrideHeadless Optional override for headless mode (true = show browser)
123 |    */
124 |   private async recreateContext(overrideHeadless?: boolean): Promise<void> {
125 |     // Close old context if exists
126 |     if (this.globalContext) {
127 |       try {
128 |         log.info("  🗑️  Closing old context...");
129 |         await this.globalContext.close();
130 |       } catch (error) {
131 |         log.warning(`  ⚠️  Error closing old context: ${error}`);
132 |       }
133 |     }
134 | 
135 |     // Check for saved auth
136 |     const statePath = await this.authManager.getValidStatePath();
137 | 
138 |     if (statePath) {
139 |       log.success(`  📂 Found auth state: ${statePath}`);
140 |       log.info("  💡 Will load cookies into persistent profile");
141 |     } else {
142 |       log.warning("  🆕 No saved auth - fresh persistent profile");
143 |       log.info("  💡 First login will save auth to persistent profile");
144 |     }
145 | 
146 |     // Determine headless mode: use override if provided, otherwise use CONFIG
147 |     const shouldBeHeadless = overrideHeadless !== undefined ? !overrideHeadless : CONFIG.headless;
148 | 
149 |     if (overrideHeadless !== undefined) {
150 |       log.info(`  Browser visibility override: ${overrideHeadless ? 'VISIBLE' : 'HEADLESS'}`);
151 |     }
152 | 
153 |     // Build launch options for persistent context
154 |     // NOTE: userDataDir is passed as first parameter, NOT in options!
155 |     const launchOptions = {
156 |       headless: shouldBeHeadless,
157 |       channel: "chrome" as const,
158 |       viewport: CONFIG.viewport,
159 |       locale: "en-US",
160 |       timezoneId: "Europe/Berlin",
161 |       // ✅ CRITICAL FIX: Pass storageState directly at launch!
162 |       // This is the PROPER way to handle session cookies (Playwright bug workaround)
163 |       // Benefits:
164 |       // - Session cookies persist correctly
165 |       // - No need for addCookies() workarounds
166 |       // - Chrome loads everything automatically
167 |       ...(statePath && { storageState: statePath }),
168 |       args: [
169 |         "--disable-blink-features=AutomationControlled",
170 |         "--disable-dev-shm-usage",
171 |         "--no-first-run",
172 |         "--no-default-browser-check",
173 |       ],
174 |     };
175 | 
176 |     // 🔥 CRITICAL: launchPersistentContext creates/loads Chrome profile
177 |     // Strategy handling for concurrent instances
178 |     const baseProfile = CONFIG.chromeProfileDir;
179 |     const strategy = CONFIG.profileStrategy;
180 |     const tryLaunch = async (userDataDir: string) => {
181 |       log.info("  🚀 Launching persistent Chrome context...");
182 |       log.dim(`  📍 Profile location: ${userDataDir}`);
183 |       if (statePath) {
184 |         log.info(`  📄 Loading auth state: ${statePath}`);
185 |       }
186 |       return chromium.launchPersistentContext(userDataDir, launchOptions);
187 |     };
188 | 
189 |     try {
190 |       if (strategy === "isolated") {
191 |         const isolatedDir = await this.prepareIsolatedProfileDir(baseProfile);
192 |         this.globalContext = await tryLaunch(isolatedDir);
193 |         this.currentProfileDir = isolatedDir;
194 |         this.isIsolatedProfile = true;
195 |       } else {
196 |         // single or auto → first try base
197 |         this.globalContext = await tryLaunch(baseProfile);
198 |         this.currentProfileDir = baseProfile;
199 |         this.isIsolatedProfile = false;
200 |       }
201 |     } catch (e: any) {
202 |       const msg = String(e?.message || e);
203 |       const isSingleton = /ProcessSingleton|SingletonLock|profile is already in use/i.test(msg);
204 |       if (strategy === "single" || !isSingleton) {
205 |         // hard fail
206 |         if (isSingleton && strategy === "single") {
207 |           log.error("❌ Chrome profile already in use and strategy=single. Close other instance or set NOTEBOOK_PROFILE_STRATEGY=auto/isolated.");
208 |         }
209 |         throw e;
210 |       }
211 | 
212 |       // auto strategy with lock → fall back to isolated instance dir
213 |       log.warning("⚠️  Base Chrome profile in use by another process. Falling back to isolated per-instance profile...");
214 |       const isolatedDir = await this.prepareIsolatedProfileDir(baseProfile);
215 |       this.globalContext = await tryLaunch(isolatedDir);
216 |       this.currentProfileDir = isolatedDir;
217 |       this.isIsolatedProfile = true;
218 |     }
219 |     this.contextCreatedAt = Date.now();
220 |     this.currentHeadlessMode = shouldBeHeadless;
221 |     // Track close event to force recreation next time
222 |     try {
223 |       this.globalContext.on("close", () => {
224 |         log.warning("  🛑 Persistent context was closed externally");
225 |         this.globalContext = null;
226 |         this.contextCreatedAt = null;
227 |         this.currentHeadlessMode = null;
228 |       });
229 |     } catch {}
230 | 
231 |     // Validate cookies if we loaded state
232 |     if (statePath) {
233 |       try {
234 |         if (await this.authManager.validateCookiesExpiry(this.globalContext)) {
235 |           log.success("  ✅ Authentication state loaded successfully");
236 |           log.success("  🎯 Session cookies persisted correctly!");
237 |         } else {
238 |           log.warning("  ⚠️  Cookies expired - will need re-login");
239 |         }
240 |       } catch (error) {
241 |         log.warning(`  ⚠️  Could not validate auth state: ${error}`);
242 |       }
243 |     }
244 | 
245 |     log.success("✅ Persistent context ready!");
246 |     log.dim(`  Context ID: ${this.getContextId()}`);
247 |     log.dim(`  Chrome Profile: ${CONFIG.chromeProfileDir}`);
248 |     log.success("  🎯 Fingerprint: PERSISTENT (same across restarts!)");
249 |   }
250 | 
251 |   /**
252 |    * Manually close the global context (e.g., on shutdown)
253 |    *
254 |    * Note: This closes the context for ALL sessions!
255 |    * Chrome will save everything to user_data_dir automatically.
256 |    */
257 |   async closeContext(): Promise<void> {
258 |     if (this.globalContext) {
259 |       log.warning("🛑 Closing persistent context...");
260 |       log.info("  💾 Chrome is saving profile to disk...");
261 |       try {
262 |         await this.globalContext.close();
263 |         this.globalContext = null;
264 |         this.contextCreatedAt = null;
265 |         this.currentHeadlessMode = null;
266 |         log.success("✅ Persistent context closed");
267 |         log.success(`  💾 Profile saved: ${this.currentProfileDir || CONFIG.chromeProfileDir}`);
268 |       } catch (error) {
269 |         log.error(`❌ Error closing context: ${error}`);
270 |       }
271 |     }
272 | 
273 |     // Best-effort cleanup on shutdown
274 |     if (CONFIG.cleanupInstancesOnShutdown) {
275 |       try {
276 |         // If this process used an isolated profile, remove it now
277 |         if (this.isIsolatedProfile && this.currentProfileDir) {
278 |           await this.safeRemoveIsolatedProfile(this.currentProfileDir);
279 |         }
280 |       } catch (err) {
281 |         log.warning(`  ⚠️  Cleanup (self) failed: ${err}`);
282 |       }
283 |       try {
284 |         await this.pruneIsolatedProfiles("shutdown");
285 |       } catch (err) {
286 |         log.warning(`  ⚠️  Cleanup (prune) failed: ${err}`);
287 |       }
288 |     }
289 |   }
290 | 
291 |   private async prepareIsolatedProfileDir(baseProfile: string): Promise<string> {
292 |     const stamp = `${process.pid}-${Date.now()}`;
293 |     const dir = path.join(CONFIG.chromeInstancesDir, `instance-${stamp}`);
294 |     try {
295 |       fs.mkdirSync(dir, { recursive: true });
296 |       if (CONFIG.cloneProfileOnIsolated && fs.existsSync(baseProfile)) {
297 |         log.info("  🧬 Cloning base Chrome profile into isolated instance (may take time)...");
298 |         // Best-effort clone without locks
299 |         await (fs.promises as any).cp(baseProfile, dir, {
300 |           recursive: true,
301 |           errorOnExist: false,
302 |           force: true,
303 |           filter: (src: string) => {
304 |             const bn = path.basename(src);
305 |             return !/^Singleton/i.test(bn) && !bn.endsWith(".lock") && !bn.endsWith(".tmp");
306 |           },
307 |         } as any);
308 |         log.success("  ✅ Clone complete");
309 |       } else {
310 |         log.info("  🧪 Using fresh isolated Chrome profile (no clone)");
311 |       }
312 |     } catch (err) {
313 |       log.warning(`  ⚠️  Could not prepare isolated profile: ${err}`);
314 |     }
315 |     return dir;
316 |   }
317 | 
318 |   private async pruneIsolatedProfiles(phase: "startup" | "shutdown"): Promise<void> {
319 |     const root = CONFIG.chromeInstancesDir;
320 |     let entries: Array<{ path: string; mtimeMs: number }>; 
321 |     try {
322 |       const names = await fs.promises.readdir(root, { withFileTypes: true });
323 |       entries = [];
324 |       for (const d of names) {
325 |         if (!d.isDirectory()) continue;
326 |         const p = path.join(root, d.name);
327 |         try {
328 |           const st = await fs.promises.stat(p);
329 |           entries.push({ path: p, mtimeMs: st.mtimeMs });
330 |         } catch {}
331 |       }
332 |     } catch {
333 |       return; // directory absent is fine
334 |     }
335 | 
336 |     if (entries.length === 0) return;
337 | 
338 |     const now = Date.now();
339 |     const ttlMs = CONFIG.instanceProfileTtlHours * 3600 * 1000;
340 |     const maxCount = Math.max(0, CONFIG.instanceProfileMaxCount);
341 | 
342 |     // Sort newest first
343 |     entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
344 | 
345 |     const keep: Set<string> = new Set();
346 |     const toDelete: Set<string> = new Set();
347 | 
348 |     // Keep newest up to maxCount
349 |     for (let i = 0; i < entries.length; i++) {
350 |       const e = entries[i];
351 |       const ageMs = now - e.mtimeMs;
352 |       const overTtl = ttlMs > 0 && ageMs > ttlMs;
353 |       const overCount = i >= maxCount;
354 |       const isCurrent = this.currentProfileDir && path.resolve(e.path) === path.resolve(this.currentProfileDir);
355 |       if (!isCurrent && (overTtl || overCount)) {
356 |         toDelete.add(e.path);
357 |       } else {
358 |         keep.add(e.path);
359 |       }
360 |     }
361 | 
362 |     if (toDelete.size === 0) return;
363 |     log.info(`🧹 Pruning isolated profiles (${phase})...`);
364 |     for (const p of toDelete) {
365 |       try {
366 |         await this.safeRemoveIsolatedProfile(p);
367 |         log.dim(`  🗑️  removed ${p}`);
368 |       } catch (err) {
369 |         log.warning(`  ⚠️  Failed to remove ${p}: ${err}`);
370 |       }
371 |     }
372 |   }
373 | 
374 |   private async safeRemoveIsolatedProfile(dir: string): Promise<void> {
375 |     // Never remove the base profile
376 |     if (path.resolve(dir) === path.resolve(CONFIG.chromeProfileDir)) return;
377 |     // Only remove within instances root
378 |     if (!path.resolve(dir).startsWith(path.resolve(CONFIG.chromeInstancesDir))) return;
379 |     // Best-effort: try removing typical lock files first, then the directory
380 |     try {
381 |       await fs.promises.rm(dir, { recursive: true, force: true } as any);
382 |     } catch (err) {
383 |       // If rm is not available in older node, fallback to rmdir
384 |       try {
385 |         await (fs.promises as any).rmdir(dir, { recursive: true });
386 |       } catch {}
387 |     }
388 |   }
389 | 
390 |   /**
391 |    * Get information about the global persistent context
392 |    */
393 |   getContextInfo(): {
394 |     exists: boolean;
395 |     age_seconds?: number;
396 |     age_hours?: number;
397 |     fingerprint_id?: string;
398 |     user_data_dir: string;
399 |     persistent: boolean;
400 |   } {
401 |     if (!this.globalContext) {
402 |       return {
403 |         exists: false,
404 |         user_data_dir: CONFIG.chromeProfileDir,
405 |         persistent: true,
406 |       };
407 |     }
408 | 
409 |     const ageSeconds = this.contextCreatedAt
410 |       ? (Date.now() - this.contextCreatedAt) / 1000
411 |       : undefined;
412 |     const ageHours = ageSeconds ? ageSeconds / 3600 : undefined;
413 | 
414 |     return {
415 |       exists: true,
416 |       age_seconds: ageSeconds,
417 |       age_hours: ageHours,
418 |       fingerprint_id: this.getContextId(),
419 |       user_data_dir: CONFIG.chromeProfileDir,
420 |       persistent: true,
421 |     };
422 |   }
423 | 
424 |   /**
425 |    * Get the current headless mode of the browser context
426 |    *
427 |    * @returns boolean | null - true if headless, false if visible, null if no context exists
428 |    */
429 |   getCurrentHeadlessMode(): boolean | null {
430 |     return this.currentHeadlessMode;
431 |   }
432 | 
433 |   /**
434 |    * Check if the browser context needs to be recreated due to headless mode change
435 |    *
436 |    * @param overrideHeadless - Optional override for headless mode (true = show browser)
437 |    * @returns boolean - true if context needs to be recreated with new mode
438 |    */
439 |   needsHeadlessModeChange(overrideHeadless?: boolean): boolean {
440 |     // No context exists yet = will be created with correct mode anyway
441 |     if (this.currentHeadlessMode === null) {
442 |       return false;
443 |     }
444 | 
445 |     // Calculate target headless mode
446 |     // If override is specified, use it (!overrideHeadless because true = show browser = headless false)
447 |     // Otherwise, use CONFIG.headless (which may have been temporarily modified by browser_options)
448 |     const targetHeadless = overrideHeadless !== undefined
449 |       ? !overrideHeadless
450 |       : CONFIG.headless;
451 | 
452 |     // Compare with current mode
453 |     const needsChange = this.currentHeadlessMode !== targetHeadless;
454 | 
455 |     if (needsChange) {
456 |       log.info(`  Browser mode change detected: ${this.currentHeadlessMode ? 'HEADLESS' : 'VISIBLE'} → ${targetHeadless ? 'HEADLESS' : 'VISIBLE'}`);
457 |     }
458 | 
459 |     return needsChange;
460 |   }
461 | 
462 |   /**
463 |    * Get context ID for logging
464 |    */
465 |   private getContextId(): string {
466 |     if (!this.globalContext) {
467 |       return "none";
468 |     }
469 |     // Use object hash as ID
470 |     return `ctx-${(this.globalContext as any)._guid || "unknown"}`;
471 |   }
472 | }
473 | 
```

--------------------------------------------------------------------------------
/src/session/browser-session.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Browser Session
  3 |  *
  4 |  * Represents a single browser session for NotebookLM interactions.
  5 |  *
  6 |  * Features:
  7 |  * - Human-like question typing
  8 |  * - Streaming response detection
  9 |  * - Auto-login on session expiry
 10 |  * - Session activity tracking
 11 |  * - Chat history reset
 12 |  *
 13 |  * Based on the Python implementation from browser_session.py
 14 |  */
 15 | 
 16 | import type { BrowserContext, Page } from "patchright";
 17 | import { SharedContextManager } from "./shared-context-manager.js";
 18 | import { AuthManager } from "../auth/auth-manager.js";
 19 | import { humanType, randomDelay } from "../utils/stealth-utils.js";
 20 | import {
 21 |   waitForLatestAnswer,
 22 |   snapshotAllResponses,
 23 | } from "../utils/page-utils.js";
 24 | import { CONFIG } from "../config.js";
 25 | import { log } from "../utils/logger.js";
 26 | import type { SessionInfo, ProgressCallback } from "../types.js";
 27 | import { RateLimitError } from "../errors.js";
 28 | 
 29 | export class BrowserSession {
 30 |   public readonly sessionId: string;
 31 |   public readonly notebookUrl: string;
 32 |   public readonly createdAt: number;
 33 |   public lastActivity: number;
 34 |   public messageCount: number;
 35 | 
 36 |   private context!: BrowserContext;
 37 |   private sharedContextManager: SharedContextManager;
 38 |   private authManager: AuthManager;
 39 |   private page: Page | null = null;
 40 |   private initialized: boolean = false;
 41 | 
 42 |   constructor(
 43 |     sessionId: string,
 44 |     sharedContextManager: SharedContextManager,
 45 |     authManager: AuthManager,
 46 |     notebookUrl: string
 47 |   ) {
 48 |     this.sessionId = sessionId;
 49 |     this.sharedContextManager = sharedContextManager;
 50 |     this.authManager = authManager;
 51 |     this.notebookUrl = notebookUrl;
 52 |     this.createdAt = Date.now();
 53 |     this.lastActivity = Date.now();
 54 |     this.messageCount = 0;
 55 | 
 56 |     log.info(`🆕 BrowserSession ${sessionId} created`);
 57 |   }
 58 | 
 59 |   /**
 60 |    * Initialize the session by creating a page and navigating to the notebook
 61 |    */
 62 |   async init(): Promise<void> {
 63 |     if (this.initialized) {
 64 |       log.warning(`⚠️  Session ${this.sessionId} already initialized`);
 65 |       return;
 66 |     }
 67 | 
 68 |     log.info(`🚀 Initializing session ${this.sessionId}...`);
 69 | 
 70 |     try {
 71 |       // Ensure a valid shared context
 72 |       this.context = await this.sharedContextManager.getOrCreateContext();
 73 | 
 74 |       // Create new page (tab) in the shared context (with auto-recovery)
 75 |       try {
 76 |         this.page = await this.context.newPage();
 77 |       } catch (e: any) {
 78 |         const msg = String(e?.message || e);
 79 |         if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
 80 |           log.warning("  ♻️  Context was closed. Recreating and retrying newPage...");
 81 |           this.context = await this.sharedContextManager.getOrCreateContext();
 82 |           this.page = await this.context.newPage();
 83 |         } else {
 84 |           throw e;
 85 |         }
 86 |       }
 87 |       log.success(`  ✅ Created new page`);
 88 | 
 89 |       // Navigate to notebook
 90 |       log.info(`  🌐 Navigating to: ${this.notebookUrl}`);
 91 |       await this.page.goto(this.notebookUrl, {
 92 |         waitUntil: "domcontentloaded",
 93 |         timeout: CONFIG.browserTimeout,
 94 |       });
 95 | 
 96 |       // Wait for page to stabilize
 97 |       await randomDelay(2000, 3000);
 98 | 
 99 |       // Check if we need to login
100 |       const isAuthenticated = await this.authManager.validateCookiesExpiry(
101 |         this.context
102 |       );
103 | 
104 |       if (!isAuthenticated) {
105 |         log.warning(`  🔑 Session ${this.sessionId} needs authentication`);
106 |         const loginSuccess = await this.ensureAuthenticated();
107 |         if (!loginSuccess) {
108 |           throw new Error("Failed to authenticate session");
109 |         }
110 |       } else {
111 |         log.success(`  ✅ Session already authenticated`);
112 |       }
113 | 
114 |       // CRITICAL: Restore sessionStorage from saved state
115 |       // This is essential for maintaining Google session state!
116 |       log.info(`  🔄 Restoring sessionStorage...`);
117 |       const sessionData = await this.authManager.loadSessionStorage();
118 |       if (sessionData) {
119 |         const entryCount = Object.keys(sessionData).length;
120 |         if (entryCount > 0) {
121 |           await this.restoreSessionStorage(sessionData, entryCount);
122 |         } else {
123 |           log.info(`  ℹ️  SessionStorage empty (fresh session)`);
124 |         }
125 |       } else {
126 |         log.info(`  ℹ️  No saved sessionStorage found (fresh session)`);
127 |       }
128 | 
129 |       // Wait for NotebookLM interface to load
130 |       log.info(`  ⏳ Waiting for NotebookLM interface...`);
131 |       await this.waitForNotebookLMReady();
132 | 
133 |       this.initialized = true;
134 |       this.updateActivity();
135 |       log.success(`✅ Session ${this.sessionId} initialized successfully`);
136 |     } catch (error) {
137 |       log.error(`❌ Failed to initialize session ${this.sessionId}: ${error}`);
138 |       if (this.page) {
139 |         await this.page.close();
140 |         this.page = null;
141 |       }
142 |       throw error;
143 |     }
144 |   }
145 | 
146 |   /**
147 |    * Wait for NotebookLM interface to be ready
148 |    *
149 |    * IMPORTANT: Matches Python implementation EXACTLY!
150 |    * - Uses SPECIFIC selectors (textarea.query-box-input)
151 |    * - Checks ONLY for "visible" state (NOT disabled!)
152 |    * - NO placeholder checks (let NotebookLM handle that!)
153 |    *
154 |    * Based on Python _wait_for_ready() from browser_session.py:104-113
155 |    */
156 |   private async waitForNotebookLMReady(): Promise<void> {
157 |     if (!this.page) {
158 |       throw new Error("Page not initialized");
159 |     }
160 | 
161 |     try {
162 |       // PRIMARY: Exact Python selector - textarea.query-box-input
163 |       log.info("  ⏳ Waiting for chat input (textarea.query-box-input)...");
164 |       await this.page.waitForSelector("textarea.query-box-input", {
165 |         timeout: 10000, // Python uses 10s timeout
166 |         state: "visible", // ONLY check visibility (NO disabled check!)
167 |       });
168 |       log.success("  ✅ Chat input ready!");
169 |     } catch {
170 |       // FALLBACK: Python alternative selector
171 |       try {
172 |         log.info("  ⏳ Trying fallback selector (aria-label)...");
173 |         await this.page.waitForSelector('textarea[aria-label="Feld für Anfragen"]', {
174 |           timeout: 5000, // Python uses 5s for fallback
175 |           state: "visible",
176 |         });
177 |         log.success("  ✅ Chat input ready (fallback)!");
178 |       } catch (error) {
179 |         log.error(`  ❌ NotebookLM interface not ready: ${error}`);
180 |         throw new Error(
181 |           "Could not find NotebookLM chat input. " +
182 |           "Please ensure the notebook page has loaded correctly."
183 |         );
184 |       }
185 |     }
186 |   }
187 | 
188 |   private isPageClosedSafe(): boolean {
189 |     if (!this.page) return true;
190 |     const p: any = this.page as any;
191 |     try {
192 |       if (typeof p.isClosed === 'function') {
193 |         if (p.isClosed()) return true;
194 |       }
195 |       // Accessing URL should be safe; if page is gone, this may throw
196 |       void this.page.url();
197 |       return false;
198 |     } catch {
199 |       return true;
200 |     }
201 |   }
202 | 
203 |   /**
204 |    * Ensure the session is authenticated, perform auto-login if needed
205 |    */
206 |   private async ensureAuthenticated(): Promise<boolean> {
207 |     if (!this.page) {
208 |       throw new Error("Page not initialized");
209 |     }
210 | 
211 |     log.info(`🔑 Checking authentication for session ${this.sessionId}...`);
212 | 
213 |     // Check cookie validity
214 |     const isValid = await this.authManager.validateCookiesExpiry(this.context);
215 | 
216 |     if (isValid) {
217 |       log.success(`  ✅ Cookies valid`);
218 |       return true;
219 |     }
220 | 
221 |     log.warning(`  ⚠️  Cookies expired or invalid`);
222 | 
223 |     // Try to get valid auth state
224 |     const statePath = await this.authManager.getValidStatePath();
225 | 
226 |     if (statePath) {
227 |       // Load saved state
228 |       log.info(`  📂 Loading auth state from: ${statePath}`);
229 |       await this.authManager.loadAuthState(this.context, statePath);
230 | 
231 |       // Reload page to apply new auth
232 |       log.info(`  🔄 Reloading page...`);
233 |       await (this.page as Page).reload({ waitUntil: "domcontentloaded" });
234 |       await randomDelay(2000, 3000);
235 | 
236 |       // Check if it worked
237 |       const nowValid = await this.authManager.validateCookiesExpiry(
238 |         this.context
239 |       );
240 |       if (nowValid) {
241 |         log.success(`  ✅ Auth state loaded successfully`);
242 |         return true;
243 |       }
244 |     }
245 | 
246 |     // Need fresh login
247 |     log.warning(`  🔑 Fresh login required`);
248 | 
249 |     if (CONFIG.autoLoginEnabled) {
250 |       log.info(`  🤖 Attempting auto-login...`);
251 |       const loginSuccess = await this.authManager.loginWithCredentials(
252 |         this.context,
253 |         this.page,
254 |         CONFIG.loginEmail,
255 |         CONFIG.loginPassword
256 |       );
257 | 
258 |       if (loginSuccess) {
259 |         log.success(`  ✅ Auto-login successful`);
260 |         // Navigate back to notebook
261 |         await this.page.goto(this.notebookUrl, {
262 |           waitUntil: "domcontentloaded",
263 |         });
264 |         await randomDelay(2000, 3000);
265 |         return true;
266 |       } else {
267 |         log.error(`  ❌ Auto-login failed`);
268 |         return false;
269 |       }
270 |     } else {
271 |       log.error(
272 |         `  ❌ Auto-login disabled and no valid auth state - manual login required`
273 |       );
274 |       return false;
275 |     }
276 |   }
277 | 
278 |   private getOriginFromUrl(url: string): string | null {
279 |     try {
280 |       return new URL(url).origin;
281 |     } catch {
282 |       return null;
283 |     }
284 |   }
285 | 
286 |   /**
287 |    * Safely restore sessionStorage when the page is on the expected origin
288 |    */
289 |   private async restoreSessionStorage(
290 |     sessionData: Record<string, string>,
291 |     entryCount: number
292 |   ): Promise<void> {
293 |     if (!this.page) {
294 |       log.warning(`  ⚠️  Cannot restore sessionStorage without an active page`);
295 |       return;
296 |     }
297 | 
298 |     const targetOrigin = this.getOriginFromUrl(this.notebookUrl);
299 |     if (!targetOrigin) {
300 |       log.warning(`  ⚠️  Unable to determine target origin for sessionStorage restore`);
301 |       return;
302 |     }
303 | 
304 |     let restored = false;
305 | 
306 |     const applyToPage = async (): Promise<boolean> => {
307 |       if (!this.page) {
308 |         return false;
309 |       }
310 | 
311 |       const currentOrigin = this.getOriginFromUrl(this.page.url());
312 |       if (currentOrigin !== targetOrigin) {
313 |         return false;
314 |       }
315 | 
316 |       try {
317 |         await this.page.evaluate((data) => {
318 |           for (const [key, value] of Object.entries(data)) {
319 |             // @ts-expect-error - sessionStorage exists in browser context
320 |             sessionStorage.setItem(key, value);
321 |           }
322 |         }, sessionData);
323 |         restored = true;
324 |         log.success(`  ✅ SessionStorage restored: ${entryCount} entries`);
325 |         return true;
326 |       } catch (error) {
327 |         log.warning(`  ⚠️  Failed to restore sessionStorage: ${error}`);
328 |         return false;
329 |       }
330 |     };
331 | 
332 |     if (await applyToPage()) {
333 |       return;
334 |     }
335 | 
336 |     log.info(`  ⏳ Waiting for NotebookLM origin before restoring sessionStorage...`);
337 | 
338 |     const handleNavigation = async () => {
339 |       if (restored) {
340 |         return;
341 |       }
342 | 
343 |       if (await applyToPage()) {
344 |         this.page?.off("framenavigated", handleNavigation);
345 |       }
346 |     };
347 | 
348 |     this.page.on("framenavigated", handleNavigation);
349 |   }
350 | 
351 |   /**
352 |    * Ask a question to NotebookLM
353 |    */
354 |   async ask(question: string, sendProgress?: ProgressCallback): Promise<string> {
355 |     const askOnce = async (): Promise<string> => {
356 |       if (!this.initialized || !this.page || this.isPageClosedSafe()) {
357 |         log.warning(`  ℹ️  Session not initialized or page missing → re-initializing...`);
358 |         await this.init();
359 |       }
360 | 
361 |       log.info(`💬 [${this.sessionId}] Asking: "${question.substring(0, 100)}..."`);
362 |       const page = this.page!;
363 |       // Ensure we're still authenticated
364 |       await sendProgress?.("Verifying authentication...", 2, 5);
365 |       const isAuth = await this.authManager.validateCookiesExpiry(this.context);
366 |       if (!isAuth) {
367 |         log.warning(`  🔑 Session expired, re-authenticating...`);
368 |         await sendProgress?.("Re-authenticating session...", 2, 5);
369 |         const reAuthSuccess = await this.ensureAuthenticated();
370 |         if (!reAuthSuccess) {
371 |           throw new Error("Failed to re-authenticate session");
372 |         }
373 |       }
374 | 
375 |       // Snapshot existing responses BEFORE asking
376 |       log.info(`  📸 Snapshotting existing responses...`);
377 |       const existingResponses = await snapshotAllResponses(page);
378 |       log.success(`  ✅ Captured ${existingResponses.length} existing responses`);
379 | 
380 |       // Find the chat input
381 |       const inputSelector = await this.findChatInput();
382 |       if (!inputSelector) {
383 |         throw new Error(
384 |           "Could not find visible chat input element. " +
385 |           "Please check if the notebook page has loaded correctly."
386 |         );
387 |       }
388 | 
389 |       log.info(`  ⌨️  Typing question with human-like behavior...`);
390 |       await sendProgress?.("Typing question with human-like behavior...", 2, 5);
391 |       await humanType(page, inputSelector, question, {
392 |         withTypos: true,
393 |         wpm: Math.max(CONFIG.typingWpmMin, CONFIG.typingWpmMax),
394 |       });
395 | 
396 |       // Small pause before submitting
397 |       await randomDelay(500, 1000);
398 | 
399 |       // Submit the question (Enter key)
400 |       log.info(`  📤 Submitting question...`);
401 |       await sendProgress?.("Submitting question...", 3, 5);
402 |       await page.keyboard.press("Enter");
403 | 
404 |       // Small pause after submit
405 |       await randomDelay(1000, 1500);
406 | 
407 |       // Wait for the response with streaming detection
408 |       log.info(`  ⏳ Waiting for response (with streaming detection)...`);
409 |       await sendProgress?.("Waiting for NotebookLM response (streaming detection active)...", 3, 5);
410 |       const answer = await waitForLatestAnswer(page, {
411 |         question,
412 |         timeoutMs: 120000, // 2 minutes
413 |         pollIntervalMs: 1000,
414 |         ignoreTexts: existingResponses,
415 |         debug: false,
416 |       });
417 | 
418 |       if (!answer) {
419 |         throw new Error("Timeout waiting for response from NotebookLM");
420 |       }
421 | 
422 |       // Check for rate limit errors AFTER receiving answer
423 |       log.info(`  🔍 Checking for rate limit errors...`);
424 |       if (await this.detectRateLimitError()) {
425 |         throw new RateLimitError(
426 |           "NotebookLM rate limit reached (50 queries/day for free accounts)"
427 |         );
428 |       }
429 | 
430 |       // Update session stats
431 |       this.messageCount++;
432 |       this.updateActivity();
433 | 
434 |       log.success(
435 |         `✅ [${this.sessionId}] Received answer (${answer.length} chars, ${this.messageCount} total messages)`
436 |       );
437 | 
438 |       return answer;
439 |     };
440 | 
441 |     try {
442 |       return await askOnce();
443 |     } catch (error: any) {
444 |       const msg = String(error?.message || error);
445 |       if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
446 |         log.warning(`  ♻️  Detected closed page/context. Recovering session and retrying ask...`);
447 |         try {
448 |           this.initialized = false;
449 |           if (this.page) { try { await this.page.close(); } catch {} }
450 |           this.page = null;
451 |           await this.init();
452 |           return await askOnce();
453 |         } catch (e2) {
454 |           log.error(`❌ Recovery failed: ${e2}`);
455 |           throw e2;
456 |         }
457 |       }
458 |       log.error(`❌ [${this.sessionId}] Failed to ask question: ${msg}`);
459 |       throw error;
460 |     }
461 |   }
462 | 
463 |   /**
464 |    * Find the chat input element
465 |    *
466 |    * IMPORTANT: Matches Python implementation EXACTLY!
467 |    * - Uses SPECIFIC selectors from Python
468 |    * - Checks ONLY visibility (NOT disabled state!)
469 |    *
470 |    * Based on Python ask() method from browser_session.py:166-171
471 |    */
472 |   private async findChatInput(): Promise<string | null> {
473 |     if (!this.page) {
474 |       return null;
475 |     }
476 | 
477 |     // Use EXACT Python selectors (in order of preference)
478 |     const selectors = [
479 |       "textarea.query-box-input", // ← PRIMARY Python selector
480 |       'textarea[aria-label="Feld für Anfragen"]', // ← Python fallback
481 |     ];
482 | 
483 |     for (const selector of selectors) {
484 |       try {
485 |         const element = await this.page.$(selector);
486 |         if (element) {
487 |           const isVisible = await element.isVisible();
488 |           if (isVisible) {
489 |             // NO disabled check! Just like Python!
490 |             log.success(`  ✅ Found chat input: ${selector}`);
491 |             return selector;
492 |           }
493 |         }
494 |       } catch {
495 |         continue;
496 |       }
497 |     }
498 | 
499 |     log.error(`  ❌ Could not find visible chat input`);
500 |     return null;
501 |   }
502 | 
503 |   /**
504 |    * Detect if a rate limit error occurred
505 |    *
506 |    * Searches the page for error messages indicating rate limit/quota exhaustion.
507 |    * Free NotebookLM accounts have 50 queries/day limit.
508 |    *
509 |    * @returns true if rate limit error detected, false otherwise
510 |    */
511 |   private async detectRateLimitError(): Promise<boolean> {
512 |     if (!this.page) {
513 |       return false;
514 |     }
515 | 
516 |     // Error message selectors (common patterns for error containers)
517 |     const errorSelectors = [
518 |       ".error-message",
519 |       ".error-container",
520 |       "[role='alert']",
521 |       ".rate-limit-message",
522 |       "[data-error]",
523 |       ".notification-error",
524 |       ".alert-error",
525 |       ".toast-error",
526 |     ];
527 | 
528 |     // Keywords that indicate rate limiting
529 |     const keywords = [
530 |       "rate limit",
531 |       "limit exceeded",
532 |       "quota exhausted",
533 |       "daily limit",
534 |       "limit reached",
535 |       "too many requests",
536 |       "ratenlimit",
537 |       "quota",
538 |       "query limit",
539 |       "request limit",
540 |     ];
541 | 
542 |     // Check error containers for rate limit messages
543 |     for (const selector of errorSelectors) {
544 |       try {
545 |         const elements = await this.page.$$(selector);
546 |         for (const el of elements) {
547 |           try {
548 |             const text = await el.innerText();
549 |             const lower = text.toLowerCase();
550 | 
551 |             if (keywords.some((k) => lower.includes(k))) {
552 |               log.error(`🚫 Rate limit detected: ${text.slice(0, 100)}`);
553 |               return true;
554 |             }
555 |           } catch {
556 |             continue;
557 |           }
558 |         }
559 |       } catch {
560 |         continue;
561 |       }
562 |     }
563 | 
564 |     // Also check if chat input is disabled (sometimes NotebookLM disables input when rate limited)
565 |     try {
566 |       const inputSelector = "textarea.query-box-input";
567 |       const input = await this.page.$(inputSelector);
568 |       if (input) {
569 |         const isDisabled = await input.evaluate((el: any) => {
570 |           return el.disabled || el.hasAttribute("disabled");
571 |         });
572 | 
573 |         if (isDisabled) {
574 |           // Check if there's an error message near the input
575 |           const parent = await input.evaluateHandle((el) => el.parentElement);
576 |           const parentEl = parent.asElement();
577 |           if (parentEl) {
578 |             try {
579 |               const parentText = await parentEl.innerText();
580 |               const lower = parentText.toLowerCase();
581 |               if (keywords.some((k) => lower.includes(k))) {
582 |                 log.error(`🚫 Rate limit detected: Chat input disabled with error message`);
583 |                 return true;
584 |               }
585 |             } catch {
586 |               // Ignore
587 |             }
588 |           }
589 |         }
590 |       }
591 |     } catch {
592 |       // Ignore errors checking input state
593 |     }
594 | 
595 |     return false;
596 |   }
597 | 
598 |   /**
599 |    * Reset the chat history (start a new conversation)
600 |    */
601 |   async reset(): Promise<void> {
602 |     const resetOnce = async (): Promise<void> => {
603 |       if (!this.initialized || !this.page || this.isPageClosedSafe()) {
604 |         await this.init();
605 |       }
606 |       log.info(`🔄 [${this.sessionId}] Resetting chat history...`);
607 |       // Reload the page to clear chat history
608 |       await (this.page as Page).reload({ waitUntil: "domcontentloaded" });
609 |       await randomDelay(2000, 3000);
610 | 
611 |       // Wait for interface to be ready again
612 |       await this.waitForNotebookLMReady();
613 | 
614 |       // Reset message count
615 |       this.messageCount = 0;
616 |       this.updateActivity();
617 | 
618 |       log.success(`✅ [${this.sessionId}] Chat history reset`);
619 |     };
620 | 
621 |     try {
622 |       await resetOnce();
623 |     } catch (error: any) {
624 |       const msg = String(error?.message || error);
625 |       if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
626 |         log.warning(`  ♻️  Detected closed page/context during reset. Recovering and retrying...`);
627 |         this.initialized = false;
628 |         if (this.page) { try { await this.page.close(); } catch {} }
629 |         this.page = null;
630 |         await this.init();
631 |         await resetOnce();
632 |         return;
633 |       }
634 |       log.error(`❌ [${this.sessionId}] Failed to reset: ${msg}`);
635 |       throw error;
636 |     }
637 |   }
638 | 
639 |   /**
640 |    * Close the session
641 |    */
642 |   async close(): Promise<void> {
643 |     log.info(`🛑 Closing session ${this.sessionId}...`);
644 | 
645 |     if (this.page) {
646 |       try {
647 |         await this.page.close();
648 |         this.page = null;
649 |         log.success(`  ✅ Page closed`);
650 |       } catch (error) {
651 |         log.warning(`  ⚠️  Error closing page: ${error}`);
652 |       }
653 |     }
654 | 
655 |     this.initialized = false;
656 |     log.success(`✅ Session ${this.sessionId} closed`);
657 |   }
658 | 
659 |   /**
660 |    * Update last activity timestamp
661 |    */
662 |   updateActivity(): void {
663 |     this.lastActivity = Date.now();
664 |   }
665 | 
666 |   /**
667 |    * Check if session has expired (inactive for too long)
668 |    */
669 |   isExpired(timeoutSeconds: number): boolean {
670 |     const inactiveSeconds = (Date.now() - this.lastActivity) / 1000;
671 |     return inactiveSeconds > timeoutSeconds;
672 |   }
673 | 
674 |   /**
675 |    * Get session information
676 |    */
677 |   getInfo(): SessionInfo {
678 |     const now = Date.now();
679 |     return {
680 |       id: this.sessionId,
681 |       created_at: this.createdAt,
682 |       last_activity: this.lastActivity,
683 |       age_seconds: (now - this.createdAt) / 1000,
684 |       inactive_seconds: (now - this.lastActivity) / 1000,
685 |       message_count: this.messageCount,
686 |       notebook_url: this.notebookUrl,
687 |     };
688 |   }
689 | 
690 |   /**
691 |    * Get the underlying page (for advanced operations)
692 |    */
693 |   getPage(): Page | null {
694 |     return this.page;
695 |   }
696 | 
697 |   /**
698 |    * Check if session is initialized
699 |    */
700 |   isInitialized(): boolean {
701 |     return this.initialized && this.page !== null;
702 |   }
703 | }
704 | 
```

--------------------------------------------------------------------------------
/src/utils/cleanup-manager.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Cleanup Manager for NotebookLM MCP Server
  3 |  *
  4 |  * ULTRATHINK EDITION - Complete cleanup across all platforms!
  5 |  *
  6 |  * Handles safe removal of:
  7 |  * - Legacy data from notebooklm-mcp-nodejs
  8 |  * - Current installation data
  9 |  * - Browser profiles and session data
 10 |  * - NPM/NPX cache
 11 |  * - Claude CLI MCP logs
 12 |  * - Claude Projects cache
 13 |  * - Temporary backups
 14 |  * - Editor logs (Cursor, VSCode)
 15 |  * - Trash files (optional)
 16 |  *
 17 |  * Platform support: Linux, Windows, macOS
 18 |  */
 19 | 
 20 | import fs from "fs/promises";
 21 | import path from "path";
 22 | import { globby } from "globby";
 23 | import envPaths from "env-paths";
 24 | import os from "os";
 25 | import { log } from "./logger.js";
 26 | 
 27 | export type CleanupMode = "legacy" | "all" | "deep";
 28 | 
 29 | export interface CleanupResult {
 30 |   success: boolean;
 31 |   mode: CleanupMode;
 32 |   deletedPaths: string[];
 33 |   failedPaths: string[];
 34 |   totalSizeBytes: number;
 35 |   categorySummary: Record<string, { count: number; bytes: number }>;
 36 | }
 37 | 
 38 | export interface CleanupCategory {
 39 |   name: string;
 40 |   description: string;
 41 |   paths: string[];
 42 |   totalBytes: number;
 43 |   optional: boolean;
 44 | }
 45 | 
 46 | interface Paths {
 47 |   data: string;
 48 |   config: string;
 49 |   cache: string;
 50 |   log: string;
 51 |   temp: string;
 52 | }
 53 | 
 54 | export class CleanupManager {
 55 |   private legacyPaths: Paths;
 56 |   private currentPaths: Paths;
 57 |   private homeDir: string;
 58 |   private tempDir: string;
 59 | 
 60 |   constructor() {
 61 |     // envPaths() does NOT create directories - it just returns path strings
 62 |     // IMPORTANT: envPaths() has a default suffix 'nodejs', so we must explicitly disable it!
 63 | 
 64 |     // Legacy paths with -nodejs suffix (using default suffix behavior)
 65 |     this.legacyPaths = envPaths("notebooklm-mcp");  // This becomes notebooklm-mcp-nodejs by default
 66 |     // Current paths without suffix (disable the default suffix with empty string)
 67 |     this.currentPaths = envPaths("notebooklm-mcp", {suffix: ""});
 68 |     // Platform-agnostic paths
 69 |     this.homeDir = os.homedir();
 70 |     this.tempDir = os.tmpdir();
 71 |   }
 72 | 
 73 |   // ============================================================================
 74 |   // Platform-Specific Path Resolution
 75 |   // ============================================================================
 76 | 
 77 |   /**
 78 |    * Get NPM cache directory (platform-specific)
 79 |    */
 80 |   private getNpmCachePath(): string {
 81 |     return path.join(this.homeDir, ".npm");
 82 |   }
 83 | 
 84 |   /**
 85 |    * Get Claude CLI cache directory (platform-specific)
 86 |    */
 87 |   private getClaudeCliCachePath(): string {
 88 |     const platform = process.platform;
 89 | 
 90 |     if (platform === "win32") {
 91 |       const localAppData = process.env.LOCALAPPDATA || path.join(this.homeDir, "AppData", "Local");
 92 |       return path.join(localAppData, "claude-cli-nodejs");
 93 |     } else if (platform === "darwin") {
 94 |       return path.join(this.homeDir, "Library", "Caches", "claude-cli-nodejs");
 95 |     } else {
 96 |       // Linux and others
 97 |       return path.join(this.homeDir, ".cache", "claude-cli-nodejs");
 98 |     }
 99 |   }
100 | 
101 |   /**
102 |    * Get Claude projects directory (platform-specific)
103 |    */
104 |   private getClaudeProjectsPath(): string {
105 |     const platform = process.platform;
106 | 
107 |     if (platform === "win32") {
108 |       const appData = process.env.APPDATA || path.join(this.homeDir, "AppData", "Roaming");
109 |       return path.join(appData, ".claude", "projects");
110 |     } else if (platform === "darwin") {
111 |       return path.join(this.homeDir, "Library", "Application Support", "claude", "projects");
112 |     } else {
113 |       // Linux and others
114 |       return path.join(this.homeDir, ".claude", "projects");
115 |     }
116 |   }
117 | 
118 |   /**
119 |    * Get editor config paths (Cursor, VSCode)
120 |    */
121 |   private getEditorConfigPaths(): string[] {
122 |     const platform = process.platform;
123 |     const paths: string[] = [];
124 | 
125 |     if (platform === "win32") {
126 |       const appData = process.env.APPDATA || path.join(this.homeDir, "AppData", "Roaming");
127 |       paths.push(
128 |         path.join(appData, "Cursor", "logs"),
129 |         path.join(appData, "Code", "logs")
130 |       );
131 |     } else if (platform === "darwin") {
132 |       paths.push(
133 |         path.join(this.homeDir, "Library", "Application Support", "Cursor", "logs"),
134 |         path.join(this.homeDir, "Library", "Application Support", "Code", "logs")
135 |       );
136 |     } else {
137 |       // Linux
138 |       paths.push(
139 |         path.join(this.homeDir, ".config", "Cursor", "logs"),
140 |         path.join(this.homeDir, ".config", "Code", "logs")
141 |       );
142 |     }
143 | 
144 |     return paths;
145 |   }
146 | 
147 |   /**
148 |    * Get trash directory (platform-specific)
149 |    */
150 |   private getTrashPath(): string | null {
151 |     const platform = process.platform;
152 | 
153 |     if (platform === "darwin") {
154 |       return path.join(this.homeDir, ".Trash");
155 |     } else if (platform === "linux") {
156 |       return path.join(this.homeDir, ".local", "share", "Trash");
157 |     } else {
158 |       // Windows Recycle Bin is complex, skip for now
159 |       return null;
160 |     }
161 |   }
162 | 
163 |   /**
164 |    * Get manual legacy config paths that might not be caught by envPaths
165 |    * This ensures we catch ALL legacy installations including old config.json files
166 |    */
167 |   private getManualLegacyPaths(): string[] {
168 |     const paths: string[] = [];
169 |     const platform = process.platform;
170 | 
171 |     if (platform === "linux") {
172 |       // Linux-specific paths
173 |       paths.push(
174 |         path.join(this.homeDir, ".config", "notebooklm-mcp"),
175 |         path.join(this.homeDir, ".config", "notebooklm-mcp-nodejs"),
176 |         path.join(this.homeDir, ".local", "share", "notebooklm-mcp"),
177 |         path.join(this.homeDir, ".local", "share", "notebooklm-mcp-nodejs"),
178 |         path.join(this.homeDir, ".cache", "notebooklm-mcp"),
179 |         path.join(this.homeDir, ".cache", "notebooklm-mcp-nodejs"),
180 |         path.join(this.homeDir, ".local", "state", "notebooklm-mcp"),
181 |         path.join(this.homeDir, ".local", "state", "notebooklm-mcp-nodejs")
182 |       );
183 |     } else if (platform === "darwin") {
184 |       // macOS-specific paths
185 |       paths.push(
186 |         path.join(this.homeDir, "Library", "Application Support", "notebooklm-mcp"),
187 |         path.join(this.homeDir, "Library", "Application Support", "notebooklm-mcp-nodejs"),
188 |         path.join(this.homeDir, "Library", "Preferences", "notebooklm-mcp"),
189 |         path.join(this.homeDir, "Library", "Preferences", "notebooklm-mcp-nodejs"),
190 |         path.join(this.homeDir, "Library", "Caches", "notebooklm-mcp"),
191 |         path.join(this.homeDir, "Library", "Caches", "notebooklm-mcp-nodejs"),
192 |         path.join(this.homeDir, "Library", "Logs", "notebooklm-mcp"),
193 |         path.join(this.homeDir, "Library", "Logs", "notebooklm-mcp-nodejs")
194 |       );
195 |     } else if (platform === "win32") {
196 |       // Windows-specific paths
197 |       const localAppData = process.env.LOCALAPPDATA || path.join(this.homeDir, "AppData", "Local");
198 |       const appData = process.env.APPDATA || path.join(this.homeDir, "AppData", "Roaming");
199 |       paths.push(
200 |         path.join(localAppData, "notebooklm-mcp"),
201 |         path.join(localAppData, "notebooklm-mcp-nodejs"),
202 |         path.join(appData, "notebooklm-mcp"),
203 |         path.join(appData, "notebooklm-mcp-nodejs")
204 |       );
205 |     }
206 | 
207 |     return paths;
208 |   }
209 | 
210 |   // ============================================================================
211 |   // Search Methods for Different File Types
212 |   // ============================================================================
213 | 
214 |   /**
215 |    * Find NPM/NPX cache files
216 |    */
217 |   private async findNpmCache(): Promise<string[]> {
218 |     const found: string[] = [];
219 | 
220 |     try {
221 |       const npmCachePath = this.getNpmCachePath();
222 |       const npxPath = path.join(npmCachePath, "_npx");
223 | 
224 |       if (!(await this.pathExists(npxPath))) {
225 |         return found;
226 |       }
227 | 
228 |       // Search for notebooklm-mcp in npx cache
229 |       const pattern = path.join(npxPath, "*/node_modules/notebooklm-mcp");
230 |       const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
231 |       found.push(...matches);
232 |     } catch (error) {
233 |       log.warning(`⚠️  Error searching NPM cache: ${error}`);
234 |     }
235 | 
236 |     return found;
237 |   }
238 | 
239 |   /**
240 |    * Find Claude CLI MCP logs
241 |    */
242 |   private async findClaudeCliLogs(): Promise<string[]> {
243 |     const found: string[] = [];
244 | 
245 |     try {
246 |       const claudeCliPath = this.getClaudeCliCachePath();
247 | 
248 |       if (!(await this.pathExists(claudeCliPath))) {
249 |         return found;
250 |       }
251 | 
252 |       // Search for notebooklm MCP logs
253 |       const patterns = [
254 |         path.join(claudeCliPath, "*/mcp-logs-notebooklm"),
255 |         path.join(claudeCliPath, "*notebooklm-mcp*"),
256 |       ];
257 | 
258 |       for (const pattern of patterns) {
259 |         const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
260 |         found.push(...matches);
261 |       }
262 |     } catch (error) {
263 |       log.warning(`⚠️  Error searching Claude CLI cache: ${error}`);
264 |     }
265 | 
266 |     return found;
267 |   }
268 | 
269 |   /**
270 |    * Find Claude projects cache
271 |    */
272 |   private async findClaudeProjects(): Promise<string[]> {
273 |     const found: string[] = [];
274 | 
275 |     try {
276 |       const projectsPath = this.getClaudeProjectsPath();
277 | 
278 |       if (!(await this.pathExists(projectsPath))) {
279 |         return found;
280 |       }
281 | 
282 |       // Search for notebooklm-mcp projects
283 |       const pattern = path.join(projectsPath, "*notebooklm-mcp*");
284 |       const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
285 |       found.push(...matches);
286 |     } catch (error) {
287 |       log.warning(`⚠️  Error searching Claude projects: ${error}`);
288 |     }
289 | 
290 |     return found;
291 |   }
292 | 
293 |   /**
294 |    * Find temporary backups
295 |    */
296 |   private async findTemporaryBackups(): Promise<string[]> {
297 |     const found: string[] = [];
298 | 
299 |     try {
300 |       // Search for notebooklm backup directories in temp
301 |       const pattern = path.join(this.tempDir, "notebooklm-backup-*");
302 |       const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
303 |       found.push(...matches);
304 |     } catch (error) {
305 |       log.warning(`⚠️  Error searching temp backups: ${error}`);
306 |     }
307 | 
308 |     return found;
309 |   }
310 | 
311 |   /**
312 |    * Find editor logs (Cursor, VSCode)
313 |    */
314 |   private async findEditorLogs(): Promise<string[]> {
315 |     const found: string[] = [];
316 | 
317 |     try {
318 |       const editorPaths = this.getEditorConfigPaths();
319 | 
320 |       for (const editorPath of editorPaths) {
321 |         if (!(await this.pathExists(editorPath))) {
322 |           continue;
323 |         }
324 | 
325 |         // Search for MCP notebooklm logs
326 |         const pattern = path.join(editorPath, "**/exthost/**/*notebooklm*.log");
327 |         const matches = await globby(pattern, { onlyFiles: true, absolute: true });
328 |         found.push(...matches);
329 |       }
330 |     } catch (error) {
331 |       log.warning(`⚠️  Error searching editor logs: ${error}`);
332 |     }
333 | 
334 |     return found;
335 |   }
336 | 
337 |   /**
338 |    * Find trash files
339 |    */
340 |   private async findTrashFiles(): Promise<string[]> {
341 |     const found: string[] = [];
342 | 
343 |     try {
344 |       const trashPath = this.getTrashPath();
345 |       if (!trashPath || !(await this.pathExists(trashPath))) {
346 |         return found;
347 |       }
348 | 
349 |       // Search for notebooklm files in trash
350 |       const patterns = [
351 |         path.join(trashPath, "**/*notebooklm*"),
352 |       ];
353 | 
354 |       for (const pattern of patterns) {
355 |         const matches = await globby(pattern, { absolute: true });
356 |         found.push(...matches);
357 |       }
358 |     } catch (error) {
359 |       log.warning(`⚠️  Error searching trash: ${error}`);
360 |     }
361 | 
362 |     return found;
363 |   }
364 | 
365 |   // ============================================================================
366 |   // Main Cleanup Methods
367 |   // ============================================================================
368 | 
369 |   /**
370 |    * Get all paths that would be deleted for a given mode (with categorization)
371 |    */
372 |   async getCleanupPaths(
373 |     mode: CleanupMode,
374 |     preserveLibrary: boolean = false
375 |   ): Promise<{
376 |     categories: CleanupCategory[];
377 |     totalPaths: string[];
378 |     totalSizeBytes: number;
379 |   }> {
380 |     const categories: CleanupCategory[] = [];
381 |     const allPaths: Set<string> = new Set();
382 |     let totalSizeBytes = 0;
383 | 
384 |     // Category 1: Legacy Paths (notebooklm-mcp-nodejs & manual legacy paths)
385 |     if (mode === "legacy" || mode === "all" || mode === "deep") {
386 |       const legacyPaths: string[] = [];
387 |       let legacyBytes = 0;
388 | 
389 |       // Check envPaths-based legacy directories
390 |       const legacyDirs = [
391 |         this.legacyPaths.data,
392 |         this.legacyPaths.config,
393 |         this.legacyPaths.cache,
394 |         this.legacyPaths.log,
395 |         this.legacyPaths.temp,
396 |       ];
397 | 
398 |       for (const dir of legacyDirs) {
399 |         if (await this.pathExists(dir)) {
400 |           const size = await this.getDirectorySize(dir);
401 |           legacyPaths.push(dir);
402 |           legacyBytes += size;
403 |           allPaths.add(dir);
404 |         }
405 |       }
406 | 
407 |       // CRITICAL: Also check manual legacy paths to catch old config.json files
408 |       // and any paths that envPaths might miss
409 |       const manualLegacyPaths = this.getManualLegacyPaths();
410 |       for (const dir of manualLegacyPaths) {
411 |         if (await this.pathExists(dir) && !allPaths.has(dir)) {
412 |           const size = await this.getDirectorySize(dir);
413 |           legacyPaths.push(dir);
414 |           legacyBytes += size;
415 |           allPaths.add(dir);
416 |         }
417 |       }
418 | 
419 |       if (legacyPaths.length > 0) {
420 |         categories.push({
421 |           name: "Legacy Installation (notebooklm-mcp-nodejs)",
422 |           description: "Old installation data with -nodejs suffix and legacy config files",
423 |           paths: legacyPaths,
424 |           totalBytes: legacyBytes,
425 |           optional: false,
426 |         });
427 |         totalSizeBytes += legacyBytes;
428 |       }
429 |     }
430 | 
431 |     // Category 2: Current Installation
432 |     if (mode === "all" || mode === "deep") {
433 |       const currentPaths: string[] = [];
434 |       let currentBytes = 0;
435 | 
436 |       // If preserveLibrary is true, don't delete the data directory itself
437 |       // Instead, only delete subdirectories
438 |       const currentDirs = preserveLibrary
439 |         ? [
440 |             // Don't include data directory to preserve library.json
441 |             this.currentPaths.config,
442 |             this.currentPaths.cache,
443 |             this.currentPaths.log,
444 |             this.currentPaths.temp,
445 |             // Only delete subdirectories, not the parent
446 |             path.join(this.currentPaths.data, "browser_state"),
447 |             path.join(this.currentPaths.data, "chrome_profile"),
448 |             path.join(this.currentPaths.data, "chrome_profile_instances"),
449 |           ]
450 |         : [
451 |             // Delete everything including data directory
452 |             this.currentPaths.data,
453 |             this.currentPaths.config,
454 |             this.currentPaths.cache,
455 |             this.currentPaths.log,
456 |             this.currentPaths.temp,
457 |             // Specific subdirectories (only if parent doesn't exist)
458 |             path.join(this.currentPaths.data, "browser_state"),
459 |             path.join(this.currentPaths.data, "chrome_profile"),
460 |             path.join(this.currentPaths.data, "chrome_profile_instances"),
461 |           ];
462 | 
463 |       for (const dir of currentDirs) {
464 |         if (await this.pathExists(dir) && !allPaths.has(dir)) {
465 |           const size = await this.getDirectorySize(dir);
466 |           currentPaths.push(dir);
467 |           currentBytes += size;
468 |           allPaths.add(dir);
469 |         }
470 |       }
471 | 
472 |       if (currentPaths.length > 0) {
473 |         const description = preserveLibrary
474 |           ? "Active installation data and browser profiles (library.json will be preserved)"
475 |           : "Active installation data and browser profiles";
476 | 
477 |         categories.push({
478 |           name: "Current Installation (notebooklm-mcp)",
479 |           description,
480 |           paths: currentPaths,
481 |           totalBytes: currentBytes,
482 |           optional: false,
483 |         });
484 |         totalSizeBytes += currentBytes;
485 |       }
486 |     }
487 | 
488 |     // Category 3: NPM Cache
489 |     if (mode === "all" || mode === "deep") {
490 |       const npmPaths = await this.findNpmCache();
491 |       if (npmPaths.length > 0) {
492 |         let npmBytes = 0;
493 |         for (const p of npmPaths) {
494 |           if (!allPaths.has(p)) {
495 |             npmBytes += await this.getDirectorySize(p);
496 |             allPaths.add(p);
497 |           }
498 |         }
499 | 
500 |         if (npmBytes > 0) {
501 |           categories.push({
502 |             name: "NPM/NPX Cache",
503 |             description: "NPX cached installations of notebooklm-mcp",
504 |             paths: npmPaths,
505 |             totalBytes: npmBytes,
506 |             optional: false,
507 |           });
508 |           totalSizeBytes += npmBytes;
509 |         }
510 |       }
511 |     }
512 | 
513 |     // Category 4: Claude CLI Logs
514 |     if (mode === "all" || mode === "deep") {
515 |       const claudeCliPaths = await this.findClaudeCliLogs();
516 |       if (claudeCliPaths.length > 0) {
517 |         let claudeCliBytes = 0;
518 |         for (const p of claudeCliPaths) {
519 |           if (!allPaths.has(p)) {
520 |             claudeCliBytes += await this.getDirectorySize(p);
521 |             allPaths.add(p);
522 |           }
523 |         }
524 | 
525 |         if (claudeCliBytes > 0) {
526 |           categories.push({
527 |             name: "Claude CLI MCP Logs",
528 |             description: "MCP server logs from Claude CLI",
529 |             paths: claudeCliPaths,
530 |             totalBytes: claudeCliBytes,
531 |             optional: false,
532 |           });
533 |           totalSizeBytes += claudeCliBytes;
534 |         }
535 |       }
536 |     }
537 | 
538 |     // Category 5: Temporary Backups
539 |     if (mode === "all" || mode === "deep") {
540 |       const backupPaths = await this.findTemporaryBackups();
541 |       if (backupPaths.length > 0) {
542 |         let backupBytes = 0;
543 |         for (const p of backupPaths) {
544 |           if (!allPaths.has(p)) {
545 |             backupBytes += await this.getDirectorySize(p);
546 |             allPaths.add(p);
547 |           }
548 |         }
549 | 
550 |         if (backupBytes > 0) {
551 |           categories.push({
552 |             name: "Temporary Backups",
553 |             description: "Temporary backup directories in system temp",
554 |             paths: backupPaths,
555 |             totalBytes: backupBytes,
556 |             optional: false,
557 |           });
558 |           totalSizeBytes += backupBytes;
559 |         }
560 |       }
561 |     }
562 | 
563 |     // Category 6: Claude Projects (deep mode only)
564 |     if (mode === "deep") {
565 |       const projectPaths = await this.findClaudeProjects();
566 |       if (projectPaths.length > 0) {
567 |         let projectBytes = 0;
568 |         for (const p of projectPaths) {
569 |           if (!allPaths.has(p)) {
570 |             projectBytes += await this.getDirectorySize(p);
571 |             allPaths.add(p);
572 |           }
573 |         }
574 | 
575 |         if (projectBytes > 0) {
576 |           categories.push({
577 |             name: "Claude Projects Cache",
578 |             description: "Project-specific cache in Claude config",
579 |             paths: projectPaths,
580 |             totalBytes: projectBytes,
581 |             optional: true,
582 |           });
583 |           totalSizeBytes += projectBytes;
584 |         }
585 |       }
586 |     }
587 | 
588 |     // Category 7: Editor Logs (deep mode only)
589 |     if (mode === "deep") {
590 |       const editorPaths = await this.findEditorLogs();
591 |       if (editorPaths.length > 0) {
592 |         let editorBytes = 0;
593 |         for (const p of editorPaths) {
594 |           if (!allPaths.has(p)) {
595 |             editorBytes += await this.getFileSize(p);
596 |             allPaths.add(p);
597 |           }
598 |         }
599 | 
600 |         if (editorBytes > 0) {
601 |           categories.push({
602 |             name: "Editor Logs (Cursor/VSCode)",
603 |             description: "MCP logs from code editors",
604 |             paths: editorPaths,
605 |             totalBytes: editorBytes,
606 |             optional: true,
607 |           });
608 |           totalSizeBytes += editorBytes;
609 |         }
610 |       }
611 |     }
612 | 
613 |     // Category 8: Trash Files (deep mode only)
614 |     if (mode === "deep") {
615 |       const trashPaths = await this.findTrashFiles();
616 |       if (trashPaths.length > 0) {
617 |         let trashBytes = 0;
618 |         for (const p of trashPaths) {
619 |           if (!allPaths.has(p)) {
620 |             trashBytes += await this.getFileSize(p);
621 |             allPaths.add(p);
622 |           }
623 |         }
624 | 
625 |         if (trashBytes > 0) {
626 |           categories.push({
627 |             name: "Trash Files",
628 |             description: "Deleted notebooklm files in system trash",
629 |             paths: trashPaths,
630 |             totalBytes: trashBytes,
631 |             optional: true,
632 |           });
633 |           totalSizeBytes += trashBytes;
634 |         }
635 |       }
636 |     }
637 | 
638 |     return {
639 |       categories,
640 |       totalPaths: Array.from(allPaths),
641 |       totalSizeBytes,
642 |     };
643 |   }
644 | 
645 |   /**
646 |    * Perform cleanup with safety checks and detailed reporting
647 |    */
648 |   async performCleanup(
649 |     mode: CleanupMode,
650 |     preserveLibrary: boolean = false
651 |   ): Promise<CleanupResult> {
652 |     log.info(`🧹 Starting cleanup in "${mode}" mode...`);
653 |     if (preserveLibrary) {
654 |       log.info(`📚 Library preservation enabled - library.json will be kept!`);
655 |     }
656 | 
657 |     const { categories, totalSizeBytes } = await this.getCleanupPaths(mode, preserveLibrary);
658 |     const deletedPaths: string[] = [];
659 |     const failedPaths: string[] = [];
660 |     const categorySummary: Record<string, { count: number; bytes: number }> = {};
661 | 
662 |     // Delete by category
663 |     for (const category of categories) {
664 |       log.info(`\n📦 ${category.name} (${category.paths.length} items, ${this.formatBytes(category.totalBytes)})`);
665 | 
666 |       if (category.optional) {
667 |         log.warning(`  ⚠️  Optional category - ${category.description}`);
668 |       }
669 | 
670 |       let categoryDeleted = 0;
671 |       let categoryBytes = 0;
672 | 
673 |       for (const itemPath of category.paths) {
674 |         try {
675 |           if (await this.pathExists(itemPath)) {
676 |             const size = await this.getDirectorySize(itemPath);
677 |             log.info(`  🗑️  Deleting: ${itemPath}`);
678 |             await fs.rm(itemPath, { recursive: true, force: true });
679 |             deletedPaths.push(itemPath);
680 |             categoryDeleted++;
681 |             categoryBytes += size;
682 |             log.success(`  ✅ Deleted: ${itemPath} (${this.formatBytes(size)})`);
683 |           }
684 |         } catch (error) {
685 |           log.error(`  ❌ Failed to delete: ${itemPath} - ${error}`);
686 |           failedPaths.push(itemPath);
687 |         }
688 |       }
689 | 
690 |       categorySummary[category.name] = {
691 |         count: categoryDeleted,
692 |         bytes: categoryBytes,
693 |       };
694 |     }
695 | 
696 |     const success = failedPaths.length === 0;
697 | 
698 |     if (success) {
699 |       log.success(`\n✅ Cleanup complete! Deleted ${deletedPaths.length} items (${this.formatBytes(totalSizeBytes)})`);
700 |     } else {
701 |       log.warning(`\n⚠️  Cleanup completed with ${failedPaths.length} errors`);
702 |       log.success(`  Deleted: ${deletedPaths.length} items`);
703 |       log.error(`  Failed: ${failedPaths.length} items`);
704 |     }
705 | 
706 |     return {
707 |       success,
708 |       mode,
709 |       deletedPaths,
710 |       failedPaths,
711 |       totalSizeBytes,
712 |       categorySummary,
713 |     };
714 |   }
715 | 
716 |   // ============================================================================
717 |   // Helper Methods
718 |   // ============================================================================
719 | 
720 |   /**
721 |    * Check if a path exists
722 |    */
723 |   private async pathExists(dirPath: string): Promise<boolean> {
724 |     try {
725 |       await fs.access(dirPath);
726 |       return true;
727 |     } catch {
728 |       return false;
729 |     }
730 |   }
731 | 
732 |   /**
733 |    * Get the size of a single file
734 |    */
735 |   private async getFileSize(filePath: string): Promise<number> {
736 |     try {
737 |       const stats = await fs.stat(filePath);
738 |       return stats.size;
739 |     } catch {
740 |       return 0;
741 |     }
742 |   }
743 | 
744 |   /**
745 |    * Get the total size of a directory (recursive)
746 |    */
747 |   private async getDirectorySize(dirPath: string): Promise<number> {
748 |     try {
749 |       const stats = await fs.stat(dirPath);
750 |       if (!stats.isDirectory()) {
751 |         return stats.size;
752 |       }
753 | 
754 |       let totalSize = 0;
755 |       const files = await fs.readdir(dirPath);
756 | 
757 |       for (const file of files) {
758 |         const filePath = path.join(dirPath, file);
759 |         const fileStats = await fs.stat(filePath);
760 | 
761 |         if (fileStats.isDirectory()) {
762 |           totalSize += await this.getDirectorySize(filePath);
763 |         } else {
764 |           totalSize += fileStats.size;
765 |         }
766 |       }
767 | 
768 |       return totalSize;
769 |     } catch {
770 |       return 0;
771 |     }
772 |   }
773 | 
774 |   /**
775 |    * Format bytes to human-readable string
776 |    */
777 |   formatBytes(bytes: number): string {
778 |     if (bytes === 0) return "0 Bytes";
779 |     const k = 1024;
780 |     const sizes = ["Bytes", "KB", "MB", "GB"];
781 |     const i = Math.floor(Math.log(bytes) / Math.log(k));
782 |     return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
783 |   }
784 | 
785 |   /**
786 |    * Get platform-specific path info
787 |    */
788 |   getPlatformInfo(): {
789 |     platform: string;
790 |     legacyBasePath: string;
791 |     currentBasePath: string;
792 |     npmCachePath: string;
793 |     claudeCliCachePath: string;
794 |     claudeProjectsPath: string;
795 |   } {
796 |     const platform = process.platform;
797 |     let platformName = "Unknown";
798 | 
799 |     switch (platform) {
800 |       case "win32":
801 |         platformName = "Windows";
802 |         break;
803 |       case "darwin":
804 |         platformName = "macOS";
805 |         break;
806 |       case "linux":
807 |         platformName = "Linux";
808 |         break;
809 |     }
810 | 
811 |     return {
812 |       platform: platformName,
813 |       legacyBasePath: this.legacyPaths.data,
814 |       currentBasePath: this.currentPaths.data,
815 |       npmCachePath: this.getNpmCachePath(),
816 |       claudeCliCachePath: this.getClaudeCliCachePath(),
817 |       claudeProjectsPath: this.getClaudeProjectsPath(),
818 |     };
819 |   }
820 | }
821 | 
```

--------------------------------------------------------------------------------
/src/tools/handlers.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * MCP Tool Handlers
  3 |  *
  4 |  * Implements the logic for all MCP tools.
  5 |  */
  6 | 
  7 | import { SessionManager } from "../session/session-manager.js";
  8 | import { AuthManager } from "../auth/auth-manager.js";
  9 | import { NotebookLibrary } from "../library/notebook-library.js";
 10 | import type { AddNotebookInput, UpdateNotebookInput } from "../library/types.js";
 11 | import { CONFIG, applyBrowserOptions, type BrowserOptions } from "../config.js";
 12 | import { log } from "../utils/logger.js";
 13 | import type {
 14 |   AskQuestionResult,
 15 |   ToolResult,
 16 |   ProgressCallback,
 17 | } from "../types.js";
 18 | import { RateLimitError } from "../errors.js";
 19 | import { CleanupManager } from "../utils/cleanup-manager.js";
 20 | 
 21 | const FOLLOW_UP_REMINDER =
 22 |   "\n\nEXTREMELY IMPORTANT: Is that ALL you need to know? You can always ask another question using the same session ID! Think about it carefully: before you reply to the user, review their original request and this answer. If anything is still unclear or missing, ask me another question first.";
 23 | 
 24 | /**
 25 |  * MCP Tool Handlers
 26 |  */
 27 | export class ToolHandlers {
 28 |   private sessionManager: SessionManager;
 29 |   private authManager: AuthManager;
 30 |   private library: NotebookLibrary;
 31 | 
 32 |   constructor(sessionManager: SessionManager, authManager: AuthManager, library: NotebookLibrary) {
 33 |     this.sessionManager = sessionManager;
 34 |     this.authManager = authManager;
 35 |     this.library = library;
 36 |   }
 37 | 
 38 |   /**
 39 |    * Handle ask_question tool
 40 |    */
 41 |   async handleAskQuestion(
 42 |     args: {
 43 |       question: string;
 44 |       session_id?: string;
 45 |       notebook_id?: string;
 46 |       notebook_url?: string;
 47 |       show_browser?: boolean;
 48 |       browser_options?: BrowserOptions;
 49 |     },
 50 |     sendProgress?: ProgressCallback
 51 |   ): Promise<ToolResult<AskQuestionResult>> {
 52 |     const { question, session_id, notebook_id, notebook_url, show_browser, browser_options } = args;
 53 | 
 54 |     log.info(`🔧 [TOOL] ask_question called`);
 55 |     log.info(`  Question: "${question.substring(0, 100)}"...`);
 56 |     if (session_id) {
 57 |       log.info(`  Session ID: ${session_id}`);
 58 |     }
 59 |     if (notebook_id) {
 60 |       log.info(`  Notebook ID: ${notebook_id}`);
 61 |     }
 62 |     if (notebook_url) {
 63 |       log.info(`  Notebook URL: ${notebook_url}`);
 64 |     }
 65 | 
 66 |     try {
 67 |       // Resolve notebook URL
 68 |       let resolvedNotebookUrl = notebook_url;
 69 | 
 70 |       if (!resolvedNotebookUrl && notebook_id) {
 71 |         const notebook = this.library.incrementUseCount(notebook_id);
 72 |         if (!notebook) {
 73 |           throw new Error(`Notebook not found in library: ${notebook_id}`);
 74 |         }
 75 | 
 76 |         resolvedNotebookUrl = notebook.url;
 77 |         log.info(`  Resolved notebook: ${notebook.name}`);
 78 |       } else if (!resolvedNotebookUrl) {
 79 |         const active = this.library.getActiveNotebook();
 80 |         if (active) {
 81 |           const notebook = this.library.incrementUseCount(active.id);
 82 |           if (!notebook) {
 83 |             throw new Error(`Active notebook not found: ${active.id}`);
 84 |           }
 85 |           resolvedNotebookUrl = notebook.url;
 86 |           log.info(`  Using active notebook: ${notebook.name}`);
 87 |         }
 88 |       }
 89 | 
 90 |       // Progress: Getting or creating session
 91 |       await sendProgress?.("Getting or creating browser session...", 1, 5);
 92 | 
 93 |       // Apply browser options temporarily
 94 |       const originalConfig = { ...CONFIG };
 95 |       const effectiveConfig = applyBrowserOptions(browser_options, show_browser);
 96 |       Object.assign(CONFIG, effectiveConfig);
 97 | 
 98 |       // Calculate overrideHeadless parameter for session manager
 99 |       // show_browser takes precedence over browser_options.headless
100 |       let overrideHeadless: boolean | undefined = undefined;
101 |       if (show_browser !== undefined) {
102 |         overrideHeadless = show_browser;
103 |       } else if (browser_options?.show !== undefined) {
104 |         overrideHeadless = browser_options.show;
105 |       } else if (browser_options?.headless !== undefined) {
106 |         overrideHeadless = !browser_options.headless;
107 |       }
108 | 
109 |       try {
110 |         // Get or create session (with headless override to handle mode changes)
111 |         const session = await this.sessionManager.getOrCreateSession(
112 |           session_id,
113 |           resolvedNotebookUrl,
114 |           overrideHeadless
115 |         );
116 | 
117 |       // Progress: Asking question
118 |       await sendProgress?.("Asking question to NotebookLM...", 2, 5);
119 | 
120 |       // Ask the question (pass progress callback)
121 |       const rawAnswer = await session.ask(question, sendProgress);
122 |       const answer = `${rawAnswer.trimEnd()}${FOLLOW_UP_REMINDER}`;
123 | 
124 |       // Get session info
125 |       const sessionInfo = session.getInfo();
126 | 
127 |       const result: AskQuestionResult = {
128 |         status: "success",
129 |         question,
130 |         answer,
131 |         session_id: session.sessionId,
132 |         notebook_url: session.notebookUrl,
133 |         session_info: {
134 |           age_seconds: sessionInfo.age_seconds,
135 |           message_count: sessionInfo.message_count,
136 |           last_activity: sessionInfo.last_activity,
137 |         },
138 |       };
139 | 
140 |         // Progress: Complete
141 |         await sendProgress?.("Question answered successfully!", 5, 5);
142 | 
143 |         log.success(`✅ [TOOL] ask_question completed successfully`);
144 |         return {
145 |           success: true,
146 |           data: result,
147 |         };
148 |       } finally {
149 |         // Restore original CONFIG
150 |         Object.assign(CONFIG, originalConfig);
151 |       }
152 |     } catch (error) {
153 |       const errorMessage =
154 |         error instanceof Error ? error.message : String(error);
155 | 
156 |       // Special handling for rate limit errors
157 |       if (error instanceof RateLimitError || errorMessage.toLowerCase().includes("rate limit")) {
158 |         log.error(`🚫 [TOOL] Rate limit detected`);
159 |         return {
160 |           success: false,
161 |           error:
162 |             "NotebookLM rate limit reached (50 queries/day for free accounts).\n\n" +
163 |             "You can:\n" +
164 |             "1. Use the 're_auth' tool to login with a different Google account\n" +
165 |             "2. Wait until tomorrow for the quota to reset\n" +
166 |             "3. Upgrade to Google AI Pro/Ultra for 5x higher limits\n\n" +
167 |             `Original error: ${errorMessage}`,
168 |         };
169 |       }
170 | 
171 |       log.error(`❌ [TOOL] ask_question failed: ${errorMessage}`);
172 |       return {
173 |         success: false,
174 |         error: errorMessage,
175 |       };
176 |     }
177 |   }
178 | 
179 |   /**
180 |    * Handle list_sessions tool
181 |    */
182 |   async handleListSessions(): Promise<
183 |     ToolResult<{
184 |       active_sessions: number;
185 |       max_sessions: number;
186 |       session_timeout: number;
187 |       oldest_session_seconds: number;
188 |       total_messages: number;
189 |       sessions: Array<{
190 |         id: string;
191 |         created_at: number;
192 |         last_activity: number;
193 |         age_seconds: number;
194 |         inactive_seconds: number;
195 |         message_count: number;
196 |         notebook_url: string;
197 |       }>;
198 |     }> 
199 |   > {
200 |     log.info(`🔧 [TOOL] list_sessions called`);
201 | 
202 |     try {
203 |       const stats = this.sessionManager.getStats();
204 |       const sessions = this.sessionManager.getAllSessionsInfo();
205 | 
206 |       const result = {
207 |         active_sessions: stats.active_sessions,
208 |         max_sessions: stats.max_sessions,
209 |         session_timeout: stats.session_timeout,
210 |         oldest_session_seconds: stats.oldest_session_seconds,
211 |         total_messages: stats.total_messages,
212 |         sessions: sessions.map((info) => ({
213 |           id: info.id,
214 |           created_at: info.created_at,
215 |           last_activity: info.last_activity,
216 |           age_seconds: info.age_seconds,
217 |           inactive_seconds: info.inactive_seconds,
218 |           message_count: info.message_count,
219 |           notebook_url: info.notebook_url,
220 |         })),
221 |       };
222 | 
223 |       log.success(
224 |         `✅ [TOOL] list_sessions completed (${result.active_sessions} sessions)`
225 |       );
226 |       return {
227 |         success: true,
228 |         data: result,
229 |       };
230 |     } catch (error) {
231 |       const errorMessage =
232 |         error instanceof Error ? error.message : String(error);
233 |       log.error(`❌ [TOOL] list_sessions failed: ${errorMessage}`);
234 |       return {
235 |         success: false,
236 |         error: errorMessage,
237 |       };
238 |     }
239 |   }
240 | 
241 |   /**
242 |    * Handle close_session tool
243 |    */
244 |   async handleCloseSession(args: { session_id: string }): Promise<
245 |     ToolResult<{ status: string; message: string; session_id: string }>
246 |   > {
247 |     const { session_id } = args;
248 | 
249 |     log.info(`🔧 [TOOL] close_session called`);
250 |     log.info(`  Session ID: ${session_id}`);
251 | 
252 |     try {
253 |       const closed = await this.sessionManager.closeSession(session_id);
254 | 
255 |       if (closed) {
256 |         log.success(`✅ [TOOL] close_session completed`);
257 |         return {
258 |           success: true,
259 |           data: {
260 |             status: "success",
261 |             message: `Session ${session_id} closed successfully`,
262 |             session_id,
263 |           },
264 |         };
265 |       } else {
266 |         log.warning(`⚠️  [TOOL] Session ${session_id} not found`);
267 |         return {
268 |           success: false,
269 |           error: `Session ${session_id} not found`,
270 |         };
271 |       }
272 |     } catch (error) {
273 |       const errorMessage =
274 |         error instanceof Error ? error.message : String(error);
275 |       log.error(`❌ [TOOL] close_session failed: ${errorMessage}`);
276 |       return {
277 |         success: false,
278 |         error: errorMessage,
279 |       };
280 |     }
281 |   }
282 | 
283 |   /**
284 |    * Handle reset_session tool
285 |    */
286 |   async handleResetSession(args: { session_id: string }): Promise<
287 |     ToolResult<{ status: string; message: string; session_id: string }>
288 |   > {
289 |     const { session_id } = args;
290 | 
291 |     log.info(`🔧 [TOOL] reset_session called`);
292 |     log.info(`  Session ID: ${session_id}`);
293 | 
294 |     try {
295 |       const session = this.sessionManager.getSession(session_id);
296 | 
297 |       if (!session) {
298 |         log.warning(`⚠️  [TOOL] Session ${session_id} not found`);
299 |         return {
300 |           success: false,
301 |           error: `Session ${session_id} not found`,
302 |         };
303 |       }
304 | 
305 |       await session.reset();
306 | 
307 |       log.success(`✅ [TOOL] reset_session completed`);
308 |       return {
309 |         success: true,
310 |         data: {
311 |           status: "success",
312 |           message: `Session ${session_id} reset successfully`,
313 |           session_id,
314 |         },
315 |       };
316 |     } catch (error) {
317 |       const errorMessage =
318 |         error instanceof Error ? error.message : String(error);
319 |       log.error(`❌ [TOOL] reset_session failed: ${errorMessage}`);
320 |       return {
321 |         success: false,
322 |         error: errorMessage,
323 |       };
324 |     }
325 |   }
326 | 
327 |   /**
328 |    * Handle get_health tool
329 |    */
330 |   async handleGetHealth(): Promise<
331 |     ToolResult<{
332 |       status: string;
333 |       authenticated: boolean;
334 |       notebook_url: string;
335 |       active_sessions: number;
336 |       max_sessions: number;
337 |       session_timeout: number;
338 |       total_messages: number;
339 |       headless: boolean;
340 |       auto_login_enabled: boolean;
341 |       stealth_enabled: boolean;
342 |       troubleshooting_tip?: string;
343 |     }> 
344 |   > {
345 |     log.info(`🔧 [TOOL] get_health called`);
346 | 
347 |     try {
348 |       // Check authentication status
349 |       const statePath = await this.authManager.getValidStatePath();
350 |       const authenticated = statePath !== null;
351 | 
352 |       // Get session stats
353 |       const stats = this.sessionManager.getStats();
354 | 
355 |       const result = {
356 |         status: "ok",
357 |         authenticated,
358 |         notebook_url: CONFIG.notebookUrl || "not configured",
359 |         active_sessions: stats.active_sessions,
360 |         max_sessions: stats.max_sessions,
361 |         session_timeout: stats.session_timeout,
362 |         total_messages: stats.total_messages,
363 |         headless: CONFIG.headless,
364 |         auto_login_enabled: CONFIG.autoLoginEnabled,
365 |         stealth_enabled: CONFIG.stealthEnabled,
366 |         // Add troubleshooting tip if not authenticated
367 |         ...((!authenticated) && {
368 |           troubleshooting_tip:
369 |             "For fresh start with clean browser session: Close all Chrome instances → " +
370 |             "cleanup_data(confirm=true, preserve_library=true) → setup_auth"
371 |         }),
372 |       };
373 | 
374 |       log.success(`✅ [TOOL] get_health completed`);
375 |       return {
376 |         success: true,
377 |         data: result,
378 |       };
379 |     } catch (error) {
380 |       const errorMessage =
381 |         error instanceof Error ? error.message : String(error);
382 |       log.error(`❌ [TOOL] get_health failed: ${errorMessage}`);
383 |       return {
384 |         success: false,
385 |         error: errorMessage,
386 |       };
387 |     }
388 |   }
389 | 
390 |   /**
391 |    * Handle setup_auth tool
392 |    *
393 |    * Opens a browser window for manual login with live progress updates.
394 |    * The operation waits synchronously for login completion (up to 10 minutes).
395 |    */
396 |   async handleSetupAuth(
397 |     args: {
398 |       show_browser?: boolean;
399 |       browser_options?: BrowserOptions;
400 |     },
401 |     sendProgress?: ProgressCallback
402 |   ): Promise<
403 |     ToolResult<{
404 |       status: string;
405 |       message: string;
406 |       authenticated: boolean;
407 |       duration_seconds?: number;
408 |     }> 
409 |   > {
410 |     const { show_browser, browser_options } = args;
411 | 
412 |     // CRITICAL: Send immediate progress to reset timeout from the very start
413 |     await sendProgress?.("Initializing authentication setup...", 0, 10);
414 | 
415 |     log.info(`🔧 [TOOL] setup_auth called`);
416 |     if (show_browser !== undefined) {
417 |       log.info(`  Show browser: ${show_browser}`);
418 |     }
419 | 
420 |     const startTime = Date.now();
421 | 
422 |     // Apply browser options temporarily
423 |     const originalConfig = { ...CONFIG };
424 |     const effectiveConfig = applyBrowserOptions(browser_options, show_browser);
425 |     Object.assign(CONFIG, effectiveConfig);
426 | 
427 |     try {
428 |       // Progress: Starting
429 |       await sendProgress?.("Preparing authentication browser...", 1, 10);
430 | 
431 |       log.info(`  🌐 Opening browser for interactive login...`);
432 | 
433 |       // Progress: Opening browser
434 |       await sendProgress?.("Opening browser window...", 2, 10);
435 | 
436 |       // Perform setup with progress updates (uses CONFIG internally)
437 |       const success = await this.authManager.performSetup(sendProgress);
438 | 
439 |       const durationSeconds = (Date.now() - startTime) / 1000;
440 | 
441 |       if (success) {
442 |         // Progress: Complete
443 |         await sendProgress?.("Authentication saved successfully!", 10, 10);
444 | 
445 |         log.success(`✅ [TOOL] setup_auth completed (${durationSeconds.toFixed(1)}s)`);
446 |         return {
447 |           success: true,
448 |           data: {
449 |             status: "authenticated",
450 |             message: "Successfully authenticated and saved browser state",
451 |             authenticated: true,
452 |             duration_seconds: durationSeconds,
453 |           },
454 |         };
455 |       } else {
456 |         log.error(`❌ [TOOL] setup_auth failed (${durationSeconds.toFixed(1)}s)`);
457 |         return {
458 |           success: false,
459 |           error: "Authentication failed or was cancelled",
460 |         };
461 |       }
462 |     } catch (error) {
463 |       const errorMessage =
464 |         error instanceof Error ? error.message : String(error);
465 |       const durationSeconds = (Date.now() - startTime) / 1000;
466 |       log.error(`❌ [TOOL] setup_auth failed: ${errorMessage} (${durationSeconds.toFixed(1)}s)`);
467 |       return {
468 |         success: false,
469 |         error: errorMessage,
470 |       };
471 |     } finally {
472 |       // Restore original CONFIG
473 |       Object.assign(CONFIG, originalConfig);
474 |     }
475 |   }
476 | 
477 |   /**
478 |    * Handle re_auth tool
479 |    *
480 |    * Performs a complete re-authentication:
481 |    * 1. Closes all active browser sessions
482 |    * 2. Deletes all saved authentication data (cookies, Chrome profile)
483 |    * 3. Opens browser for fresh Google login
484 |    *
485 |    * Use for switching Google accounts or recovering from rate limits.
486 |    */
487 |   async handleReAuth(
488 |     args: {
489 |       show_browser?: boolean;
490 |       browser_options?: BrowserOptions;
491 |     },
492 |     sendProgress?: ProgressCallback
493 |   ): Promise<
494 |     ToolResult<{
495 |       status: string;
496 |       message: string;
497 |       authenticated: boolean;
498 |       duration_seconds?: number;
499 |     }> 
500 |   > {
501 |     const { show_browser, browser_options } = args;
502 | 
503 |     await sendProgress?.("Preparing re-authentication...", 0, 12);
504 |     log.info(`🔧 [TOOL] re_auth called`);
505 |     if (show_browser !== undefined) {
506 |       log.info(`  Show browser: ${show_browser}`);
507 |     }
508 | 
509 |     const startTime = Date.now();
510 | 
511 |     // Apply browser options temporarily
512 |     const originalConfig = { ...CONFIG };
513 |     const effectiveConfig = applyBrowserOptions(browser_options, show_browser);
514 |     Object.assign(CONFIG, effectiveConfig);
515 | 
516 |     try {
517 |       // 1. Close all active sessions
518 |       await sendProgress?.("Closing all active sessions...", 1, 12);
519 |       log.info("  🛑 Closing all sessions...");
520 |       await this.sessionManager.closeAllSessions();
521 |       log.success("  ✅ All sessions closed");
522 | 
523 |       // 2. Clear all auth data
524 |       await sendProgress?.("Clearing authentication data...", 2, 12);
525 |       log.info("  🗑️  Clearing all auth data...");
526 |       await this.authManager.clearAllAuthData();
527 |       log.success("  ✅ Auth data cleared");
528 | 
529 |       // 3. Perform fresh setup
530 |       await sendProgress?.("Starting fresh authentication...", 3, 12);
531 |       log.info("  🌐 Starting fresh authentication setup...");
532 |       const success = await this.authManager.performSetup(sendProgress);
533 | 
534 |       const durationSeconds = (Date.now() - startTime) / 1000;
535 | 
536 |       if (success) {
537 |         await sendProgress?.("Re-authentication complete!", 12, 12);
538 |         log.success(`✅ [TOOL] re_auth completed (${durationSeconds.toFixed(1)}s)`);
539 |         return {
540 |           success: true,
541 |           data: {
542 |             status: "authenticated",
543 |             message:
544 |               "Successfully re-authenticated with new account. All previous sessions have been closed.",
545 |             authenticated: true,
546 |             duration_seconds: durationSeconds,
547 |           },
548 |         };
549 |       } else {
550 |         log.error(`❌ [TOOL] re_auth failed (${durationSeconds.toFixed(1)}s)`);
551 |         return {
552 |           success: false,
553 |           error: "Re-authentication failed or was cancelled",
554 |         };
555 |       }
556 |     } catch (error) {
557 |       const errorMessage = error instanceof Error ? error.message : String(error);
558 |       const durationSeconds = (Date.now() - startTime) / 1000;
559 |       log.error(
560 |         `❌ [TOOL] re_auth failed: ${errorMessage} (${durationSeconds.toFixed(1)}s)`
561 |       );
562 |       return {
563 |         success: false,
564 |         error: errorMessage,
565 |       };
566 |     } finally {
567 |       // Restore original CONFIG
568 |       Object.assign(CONFIG, originalConfig);
569 |     }
570 |   }
571 | 
572 |   /**
573 |    * Handle add_notebook tool
574 |    */
575 |   async handleAddNotebook(args: AddNotebookInput): Promise<ToolResult<{ notebook: any }>> {
576 |     log.info(`🔧 [TOOL] add_notebook called`);
577 |     log.info(`  Name: ${args.name}`);
578 | 
579 |     try {
580 |       const notebook = this.library.addNotebook(args);
581 |       log.success(`✅ [TOOL] add_notebook completed: ${notebook.id}`);
582 |       return {
583 |         success: true,
584 |         data: { notebook },
585 |       };
586 |     } catch (error) {
587 |       const errorMessage = error instanceof Error ? error.message : String(error);
588 |       log.error(`❌ [TOOL] add_notebook failed: ${errorMessage}`);
589 |       return {
590 |         success: false,
591 |         error: errorMessage,
592 |       };
593 |     }
594 |   }
595 | 
596 |   /**
597 |    * Handle list_notebooks tool
598 |    */
599 |   async handleListNotebooks(): Promise<ToolResult<{ notebooks: any[] }>> {
600 |     log.info(`🔧 [TOOL] list_notebooks called`);
601 | 
602 |     try {
603 |       const notebooks = this.library.listNotebooks();
604 |       log.success(`✅ [TOOL] list_notebooks completed (${notebooks.length} notebooks)`);
605 |       return {
606 |         success: true,
607 |         data: { notebooks },
608 |       };
609 |     } catch (error) {
610 |       const errorMessage = error instanceof Error ? error.message : String(error);
611 |       log.error(`❌ [TOOL] list_notebooks failed: ${errorMessage}`);
612 |       return {
613 |         success: false,
614 |         error: errorMessage,
615 |       };
616 |     }
617 |   }
618 | 
619 |   /**
620 |    * Handle get_notebook tool
621 |    */
622 |   async handleGetNotebook(args: { id: string }): Promise<ToolResult<{ notebook: any }>> {
623 |     log.info(`🔧 [TOOL] get_notebook called`);
624 |     log.info(`  ID: ${args.id}`);
625 | 
626 |     try {
627 |       const notebook = this.library.getNotebook(args.id);
628 |       if (!notebook) {
629 |         log.warning(`⚠️  [TOOL] Notebook not found: ${args.id}`);
630 |         return {
631 |           success: false,
632 |           error: `Notebook not found: ${args.id}`,
633 |         };
634 |       }
635 | 
636 |       log.success(`✅ [TOOL] get_notebook completed: ${notebook.name}`);
637 |       return {
638 |         success: true,
639 |         data: { notebook },
640 |       };
641 |     } catch (error) {
642 |       const errorMessage = error instanceof Error ? error.message : String(error);
643 |       log.error(`❌ [TOOL] get_notebook failed: ${errorMessage}`);
644 |       return {
645 |         success: false,
646 |         error: errorMessage,
647 |       };
648 |     }
649 |   }
650 | 
651 |   /**
652 |    * Handle select_notebook tool
653 |    */
654 |   async handleSelectNotebook(args: { id: string }): Promise<ToolResult<{ notebook: any }>> {
655 |     log.info(`🔧 [TOOL] select_notebook called`);
656 |     log.info(`  ID: ${args.id}`);
657 | 
658 |     try {
659 |       const notebook = this.library.selectNotebook(args.id);
660 |       log.success(`✅ [TOOL] select_notebook completed: ${notebook.name}`);
661 |       return {
662 |         success: true,
663 |         data: { notebook },
664 |       };
665 |     } catch (error) {
666 |       const errorMessage = error instanceof Error ? error.message : String(error);
667 |       log.error(`❌ [TOOL] select_notebook failed: ${errorMessage}`);
668 |       return {
669 |         success: false,
670 |         error: errorMessage,
671 |       };
672 |     }
673 |   }
674 | 
675 |   /**
676 |    * Handle update_notebook tool
677 |    */
678 |   async handleUpdateNotebook(args: UpdateNotebookInput): Promise<ToolResult<{ notebook: any }>> {
679 |     log.info(`🔧 [TOOL] update_notebook called`);
680 |     log.info(`  ID: ${args.id}`);
681 | 
682 |     try {
683 |       const notebook = this.library.updateNotebook(args);
684 |       log.success(`✅ [TOOL] update_notebook completed: ${notebook.name}`);
685 |       return {
686 |         success: true,
687 |         data: { notebook },
688 |       };
689 |     } catch (error) {
690 |       const errorMessage = error instanceof Error ? error.message : String(error);
691 |       log.error(`❌ [TOOL] update_notebook failed: ${errorMessage}`);
692 |       return {
693 |         success: false,
694 |         error: errorMessage,
695 |       };
696 |     }
697 |   }
698 | 
699 |   /**
700 |    * Handle remove_notebook tool
701 |    */
702 |   async handleRemoveNotebook(args: { id: string }): Promise<ToolResult<{ removed: boolean; closed_sessions: number }>> {
703 |     log.info(`🔧 [TOOL] remove_notebook called`);
704 |     log.info(`  ID: ${args.id}`);
705 | 
706 |     try {
707 |       const notebook = this.library.getNotebook(args.id);
708 |       if (!notebook) {
709 |         log.warning(`⚠️  [TOOL] Notebook not found: ${args.id}`);
710 |         return {
711 |           success: false,
712 |           error: `Notebook not found: ${args.id}`,
713 |         };
714 |       }
715 | 
716 |       const removed = this.library.removeNotebook(args.id);
717 |       if (removed) {
718 |         const closedSessions = await this.sessionManager.closeSessionsForNotebook(
719 |           notebook.url
720 |         );
721 |         log.success(`✅ [TOOL] remove_notebook completed`);
722 |         return {
723 |           success: true,
724 |           data: { removed: true, closed_sessions: closedSessions },
725 |         };
726 |       } else {
727 |         log.warning(`⚠️  [TOOL] Notebook not found: ${args.id}`);
728 |         return {
729 |           success: false,
730 |           error: `Notebook not found: ${args.id}`,
731 |         };
732 |       }
733 |     } catch (error) {
734 |       const errorMessage = error instanceof Error ? error.message : String(error);
735 |       log.error(`❌ [TOOL] remove_notebook failed: ${errorMessage}`);
736 |       return {
737 |         success: false,
738 |         error: errorMessage,
739 |       };
740 |     }
741 |   }
742 | 
743 |   /**
744 |    * Handle search_notebooks tool
745 |    */
746 |   async handleSearchNotebooks(args: { query: string }): Promise<ToolResult<{ notebooks: any[] }>> {
747 |     log.info(`🔧 [TOOL] search_notebooks called`);
748 |     log.info(`  Query: "${args.query}"`);
749 | 
750 |     try {
751 |       const notebooks = this.library.searchNotebooks(args.query);
752 |       log.success(`✅ [TOOL] search_notebooks completed (${notebooks.length} results)`);
753 |       return {
754 |         success: true,
755 |         data: { notebooks },
756 |       };
757 |     } catch (error) {
758 |       const errorMessage = error instanceof Error ? error.message : String(error);
759 |       log.error(`❌ [TOOL] search_notebooks failed: ${errorMessage}`);
760 |       return {
761 |         success: false,
762 |         error: errorMessage,
763 |       };
764 |     }
765 |   }
766 | 
767 |   /**
768 |    * Handle get_library_stats tool
769 |    */
770 |   async handleGetLibraryStats(): Promise<ToolResult<any>> {
771 |     log.info(`🔧 [TOOL] get_library_stats called`);
772 | 
773 |     try {
774 |       const stats = this.library.getStats();
775 |       log.success(`✅ [TOOL] get_library_stats completed`);
776 |       return {
777 |         success: true,
778 |         data: stats,
779 |       };
780 |     } catch (error) {
781 |       const errorMessage = error instanceof Error ? error.message : String(error);
782 |       log.error(`❌ [TOOL] get_library_stats failed: ${errorMessage}`);
783 |       return {
784 |         success: false,
785 |         error: errorMessage,
786 |       };
787 |     }
788 |   }
789 | 
790 |   /**
791 |    * Handle cleanup_data tool
792 |    *
793 |    * ULTRATHINK Deep Cleanup - scans entire system for ALL NotebookLM MCP files
794 |    */
795 |   async handleCleanupData(
796 |     args: { confirm: boolean; preserve_library?: boolean }
797 |   ): Promise<
798 |     ToolResult<{
799 |       status: string;
800 |       mode: string;
801 |       preview?: {
802 |         categories: Array<{ name: string; description: string; paths: string[]; totalBytes: number; optional: boolean }>;
803 |         totalPaths: number;
804 |         totalSizeBytes: number;
805 |       };
806 |       result?: {
807 |         deletedPaths: string[];
808 |         failedPaths: string[];
809 |         totalSizeBytes: number;
810 |         categorySummary: Record<string, { count: number; bytes: number }>;
811 |       };
812 |     }> 
813 |   > {
814 |     const { confirm, preserve_library = false } = args;
815 | 
816 |     log.info(`🔧 [TOOL] cleanup_data called`);
817 |     log.info(`  Confirm: ${confirm}`);
818 |     log.info(`  Preserve Library: ${preserve_library}`);
819 | 
820 |     const cleanupManager = new CleanupManager();
821 | 
822 |     try {
823 |       // Always run in deep mode
824 |       const mode = "deep";
825 | 
826 |       if (!confirm) {
827 |         // Preview mode - show what would be deleted
828 |         log.info(`  📋 Generating cleanup preview (mode: ${mode})...`);
829 | 
830 |         const preview = await cleanupManager.getCleanupPaths(mode, preserve_library);
831 |         const platformInfo = cleanupManager.getPlatformInfo();
832 | 
833 |         log.info(`  Found ${preview.totalPaths.length} items (${cleanupManager.formatBytes(preview.totalSizeBytes)})`);
834 |         log.info(`  Platform: ${platformInfo.platform}`);
835 | 
836 |         return {
837 |           success: true,
838 |           data: {
839 |             status: "preview",
840 |             mode,
841 |             preview: {
842 |               categories: preview.categories,
843 |               totalPaths: preview.totalPaths.length,
844 |               totalSizeBytes: preview.totalSizeBytes,
845 |             },
846 |           },
847 |         };
848 |       } else {
849 |         // Cleanup mode - actually delete files
850 |         log.info(`  🗑️  Performing cleanup (mode: ${mode})...`);
851 | 
852 |         const result = await cleanupManager.performCleanup(mode, preserve_library);
853 | 
854 |         if (result.success) {
855 |           log.success(`✅ [TOOL] cleanup_data completed - deleted ${result.deletedPaths.length} items`);
856 |         } else {
857 |           log.warning(`⚠️  [TOOL] cleanup_data completed with ${result.failedPaths.length} errors`);
858 |         }
859 | 
860 |         return {
861 |           success: result.success,
862 |           data: {
863 |             status: result.success ? "completed" : "partial",
864 |             mode,
865 |             result: {
866 |               deletedPaths: result.deletedPaths,
867 |               failedPaths: result.failedPaths,
868 |               totalSizeBytes: result.totalSizeBytes,
869 |               categorySummary: result.categorySummary,
870 |             },
871 |           },
872 |         };
873 |       }
874 |     } catch (error) {
875 |       const errorMessage = error instanceof Error ? error.message : String(error);
876 |       log.error(`❌ [TOOL] cleanup_data failed: ${errorMessage}`);
877 |       return {
878 |         success: false,
879 |         error: errorMessage,
880 |       };
881 |     }
882 |   }
883 | 
884 |   /**
885 |    * Cleanup all resources (called on server shutdown)
886 |    */
887 |   async cleanup(): Promise<void> {
888 |     log.info(`🧹 Cleaning up tool handlers...`);
889 |     await this.sessionManager.closeAllSessions();
890 |     log.success(`✅ Tool handlers cleanup complete`);
891 |   }
892 | }
893 | 
```

--------------------------------------------------------------------------------
/src/auth/auth-manager.ts:
--------------------------------------------------------------------------------

```typescript
   1 | /**
   2 |  * Authentication Manager for NotebookLM
   3 |  *
   4 |  * Handles:
   5 |  * - Interactive login (headful browser for setup)
   6 |  * - Auto-login with credentials (email/password from ENV)
   7 |  * - Browser state persistence (cookies + localStorage + sessionStorage)
   8 |  * - Cookie expiry validation
   9 |  * - State expiry checks (24h file age)
  10 |  * - Hard reset for clean start
  11 |  *
  12 |  * Based on the Python implementation from auth.py
  13 |  */
  14 | 
  15 | import type { BrowserContext, Page } from "patchright";
  16 | import fs from "fs/promises";
  17 | import { existsSync } from "fs";
  18 | import path from "path";
  19 | import { CONFIG, NOTEBOOKLM_AUTH_URL } from "../config.js";
  20 | import { log } from "../utils/logger.js";
  21 | import {
  22 |   humanType,
  23 |   randomDelay,
  24 |   realisticClick,
  25 |   randomMouseMovement,
  26 | } from "../utils/stealth-utils.js";
  27 | import type { ProgressCallback } from "../types.js";
  28 | 
  29 | /**
  30 |  * Critical cookie names for Google authentication
  31 |  */
  32 | const CRITICAL_COOKIE_NAMES = [
  33 |   "SID",
  34 |   "HSID",
  35 |   "SSID", // Google session
  36 |   "APISID",
  37 |   "SAPISID", // API auth
  38 |   "OSID",
  39 |   "__Secure-OSID", // NotebookLM-specific
  40 |   "__Secure-1PSID",
  41 |   "__Secure-3PSID", // Secure variants
  42 | ];
  43 | 
  44 | export class AuthManager {
  45 |   private stateFilePath: string;
  46 |   private sessionFilePath: string;
  47 | 
  48 |   constructor() {
  49 |     this.stateFilePath = path.join(CONFIG.browserStateDir, "state.json");
  50 |     this.sessionFilePath = path.join(CONFIG.browserStateDir, "session.json");
  51 |   }
  52 | 
  53 |   // ============================================================================
  54 |   // Browser State Management
  55 |   // ============================================================================
  56 | 
  57 |   /**
  58 |    * Save entire browser state (cookies + localStorage)
  59 |    */
  60 |   async saveBrowserState(context: BrowserContext, page?: Page): Promise<boolean> {
  61 |     try {
  62 |       // Save storage state (cookies + localStorage + IndexedDB)
  63 |       await context.storageState({ path: this.stateFilePath });
  64 | 
  65 |       // Also save sessionStorage if page is provided
  66 |       if (page) {
  67 |         try {
  68 |           const sessionStorageData: string = await page.evaluate((): string => {
  69 |             // Properly extract sessionStorage as a plain object
  70 |             const storage: Record<string, string> = {};
  71 |             // @ts-expect-error - sessionStorage exists in browser context
  72 |             for (let i = 0; i < sessionStorage.length; i++) {
  73 |               // @ts-expect-error - sessionStorage exists in browser context
  74 |               const key = sessionStorage.key(i);
  75 |               if (key) {
  76 |                 // @ts-expect-error - sessionStorage exists in browser context
  77 |                 storage[key] = sessionStorage.getItem(key) || '';
  78 |               }
  79 |             }
  80 |             return JSON.stringify(storage);
  81 |           });
  82 | 
  83 |           await fs.writeFile(this.sessionFilePath, sessionStorageData, {
  84 |             encoding: "utf-8",
  85 |           });
  86 | 
  87 |           const entries = Object.keys(JSON.parse(sessionStorageData)).length;
  88 |           log.success(`✅ Browser state saved (incl. sessionStorage: ${entries} entries)`);
  89 |         } catch (error) {
  90 |           log.warning(`⚠️  State saved, but sessionStorage failed: ${error}`);
  91 |         }
  92 |       } else {
  93 |         log.success("✅ Browser state saved");
  94 |       }
  95 | 
  96 |       return true;
  97 |     } catch (error) {
  98 |       log.error(`❌ Failed to save browser state: ${error}`);
  99 |       return false;
 100 |     }
 101 |   }
 102 | 
 103 |   /**
 104 |    * Check if saved browser state exists
 105 |    */
 106 |   async hasSavedState(): Promise<boolean> {
 107 |     try {
 108 |       await fs.access(this.stateFilePath);
 109 |       return true;
 110 |     } catch {
 111 |       return false;
 112 |     }
 113 |   }
 114 | 
 115 |   /**
 116 |    * Get path to saved browser state
 117 |    */
 118 |   getStatePath(): string | null {
 119 |     // Synchronous check using imported existsSync
 120 |     if (existsSync(this.stateFilePath)) {
 121 |       return this.stateFilePath;
 122 |     }
 123 |     return null;
 124 |   }
 125 | 
 126 |   /**
 127 |    * Get valid state path (checks expiry)
 128 |    */
 129 |   async getValidStatePath(): Promise<string | null> {
 130 |     const statePath = this.getStatePath();
 131 |     if (!statePath) {
 132 |       return null;
 133 |     }
 134 | 
 135 |     if (await this.isStateExpired()) {
 136 |       log.warning("⚠️  Saved state is expired (>24h old)");
 137 |       log.info("💡 Run setup_auth tool to re-authenticate");
 138 |       return null;
 139 |     }
 140 | 
 141 |     return statePath;
 142 |   }
 143 | 
 144 |   /**
 145 |    * Load sessionStorage from file
 146 |    */
 147 |   async loadSessionStorage(): Promise<Record<string, string> | null> {
 148 |     try {
 149 |       const data = await fs.readFile(this.sessionFilePath, { encoding: "utf-8" });
 150 |       const sessionData = JSON.parse(data);
 151 |       log.success(`✅ Loaded sessionStorage (${Object.keys(sessionData).length} entries)`);
 152 |       return sessionData;
 153 |     } catch (error) {
 154 |       log.warning(`⚠️  Failed to load sessionStorage: ${error}`);
 155 |       return null;
 156 |     }
 157 |   }
 158 | 
 159 |   // ============================================================================
 160 |   // Cookie Validation
 161 |   // ============================================================================
 162 | 
 163 |   /**
 164 |    * Validate if saved state is still valid
 165 |    */
 166 |   async validateState(context: BrowserContext): Promise<boolean> {
 167 |     try {
 168 |       const cookies = await context.cookies();
 169 |       if (cookies.length === 0) {
 170 |         log.warning("⚠️  No cookies found in state");
 171 |         return false;
 172 |       }
 173 | 
 174 |       // Check for Google auth cookies
 175 |       const googleCookies = cookies.filter((c) =>
 176 |         c.domain.includes("google.com")
 177 |       );
 178 |       if (googleCookies.length === 0) {
 179 |         log.warning("⚠️  No Google cookies found");
 180 |         return false;
 181 |       }
 182 | 
 183 |       // Check if important cookies are expired
 184 |       const currentTime = Date.now() / 1000;
 185 | 
 186 |       for (const cookie of googleCookies) {
 187 |         const expires = cookie.expires ?? -1;
 188 |         if (expires !== -1 && expires < currentTime) {
 189 |           log.warning(`⚠️  Cookie '${cookie.name}' has expired`);
 190 |           return false;
 191 |         }
 192 |       }
 193 | 
 194 |       log.success("✅ State validation passed");
 195 |       return true;
 196 |     } catch (error) {
 197 |       log.warning(`⚠️  State validation failed: ${error}`);
 198 |       return false;
 199 |     }
 200 |   }
 201 | 
 202 |   /**
 203 |    * Validate if critical authentication cookies are still valid
 204 |    */
 205 |   async validateCookiesExpiry(context: BrowserContext): Promise<boolean> {
 206 |     try {
 207 |       const cookies = await context.cookies();
 208 |       if (cookies.length === 0) {
 209 |         log.warning("⚠️  No cookies found");
 210 |         return false;
 211 |       }
 212 | 
 213 |       // Find critical cookies
 214 |       const criticalCookies = cookies.filter((c) =>
 215 |         CRITICAL_COOKIE_NAMES.includes(c.name)
 216 |       );
 217 | 
 218 |       if (criticalCookies.length === 0) {
 219 |         log.warning("⚠️  No critical auth cookies found");
 220 |         return false;
 221 |       }
 222 | 
 223 |       // Check expiration for each critical cookie
 224 |       const currentTime = Date.now() / 1000;
 225 |       const expiredCookies: string[] = [];
 226 | 
 227 |       for (const cookie of criticalCookies) {
 228 |         const expires = cookie.expires ?? -1;
 229 | 
 230 |         // -1 means session cookie (valid until browser closes)
 231 |         if (expires === -1) {
 232 |           continue;
 233 |         }
 234 | 
 235 |         // Check if cookie is expired
 236 |         if (expires < currentTime) {
 237 |           expiredCookies.push(cookie.name);
 238 |         }
 239 |       }
 240 | 
 241 |       if (expiredCookies.length > 0) {
 242 |         log.warning(`⚠️  Expired cookies: ${expiredCookies.join(", ")}`);
 243 |         return false;
 244 |       }
 245 | 
 246 |       log.success(`✅ All ${criticalCookies.length} critical cookies are valid`);
 247 |       return true;
 248 |     } catch (error) {
 249 |       log.warning(`⚠️  Cookie validation failed: ${error}`);
 250 |       return false;
 251 |     }
 252 |   }
 253 | 
 254 |   /**
 255 |    * Check if the saved state file is too old (>24 hours)
 256 |    */
 257 |   async isStateExpired(): Promise<boolean> {
 258 |     try {
 259 |       const stats = await fs.stat(this.stateFilePath);
 260 |       const fileAgeSeconds = (Date.now() - stats.mtimeMs) / 1000;
 261 |       const maxAgeSeconds = 24 * 60 * 60; // 24 hours
 262 | 
 263 |       if (fileAgeSeconds > maxAgeSeconds) {
 264 |         const hoursOld = fileAgeSeconds / 3600;
 265 |         log.warning(`⚠️  Saved state is ${hoursOld.toFixed(1)}h old (max: 24h)`);
 266 |         return true;
 267 |       }
 268 | 
 269 |       return false;
 270 |     } catch {
 271 |       return true; // File doesn't exist = expired
 272 |     }
 273 |   }
 274 | 
 275 |   // ============================================================================
 276 |   // Interactive Login
 277 |   // ============================================================================
 278 | 
 279 |   /**
 280 |    * Perform interactive login
 281 |    * User will see a browser window and login manually
 282 |    *
 283 |    * SIMPLE & RELIABLE: Just wait for URL to change to notebooklm.google.com
 284 |    */
 285 |   async performLogin(page: Page, sendProgress?: ProgressCallback): Promise<boolean> {
 286 |     try {
 287 |       log.info("🌐 Opening Google login page...");
 288 |       log.warning("📝 Please login to your Google account");
 289 |       log.warning("⏳ Browser will close automatically once you reach NotebookLM");
 290 |       log.info("");
 291 | 
 292 |       // Progress: Navigating
 293 |       await sendProgress?.("Navigating to Google login...", 3, 10);
 294 | 
 295 |       // Navigate to Google login (redirects to NotebookLM after auth)
 296 |       await page.goto(NOTEBOOKLM_AUTH_URL, { timeout: 60000 });
 297 | 
 298 |       // Progress: Waiting for login
 299 |       await sendProgress?.("Waiting for manual login (up to 10 minutes)...", 4, 10);
 300 | 
 301 |       // Wait for user to complete login
 302 |       log.warning("⏳ Waiting for login (up to 10 minutes)...");
 303 | 
 304 |       const checkIntervalMs = 1000; // Check every 1 second
 305 |       const maxAttempts = 600; // 10 minutes total
 306 |       let lastProgressUpdate = 0;
 307 | 
 308 |       for (let attempt = 0; attempt < maxAttempts; attempt++) {
 309 |         try {
 310 |           const currentUrl = page.url();
 311 |           const elapsedSeconds = Math.floor(attempt * (checkIntervalMs / 1000));
 312 | 
 313 |           // Send progress every 10 seconds
 314 |           if (elapsedSeconds - lastProgressUpdate >= 10) {
 315 |             lastProgressUpdate = elapsedSeconds;
 316 |             const progressStep = Math.min(8, 4 + Math.floor(elapsedSeconds / 60));
 317 |             await sendProgress?.(
 318 |               `Waiting for login... (${elapsedSeconds}s elapsed)`,
 319 |               progressStep,
 320 |               10
 321 |             );
 322 |           }
 323 | 
 324 |           // ✅ SIMPLE: Check if we're on NotebookLM (any path!)
 325 |           if (currentUrl.startsWith("https://notebooklm.google.com/")) {
 326 |             await sendProgress?.("Login successful! NotebookLM detected!", 9, 10);
 327 |             log.success("✅ Login successful! NotebookLM URL detected.");
 328 |             log.success(`✅ Current URL: ${currentUrl}`);
 329 | 
 330 |             // Short wait to ensure page is loaded
 331 |             await page.waitForTimeout(2000);
 332 |             return true;
 333 |           }
 334 | 
 335 |           // Still on accounts.google.com - log periodically
 336 |           if (currentUrl.includes("accounts.google.com") && attempt % 30 === 0 && attempt > 0) {
 337 |             log.warning(`⏳ Still waiting... (${elapsedSeconds}s elapsed)`);
 338 |           }
 339 | 
 340 |           await page.waitForTimeout(checkIntervalMs);
 341 |         } catch {
 342 |           await page.waitForTimeout(checkIntervalMs);
 343 |           continue;
 344 |         }
 345 |       }
 346 | 
 347 |       // Timeout reached - final check
 348 |       const currentUrl = page.url();
 349 |       if (currentUrl.startsWith("https://notebooklm.google.com/")) {
 350 |         await sendProgress?.("Login successful (detected on timeout check)!", 9, 10);
 351 |         log.success("✅ Login successful (detected on timeout check)");
 352 |         return true;
 353 |       }
 354 | 
 355 |       log.error("❌ Login verification failed - timeout reached");
 356 |       log.warning(`Current URL: ${currentUrl}`);
 357 |       return false;
 358 |     } catch (error) {
 359 |       log.error(`❌ Login failed: ${error}`);
 360 |       return false;
 361 |     }
 362 |   }
 363 | 
 364 |   // ============================================================================
 365 |   // Auto-Login with Credentials
 366 |   // ============================================================================
 367 | 
 368 |   /**
 369 |    * Attempt to authenticate using configured credentials
 370 |    */
 371 |   async loginWithCredentials(
 372 |     context: BrowserContext,
 373 |     page: Page,
 374 |     email: string,
 375 |     password: string
 376 |   ): Promise<boolean> {
 377 |     const maskedEmail = this.maskEmail(email);
 378 |     log.warning(`🔁 Attempting automatic login for ${maskedEmail}...`);
 379 | 
 380 |     // Log browser visibility
 381 |     if (!CONFIG.headless) {
 382 |       log.info("  👁️  Browser is VISIBLE for debugging");
 383 |     } else {
 384 |       log.info("  🙈 Browser is HEADLESS (invisible)");
 385 |     }
 386 | 
 387 |     log.info(`  🌐 Navigating to Google login...`);
 388 | 
 389 |     try {
 390 |       await page.goto(NOTEBOOKLM_AUTH_URL, {
 391 |         waitUntil: "domcontentloaded",
 392 |         timeout: CONFIG.browserTimeout,
 393 |       });
 394 |       log.success(`  ✅ Page loaded: ${page.url().slice(0, 80)}...`);
 395 |     } catch (error) {
 396 |       log.warning(`  ⚠️  Page load timeout (continuing anyway)`);
 397 |     }
 398 | 
 399 |     const deadline = Date.now() + CONFIG.autoLoginTimeoutMs;
 400 |     log.info(`  ⏰ Auto-login timeout: ${CONFIG.autoLoginTimeoutMs / 1000}s`);
 401 | 
 402 |     // Already on NotebookLM?
 403 |     log.info("  🔍 Checking if already authenticated...");
 404 |     if (await this.waitForNotebook(page, CONFIG.autoLoginTimeoutMs)) {
 405 |       log.success("✅ Already authenticated");
 406 |       await this.saveBrowserState(context, page);
 407 |       return true;
 408 |     }
 409 | 
 410 |     log.warning("  ❌ Not authenticated yet, proceeding with login...");
 411 | 
 412 |     // Handle possible account chooser
 413 |     log.info("  🔍 Checking for account chooser...");
 414 |     if (await this.handleAccountChooser(page, email)) {
 415 |       log.success("  ✅ Account selected from chooser");
 416 |       if (await this.waitForNotebook(page, CONFIG.autoLoginTimeoutMs)) {
 417 |         log.success("✅ Automatic login successful");
 418 |         await this.saveBrowserState(context, page);
 419 |         return true;
 420 |       }
 421 |     }
 422 | 
 423 |     // Email step
 424 |     log.info("  📧 Entering email address...");
 425 |     if (!(await this.fillIdentifier(page, email))) {
 426 |       if (await this.waitForNotebook(page, CONFIG.autoLoginTimeoutMs)) {
 427 |         log.success("✅ Automatic login successful");
 428 |         await this.saveBrowserState(context, page);
 429 |         return true;
 430 |       }
 431 |       log.warning("⚠️  Email input not detected");
 432 |     }
 433 | 
 434 |     // Password step (wait until visible)
 435 |     let waitAttempts = 0;
 436 |     log.warning("  ⏳ Waiting for password page to load...");
 437 | 
 438 |     while (Date.now() < deadline && !(await this.fillPassword(page, password))) {
 439 |       waitAttempts++;
 440 | 
 441 |       // Log every 10 seconds (20 attempts * 0.5s)
 442 |       if (waitAttempts % 20 === 0) {
 443 |         const secondsWaited = waitAttempts * 0.5;
 444 |         const secondsRemaining = (deadline - Date.now()) / 1000;
 445 |         log.warning(
 446 |           `  ⏳ Still waiting for password field... (${secondsWaited}s elapsed, ${secondsRemaining.toFixed(0)}s remaining)`
 447 |         );
 448 |         log.info(`  📍 Current URL: ${page.url().slice(0, 100)}`);
 449 |       }
 450 | 
 451 |       if (page.url().includes("challenge")) {
 452 |         log.warning("⚠️  Additional verification required (Google challenge page).");
 453 |         return false;
 454 |       }
 455 |       await page.waitForTimeout(500);
 456 |     }
 457 | 
 458 |     // Wait for Google redirect after login
 459 |     log.info("  🔄 Waiting for Google redirect to NotebookLM...");
 460 | 
 461 |     if (await this.waitForRedirectAfterLogin(page, deadline)) {
 462 |       log.success("✅ Automatic login successful");
 463 |       await this.saveBrowserState(context, page);
 464 |       return true;
 465 |     }
 466 | 
 467 |     // Login failed - diagnose
 468 |     log.error("❌ Automatic login timed out");
 469 | 
 470 |     // Take screenshot for debugging
 471 |     try {
 472 |       const screenshotPath = path.join(
 473 |         CONFIG.dataDir,
 474 |         `login_failed_${Date.now()}.png`
 475 |       );
 476 |       await page.screenshot({ path: screenshotPath });
 477 |       log.info(`  📸 Screenshot saved: ${screenshotPath}`);
 478 |     } catch (error) {
 479 |       log.warning(`  ⚠️  Could not save screenshot: ${error}`);
 480 |     }
 481 | 
 482 |     // Diagnose specific failure reason
 483 |     const currentUrl = page.url();
 484 |     log.warning("  🔍 Diagnosing failure...");
 485 | 
 486 |     if (currentUrl.includes("accounts.google.com")) {
 487 |       if (currentUrl.includes("/signin/identifier")) {
 488 |         log.error("  ❌ Still on email page - email input might have failed");
 489 |         log.info("  💡 Check if email is correct in .env");
 490 |       } else if (currentUrl.includes("/challenge")) {
 491 |         log.error(
 492 |           "  ❌ Google requires additional verification (2FA, CAPTCHA, suspicious login)"
 493 |         );
 494 |         log.info("  💡 Try logging in manually first: use setup_auth tool");
 495 |       } else if (currentUrl.includes("/pwd") || currentUrl.includes("/password")) {
 496 |         log.error("  ❌ Still on password page - password input might have failed");
 497 |         log.info("  💡 Check if password is correct in .env");
 498 |       } else {
 499 |         log.error(`  ❌ Stuck on Google accounts page: ${currentUrl.slice(0, 80)}...`);
 500 |       }
 501 |     } else if (currentUrl.includes("notebooklm.google.com")) {
 502 |       log.warning("  ⚠️  Reached NotebookLM but couldn't detect successful login");
 503 |       log.info("  💡 This might be a timing issue - try again");
 504 |     } else {
 505 |       log.error(`  ❌ Unexpected page: ${currentUrl.slice(0, 80)}...`);
 506 |     }
 507 | 
 508 |     return false;
 509 |   }
 510 | 
 511 |   // ============================================================================
 512 |   // Helper Methods
 513 |   // ============================================================================
 514 | 
 515 |   /**
 516 |    * Wait for Google to redirect to NotebookLM after successful login (SIMPLE & RELIABLE)
 517 |    *
 518 |    * Just checks if URL changes to notebooklm.google.com - no complex UI element searching!
 519 |    * Matches the simplified approach used in performLogin().
 520 |    */
 521 |   private async waitForRedirectAfterLogin(
 522 |     page: Page,
 523 |     deadline: number
 524 |   ): Promise<boolean> {
 525 |     log.info("    ⏳ Waiting for redirect to NotebookLM...");
 526 | 
 527 |     while (Date.now() < deadline) {
 528 |       try {
 529 |         const currentUrl = page.url();
 530 | 
 531 |         // Simple check: Are we on NotebookLM?
 532 |         if (currentUrl.startsWith("https://notebooklm.google.com/")) {
 533 |           log.success("    ✅ NotebookLM URL detected!");
 534 |           // Short wait to ensure page is loaded
 535 |           await page.waitForTimeout(2000);
 536 |           return true;
 537 |         }
 538 |       } catch {
 539 |         // Ignore errors
 540 |       }
 541 | 
 542 |       await page.waitForTimeout(500);
 543 |     }
 544 | 
 545 |     log.error("    ❌ Redirect timeout - NotebookLM URL not reached");
 546 |     return false;
 547 |   }
 548 | 
 549 |   /**
 550 |    * Wait for NotebookLM to load (SIMPLE & RELIABLE)
 551 |    *
 552 |    * Just checks if URL starts with notebooklm.google.com - no complex UI element searching!
 553 |    * Matches the simplified approach used in performLogin().
 554 |    */
 555 |   private async waitForNotebook(page: Page, timeoutMs: number): Promise<boolean> {
 556 |     const endTime = Date.now() + timeoutMs;
 557 | 
 558 |     while (Date.now() < endTime) {
 559 |       try {
 560 |         const currentUrl = page.url();
 561 | 
 562 |         // Simple check: Are we on NotebookLM?
 563 |         if (currentUrl.startsWith("https://notebooklm.google.com/")) {
 564 |           log.success("  ✅ NotebookLM URL detected");
 565 |           return true;
 566 |         }
 567 |       } catch {
 568 |         // Ignore errors
 569 |       }
 570 | 
 571 |       await page.waitForTimeout(1000);
 572 |     }
 573 | 
 574 |     return false;
 575 |   }
 576 | 
 577 |   /**
 578 |    * Handle possible account chooser
 579 |    */
 580 |   private async handleAccountChooser(page: Page, email: string): Promise<boolean> {
 581 |     try {
 582 |       const chooser = await page.$$("div[data-identifier], li[data-identifier]");
 583 | 
 584 |       if (chooser.length > 0) {
 585 |         for (const item of chooser) {
 586 |           const identifier = (await item.getAttribute("data-identifier"))?.toLowerCase() || "";
 587 |           if (identifier === email.toLowerCase()) {
 588 |             await item.click();
 589 |             await randomDelay(150, 320);
 590 |             await page.waitForTimeout(500);
 591 |             return true;
 592 |           }
 593 |         }
 594 | 
 595 |         // Click "Use another account"
 596 |         await this.clickText(page, [
 597 |           "Use another account",
 598 |           "Weiteres Konto hinzufügen",
 599 |           "Anderes Konto verwenden",
 600 |         ]);
 601 |         await randomDelay(150, 320);
 602 |         return false;
 603 |       }
 604 | 
 605 |       return false;
 606 |     } catch {
 607 |       return false;
 608 |     }
 609 |   }
 610 | 
 611 |   /**
 612 |    * Fill email identifier field with human-like typing
 613 |    */
 614 |   private async fillIdentifier(page: Page, email: string): Promise<boolean> {
 615 |     log.info("    📧 Looking for email field...");
 616 | 
 617 |     const emailSelectors = [
 618 |       "input#identifierId",
 619 |       "input[name='identifier']",
 620 |       "input[type='email']",
 621 |     ];
 622 | 
 623 |     let emailSelector: string | null = null;
 624 |     let emailField: any = null;
 625 | 
 626 |     for (const selector of emailSelectors) {
 627 |       try {
 628 |         const candidate = await page.waitForSelector(selector, {
 629 |           state: "attached",
 630 |           timeout: 3000,
 631 |         });
 632 |         if (!candidate) continue;
 633 | 
 634 |         try {
 635 |           if (!(await candidate.isVisible())) {
 636 |             continue; // Hidden field
 637 |           }
 638 |         } catch {
 639 |           continue;
 640 |         }
 641 | 
 642 |         emailField = candidate;
 643 |         emailSelector = selector;
 644 |         log.success(`    ✅ Email field visible: ${selector}`);
 645 |         break;
 646 |       } catch {
 647 |         continue;
 648 |       }
 649 |     }
 650 | 
 651 |     if (!emailField || !emailSelector) {
 652 |       log.warning("    ℹ️  No visible email field found (likely pre-filled)");
 653 |       log.info(`    📍 Current URL: ${page.url().slice(0, 100)}`);
 654 |       return false;
 655 |     }
 656 | 
 657 |     // Human-like mouse movement to field
 658 |     try {
 659 |       const box = await emailField.boundingBox();
 660 |       if (box) {
 661 |         const targetX = box.x + box.width / 2;
 662 |         const targetY = box.y + box.height / 2;
 663 |         await randomMouseMovement(page, targetX, targetY);
 664 |         await randomDelay(200, 500);
 665 |       }
 666 |     } catch {
 667 |       // Ignore errors
 668 |     }
 669 | 
 670 |     // Click to focus
 671 |     try {
 672 |       await realisticClick(page, emailSelector, false);
 673 |     } catch (error) {
 674 |       log.warning(`    ⚠️  Could not click email field (${error}); trying direct focus`);
 675 |       try {
 676 |         await emailField.focus();
 677 |       } catch {
 678 |         log.error("    ❌ Failed to focus email field");
 679 |         return false;
 680 |       }
 681 |     }
 682 | 
 683 |     // ✅ FASTER: Programmer typing speed (90-120 WPM from config)
 684 |     log.info(`    ⌨️  Typing email: ${this.maskEmail(email)}`);
 685 |     try {
 686 |       const wpm = CONFIG.typingWpmMin + Math.floor(Math.random() * (CONFIG.typingWpmMax - CONFIG.typingWpmMin + 1));
 687 |       await humanType(page, emailSelector, email, { wpm, withTypos: false });
 688 |       log.success("    ✅ Email typed successfully");
 689 |     } catch (error) {
 690 |       log.error(`    ❌ Typing failed: ${error}`);
 691 |       try {
 692 |         await page.fill(emailSelector, email);
 693 |         log.success("    ✅ Filled email using fallback");
 694 |       } catch {
 695 |         return false;
 696 |       }
 697 |     }
 698 | 
 699 |     // Human "thinking" pause before clicking Next
 700 |     await randomDelay(400, 1200);
 701 | 
 702 |     // Click Next button
 703 |     log.info("    🔘 Looking for Next button...");
 704 | 
 705 |     const nextSelectors = [
 706 |       "button:has-text('Next')",
 707 |       "button:has-text('Weiter')",
 708 |       "#identifierNext",
 709 |     ];
 710 | 
 711 |     let nextClicked = false;
 712 |     for (const selector of nextSelectors) {
 713 |       try {
 714 |         const button = await page.locator(selector);
 715 |         if ((await button.count()) > 0) {
 716 |           await realisticClick(page, selector, true);
 717 |           log.success(`    ✅ Next button clicked: ${selector}`);
 718 |           nextClicked = true;
 719 |           break;
 720 |         }
 721 |       } catch {
 722 |         continue;
 723 |       }
 724 |     }
 725 | 
 726 |     if (!nextClicked) {
 727 |       log.warning("    ⚠️  Button not found, pressing Enter");
 728 |       await emailField.press("Enter");
 729 |     }
 730 | 
 731 |     // Variable delay
 732 |     await randomDelay(800, 1500);
 733 |     log.success("    ✅ Email step complete");
 734 |     return true;
 735 |   }
 736 | 
 737 |   /**
 738 |    * Fill password field with human-like typing
 739 |    */
 740 |   private async fillPassword(page: Page, password: string): Promise<boolean> {
 741 |     log.info("    🔐 Looking for password field...");
 742 | 
 743 |     const passwordSelectors = ["input[name='Passwd']", "input[type='password']"];
 744 | 
 745 |     let passwordSelector: string | null = null;
 746 |     let passwordField: any = null;
 747 | 
 748 |     for (const selector of passwordSelectors) {
 749 |       try {
 750 |         passwordField = await page.$(selector);
 751 |         if (passwordField) {
 752 |           passwordSelector = selector;
 753 |           log.success(`    ✅ Password field found: ${selector}`);
 754 |           break;
 755 |         }
 756 |       } catch {
 757 |         continue;
 758 |       }
 759 |     }
 760 | 
 761 |     if (!passwordField) {
 762 |       // Not found yet, but don't fail - this is called in a loop
 763 |       return false;
 764 |     }
 765 | 
 766 |     // Human-like mouse movement to field
 767 |     try {
 768 |       const box = await passwordField.boundingBox();
 769 |       if (box) {
 770 |         const targetX = box.x + box.width / 2;
 771 |         const targetY = box.y + box.height / 2;
 772 |         await randomMouseMovement(page, targetX, targetY);
 773 |         await randomDelay(300, 700);
 774 |       }
 775 |     } catch {
 776 |       // Ignore errors
 777 |     }
 778 | 
 779 |     // Click to focus
 780 |     if (passwordSelector) {
 781 |       await realisticClick(page, passwordSelector, false);
 782 |     }
 783 | 
 784 |     // ✅ FASTER: Programmer typing speed (90-120 WPM from config)
 785 |     log.info("    ⌨️  Typing password...");
 786 |     try {
 787 |       const wpm = CONFIG.typingWpmMin + Math.floor(Math.random() * (CONFIG.typingWpmMax - CONFIG.typingWpmMin + 1));
 788 |       if (passwordSelector) {
 789 |         await humanType(page, passwordSelector, password, { wpm, withTypos: false });
 790 |       }
 791 |       log.success("    ✅ Password typed successfully");
 792 |     } catch (error) {
 793 |       log.error(`    ❌ Typing failed: ${error}`);
 794 |       return false;
 795 |     }
 796 | 
 797 |     // Human "review" pause before submitting password
 798 |     await randomDelay(300, 1000);
 799 | 
 800 |     // Click Next button
 801 |     log.info("    🔘 Looking for Next button...");
 802 | 
 803 |     const pwdNextSelectors = [
 804 |       "button:has-text('Next')",
 805 |       "button:has-text('Weiter')",
 806 |       "#passwordNext",
 807 |     ];
 808 | 
 809 |     let pwdNextClicked = false;
 810 |     for (const selector of pwdNextSelectors) {
 811 |       try {
 812 |         const button = await page.locator(selector);
 813 |         if ((await button.count()) > 0) {
 814 |           await realisticClick(page, selector, true);
 815 |           log.success(`    ✅ Next button clicked: ${selector}`);
 816 |           pwdNextClicked = true;
 817 |           break;
 818 |         }
 819 |       } catch {
 820 |         continue;
 821 |       }
 822 |     }
 823 | 
 824 |     if (!pwdNextClicked) {
 825 |       log.warning("    ⚠️  Button not found, pressing Enter");
 826 |       await passwordField.press("Enter");
 827 |     }
 828 | 
 829 |     // Variable delay
 830 |     await randomDelay(800, 1500);
 831 |     log.success("    ✅ Password step complete");
 832 |     return true;
 833 |   }
 834 | 
 835 |   /**
 836 |    * Click text element
 837 |    */
 838 |   private async clickText(page: Page, texts: string[]): Promise<boolean> {
 839 |     for (const text of texts) {
 840 |       const selector = `text="${text}"`;
 841 |       try {
 842 |         const locator = page.locator(selector);
 843 |         if ((await locator.count()) > 0) {
 844 |           await realisticClick(page, selector, true);
 845 |           await randomDelay(120, 260);
 846 |           return true;
 847 |         }
 848 |       } catch {
 849 |         continue;
 850 |       }
 851 |     }
 852 |     return false;
 853 |   }
 854 | 
 855 |   /**
 856 |    * Mask email for logging
 857 |    */
 858 |   private maskEmail(email: string): string {
 859 |     if (!email.includes("@")) {
 860 |       return "***";
 861 |     }
 862 |     const [name, domain] = email.split("@");
 863 |     if (name.length <= 2) {
 864 |       return `${"*".repeat(name.length)}@${domain}`;
 865 |     }
 866 |     return `${name[0]}${"*".repeat(name.length - 2)}${name[name.length - 1]}@${domain}`;
 867 |   }
 868 | 
 869 |   // ============================================================================
 870 |   // Additional Helper Methods
 871 |   // ============================================================================
 872 | 
 873 |   /**
 874 |    * Load authentication state from a specific file path
 875 |    */
 876 |   async loadAuthState(context: BrowserContext, statePath: string): Promise<boolean> {
 877 |     try {
 878 |       // Read state.json
 879 |       const stateData = await fs.readFile(statePath, { encoding: "utf-8" });
 880 |       const state = JSON.parse(stateData);
 881 | 
 882 |       // Add cookies to context
 883 |       if (state.cookies) {
 884 |         await context.addCookies(state.cookies);
 885 |         log.success(`✅ Loaded ${state.cookies.length} cookies from ${statePath}`);
 886 |         return true;
 887 |       }
 888 | 
 889 |       log.warning(`⚠️  No cookies found in state file`);
 890 |       return false;
 891 |     } catch (error) {
 892 |       log.error(`❌ Failed to load auth state: ${error}`);
 893 |       return false;
 894 |     }
 895 |   }
 896 | 
 897 |   /**
 898 |    * Perform interactive setup (for setup_auth tool)
 899 |    * Opens a PERSISTENT browser for manual login
 900 |    *
 901 |    * CRITICAL: Uses the SAME persistent context as runtime!
 902 |    * This ensures cookies are automatically saved to the Chrome profile.
 903 |    *
 904 |    * Benefits over temporary browser:
 905 |    * - Session cookies persist correctly (Playwright bug workaround)
 906 |    * - Same fingerprint as runtime
 907 |    * - No need for addCookies() workarounds
 908 |    * - Automatic cookie persistence via Chrome profile
 909 |    *
 910 |    * @param sendProgress Optional progress callback
 911 |    * @param overrideHeadless Optional override for headless mode (true = visible, false = headless)
 912 |    *                         If not provided, defaults to true (visible) for setup
 913 |    */
 914 |   async performSetup(sendProgress?: ProgressCallback, overrideHeadless?: boolean): Promise<boolean> {
 915 |     const { chromium } = await import("patchright");
 916 | 
 917 |     // Determine headless mode: override or default to true (visible for setup)
 918 |     // overrideHeadless contains show_browser value (true = show, false = hide)
 919 |     const shouldShowBrowser = overrideHeadless !== undefined ? overrideHeadless : true;
 920 | 
 921 |     try {
 922 |       // CRITICAL: Clear ALL old auth data FIRST (for account switching)
 923 |       log.info("🔄 Preparing for new account authentication...");
 924 |       await sendProgress?.("Clearing old authentication data...", 1, 10);
 925 |       await this.clearAllAuthData();
 926 | 
 927 |       log.info("🚀 Launching persistent browser for interactive setup...");
 928 |       log.info(`  📍 Profile: ${CONFIG.chromeProfileDir}`);
 929 |       await sendProgress?.("Launching persistent browser...", 2, 10);
 930 | 
 931 |       // ✅ CRITICAL FIX: Use launchPersistentContext (same as runtime!)
 932 |       // This ensures session cookies persist correctly
 933 |       const context = await chromium.launchPersistentContext(
 934 |         CONFIG.chromeProfileDir,
 935 |         {
 936 |           headless: !shouldShowBrowser, // Use override or default to visible for setup
 937 |           channel: "chrome" as const,
 938 |           viewport: CONFIG.viewport,
 939 |           locale: "en-US",
 940 |           timezoneId: "Europe/Berlin",
 941 |           args: [
 942 |             "--disable-blink-features=AutomationControlled",
 943 |             "--disable-dev-shm-usage",
 944 |             "--no-first-run",
 945 |             "--no-default-browser-check",
 946 |           ],
 947 |         }
 948 |       );
 949 | 
 950 |       // Get or create a page
 951 |       const pages = context.pages();
 952 |       const page = pages.length > 0 ? pages[0] : await context.newPage();
 953 | 
 954 |       // Perform login with progress updates
 955 |       const loginSuccess = await this.performLogin(page, sendProgress);
 956 | 
 957 |       if (loginSuccess) {
 958 |         // ✅ Save browser state to state.json (for validation & backup)
 959 |         // Chrome ALSO saves everything to the persistent profile automatically!
 960 |         await sendProgress?.("Saving authentication state...", 9, 10);
 961 |         await this.saveBrowserState(context, page);
 962 |         log.success("✅ Setup complete - authentication saved to:");
 963 |         log.success(`  📄 State file: ${this.stateFilePath}`);
 964 |         log.success(`  📁 Chrome profile: ${CONFIG.chromeProfileDir}`);
 965 |         log.info("💡 Session cookies will now persist across restarts!");
 966 |       }
 967 | 
 968 |       // Close persistent context
 969 |       await context.close();
 970 | 
 971 |       return loginSuccess;
 972 |     } catch (error) {
 973 |       log.error(`❌ Setup failed: ${error}`);
 974 |       return false;
 975 |     }
 976 |   }
 977 | 
 978 |   // ============================================================================
 979 |   // Cleanup
 980 |   // ============================================================================
 981 | 
 982 |   /**
 983 |    * Clear ALL authentication data for account switching
 984 |    *
 985 |    * CRITICAL: This deletes EVERYTHING to ensure only ONE account is active:
 986 |    * - All state.json files (cookies, localStorage)
 987 |    * - sessionStorage files
 988 |    * - Chrome profile directory (browser fingerprint, cache, etc.)
 989 |    *
 990 |    * Use this BEFORE authenticating a new account!
 991 |    */
 992 |   async clearAllAuthData(): Promise<void> {
 993 |     log.warning("🗑️  Clearing ALL authentication data for account switch...");
 994 | 
 995 |     let deletedCount = 0;
 996 | 
 997 |     // 1. Delete all state files in browser_state_dir
 998 |     try {
 999 |       const files = await fs.readdir(CONFIG.browserStateDir);
1000 |       for (const file of files) {
1001 |         if (file.endsWith(".json")) {
1002 |           await fs.unlink(path.join(CONFIG.browserStateDir, file));
1003 |           log.info(`  ✅ Deleted: ${file}`);
1004 |           deletedCount++;
1005 |         }
1006 |       }
1007 |     } catch (error) {
1008 |       log.warning(`  ⚠️  Could not delete state files: ${error}`);
1009 |     }
1010 | 
1011 |     // 2. Delete Chrome profile (THE KEY for account switching!)
1012 |     // This removes ALL browser data: cookies, cache, fingerprint, etc.
1013 |     try {
1014 |       const chromeProfileDir = CONFIG.chromeProfileDir;
1015 |       if (existsSync(chromeProfileDir)) {
1016 |         await fs.rm(chromeProfileDir, { recursive: true, force: true });
1017 |         log.success(`  ✅ Deleted Chrome profile: ${chromeProfileDir}`);
1018 |         deletedCount++;
1019 |       }
1020 |     } catch (error) {
1021 |       log.warning(`  ⚠️  Could not delete Chrome profile: ${error}`);
1022 |     }
1023 | 
1024 |     if (deletedCount === 0) {
1025 |       log.info("  ℹ️  No old auth data found (already clean)");
1026 |     } else {
1027 |       log.success(`✅ All auth data cleared (${deletedCount} items) - ready for new account!`);
1028 |     }
1029 |   }
1030 | 
1031 |   /**
1032 |    * Clear all saved authentication state
1033 |    */
1034 |   async clearState(): Promise<boolean> {
1035 |     try {
1036 |       try {
1037 |         await fs.unlink(this.stateFilePath);
1038 |       } catch {
1039 |         // File doesn't exist
1040 |       }
1041 | 
1042 |       try {
1043 |         await fs.unlink(this.sessionFilePath);
1044 |       } catch {
1045 |         // File doesn't exist
1046 |       }
1047 | 
1048 |       log.success("✅ Authentication state cleared");
1049 |       return true;
1050 |     } catch (error) {
1051 |       log.error(`❌ Failed to clear state: ${error}`);
1052 |       return false;
1053 |     }
1054 |   }
1055 | 
1056 |   /**
1057 |    * HARD RESET: Completely delete ALL authentication state
1058 |    */
1059 |   async hardResetState(): Promise<boolean> {
1060 |     try {
1061 |       log.warning("🧹 Performing HARD RESET of all authentication state...");
1062 | 
1063 |       let deletedCount = 0;
1064 | 
1065 |       // Delete state file
1066 |       try {
1067 |         await fs.unlink(this.stateFilePath);
1068 |         log.info(`  🗑️  Deleted: ${this.stateFilePath}`);
1069 |         deletedCount++;
1070 |       } catch {
1071 |         // File doesn't exist
1072 |       }
1073 | 
1074 |       // Delete session file
1075 |       try {
1076 |         await fs.unlink(this.sessionFilePath);
1077 |         log.info(`  🗑️  Deleted: ${this.sessionFilePath}`);
1078 |         deletedCount++;
1079 |       } catch {
1080 |         // File doesn't exist
1081 |       }
1082 | 
1083 |       // Delete entire browser_state_dir
1084 |       try {
1085 |         const files = await fs.readdir(CONFIG.browserStateDir);
1086 |         for (const file of files) {
1087 |           await fs.unlink(path.join(CONFIG.browserStateDir, file));
1088 |           deletedCount++;
1089 |         }
1090 |         log.info(`  🗑️  Deleted: ${CONFIG.browserStateDir}/ (${files.length} files)`);
1091 |       } catch {
1092 |         // Directory doesn't exist or empty
1093 |       }
1094 | 
1095 |       if (deletedCount === 0) {
1096 |         log.info("  ℹ️  No state to delete (already clean)");
1097 |       } else {
1098 |         log.success(`✅ Hard reset complete: ${deletedCount} items deleted`);
1099 |       }
1100 | 
1101 |       return true;
1102 |     } catch (error) {
1103 |       log.error(`❌ Hard reset failed: ${error}`);
1104 |       return false;
1105 |     }
1106 |   }
1107 | }
1108 | 
```
Page 2/2FirstPrevNextLast