/**
* Browser Session
*
* Represents a single browser session for NotebookLM interactions.
*
* Features:
* - Human-like question typing
* - Streaming response detection
* - Auto-login on session expiry
* - Session activity tracking
* - Chat history reset
*
* Based on the Python implementation from browser_session.py
*/
import type { BrowserContext, Page } from 'patchright';
import { SharedContextManager } from './shared-context-manager.js';
import { AuthManager } from '../auth/auth-manager.js';
import { humanType, randomDelay } from '../utils/stealth-utils.js';
import {
waitForLatestAnswer,
snapshotAllResponses,
isRateLimitMessage,
} from '../utils/page-utils.js';
import {
extractCitations,
type SourceFormat,
type CitationExtractionResult,
} from '../utils/citation-extractor.js';
import { CONFIG } from '../config.js';
import { log } from '../utils/logger.js';
import type { SessionInfo, ProgressCallback } from '../types.js';
import { RateLimitError } from '../errors.js';
/**
* Result from asking a question (internal)
*/
export interface AskResult {
/** The answer text (formatted if source_format specified) */
answer: string;
/** Original unformatted answer */
originalAnswer: string;
/** Citation extraction result (if source_format is not 'none') */
citationResult?: CitationExtractionResult;
}
export class BrowserSession {
public readonly sessionId: string;
public readonly notebookUrl: string;
public readonly createdAt: number;
public lastActivity: number;
public messageCount: number;
private context!: BrowserContext;
private sharedContextManager: SharedContextManager;
private authManager: AuthManager;
private page: Page | null = null;
private initialized: boolean = false;
/** Headless mode override - true=headless, false=visible, undefined=use config default */
private overrideHeadless?: boolean;
constructor(
sessionId: string,
sharedContextManager: SharedContextManager,
authManager: AuthManager,
notebookUrl: string,
overrideHeadless?: boolean
) {
this.sessionId = sessionId;
this.sharedContextManager = sharedContextManager;
this.authManager = authManager;
this.notebookUrl = notebookUrl;
this.overrideHeadless = overrideHeadless;
this.createdAt = Date.now();
this.lastActivity = Date.now();
this.messageCount = 0;
log.info(`🆕 BrowserSession ${sessionId} created`);
}
/**
* Initialize the session by creating a page and navigating to the notebook
*/
async init(): Promise<void> {
if (this.initialized) {
log.warning(`⚠️ Session ${this.sessionId} already initialized`);
return;
}
log.info(`🚀 Initializing session ${this.sessionId}...`);
try {
// Ensure a valid shared context (pass overrideHeadless to maintain visibility mode)
this.context = await this.sharedContextManager.getOrCreateContext(this.overrideHeadless);
// Create new page (tab) in the shared context (with auto-recovery)
try {
this.page = await this.context.newPage();
} catch (e: unknown) {
const msg = String(e instanceof Error ? e.message : e);
if (
/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)
) {
log.warning(' ♻️ Context was closed. Recreating and retrying newPage...');
this.context = await this.sharedContextManager.getOrCreateContext(this.overrideHeadless);
this.page = await this.context.newPage();
} else {
throw e;
}
}
log.success(` ✅ Created new page`);
// Navigate to notebook
log.info(` 🌐 Navigating to: ${this.notebookUrl}`);
await this.page.goto(this.notebookUrl, {
waitUntil: 'domcontentloaded',
timeout: CONFIG.browserTimeout,
});
// Wait for page to stabilize
await randomDelay(2000, 3000);
// Check if we need to login
const isAuthenticated = await this.authManager.validateCookiesExpiry(this.context);
if (!isAuthenticated) {
log.warning(` 🔑 Session ${this.sessionId} needs authentication`);
const loginSuccess = await this.ensureAuthenticated();
if (!loginSuccess) {
throw new Error('Failed to authenticate session');
}
} else {
log.success(` ✅ Session already authenticated`);
}
// CRITICAL: Restore sessionStorage from saved state
// This is essential for maintaining Google session state!
log.info(` 🔄 Restoring sessionStorage...`);
const sessionData = await this.authManager.loadSessionStorage();
if (sessionData) {
const entryCount = Object.keys(sessionData).length;
if (entryCount > 0) {
await this.restoreSessionStorage(sessionData, entryCount);
} else {
log.info(` ℹ️ SessionStorage empty (fresh session)`);
}
} else {
log.info(` ℹ️ No saved sessionStorage found (fresh session)`);
}
// Wait for NotebookLM interface to load
log.info(` ⏳ Waiting for NotebookLM interface...`);
await this.waitForNotebookLMReady();
this.initialized = true;
this.updateActivity();
log.success(`✅ Session ${this.sessionId} initialized successfully`);
} catch (error) {
log.error(`❌ Failed to initialize session ${this.sessionId}: ${error}`);
if (this.page) {
await this.page.close();
this.page = null;
}
throw error;
}
}
/**
* Wait for NotebookLM interface to be ready
*
* IMPORTANT: Wait for BOTH:
* 1. Chat input to be visible
* 2. Page content to be fully loaded (no skeleton UI)
*/
private async waitForNotebookLMReady(): Promise<void> {
if (!this.page) {
throw new Error('Page not initialized');
}
try {
// PRIMARY: Wait for chat input
log.info(' ⏳ Waiting for chat input (textarea.query-box-input)...');
await this.page.waitForSelector('textarea.query-box-input', {
timeout: 15000,
state: 'visible',
});
log.success(' ✅ Chat input ready!');
// CRITICAL: Wait for page content to load (no skeleton UI)
log.info(' ⏳ Waiting for page content to load...');
await this.waitForContentLoaded();
log.success(' ✅ Page content loaded!');
} catch {
// FALLBACK: Try alternative selector
try {
log.info(' ⏳ Trying fallback selector (aria-label)...');
await this.page.waitForSelector('textarea[aria-label="Feld für Anfragen"]', {
timeout: 5000,
state: 'visible',
});
log.success(' ✅ Chat input ready (fallback)!');
} catch (error) {
log.error(` ❌ NotebookLM interface not ready: ${error}`);
const currentUrl = this.page?.url() || 'unknown';
throw new Error(
`Could not find NotebookLM chat input.\n\n` +
`Current URL: ${currentUrl}\n\n` +
`Possible causes:\n` +
`1. Invalid notebook URL - the notebook may not exist or you don't have access\n` +
`2. NotebookLM page structure changed (rare)\n` +
`3. Page took too long to load (timeout after 15 seconds)\n\n` +
`Please verify:\n` +
`- The notebook URL is correct\n` +
`- You have access to this notebook\n` +
`- The URL format: https://notebooklm.google.com/notebook/[id]`
);
}
}
}
/**
* Wait for page content to be fully loaded (no skeleton/loading UI)
* This ensures NotebookLM has finished loading sources and is ready to answer
*/
private async waitForContentLoaded(): Promise<void> {
if (!this.page) return;
const maxWaitMs = 15000;
const pollIntervalMs = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
// Check if skeleton UI is still visible
const hasSkeletonUI = await this.page.evaluate(`
(() => {
// Look for skeleton/loading indicators
const skeletonSelectors = [
'.skeleton',
'[class*="skeleton"]',
'[class*="loading"]',
'.mat-progress-bar',
'.loading-indicator',
'.shimmer',
'[class*="shimmer"]',
];
for (const selector of skeletonSelectors) {
const elements = document.querySelectorAll(selector);
for (const el of elements) {
const style = window.getComputedStyle(el);
if (style.display !== 'none' && style.visibility !== 'hidden') {
return true;
}
}
}
// Also check for placeholder bars (common in loading states)
const placeholderBars = document.querySelectorAll('[style*="background"][style*="animate"]');
if (placeholderBars.length > 0) return true;
return false;
})()
`);
if (!hasSkeletonUI) {
// Also wait a small additional delay to be safe
await randomDelay(500, 800);
return;
}
await this.page.waitForTimeout(pollIntervalMs);
}
log.warning(' ⚠️ Timeout waiting for content to load, proceeding anyway...');
}
private isPageClosedSafe(): boolean {
if (!this.page) return true;
const p = this.page as { isClosed?: () => boolean };
try {
if (typeof p.isClosed === 'function') {
if (p.isClosed()) return true;
}
// Accessing URL should be safe; if page is gone, this may throw
void this.page.url();
return false;
} catch {
return true;
}
}
/**
* Ensure the session is authenticated, perform auto-login if needed
*/
private async ensureAuthenticated(): Promise<boolean> {
if (!this.page) {
throw new Error('Page not initialized');
}
log.info(`🔑 Checking authentication for session ${this.sessionId}...`);
// Check cookie validity
const isValid = await this.authManager.validateCookiesExpiry(this.context);
if (isValid) {
log.success(` ✅ Cookies valid`);
return true;
}
log.warning(` ⚠️ Cookies expired or invalid`);
// Try to get valid auth state
const statePath = await this.authManager.getValidStatePath();
if (statePath) {
// Load saved state
log.info(` 📂 Loading auth state from: ${statePath}`);
await this.authManager.loadAuthState(this.context, statePath);
// Reload page to apply new auth
log.info(` 🔄 Reloading page...`);
await (this.page as Page).reload({ waitUntil: 'domcontentloaded' });
await randomDelay(2000, 3000);
// Check if it worked
const nowValid = await this.authManager.validateCookiesExpiry(this.context);
if (nowValid) {
log.success(` ✅ Auth state loaded successfully`);
return true;
}
}
// Need fresh login
log.warning(` 🔑 Fresh login required`);
if (CONFIG.autoLoginEnabled) {
log.info(` 🤖 Attempting auto-login...`);
const loginSuccess = await this.authManager.loginWithCredentials(
this.context,
this.page,
CONFIG.loginEmail,
CONFIG.loginPassword
);
if (loginSuccess) {
log.success(` ✅ Auto-login successful`);
// Navigate back to notebook
await this.page.goto(this.notebookUrl, {
waitUntil: 'domcontentloaded',
});
await randomDelay(2000, 3000);
return true;
} else {
log.error(` ❌ Auto-login failed`);
return false;
}
} else {
log.error(` ❌ Auto-login disabled and no valid auth state - manual login required`);
return false;
}
}
private getOriginFromUrl(url: string): string | null {
try {
return new URL(url).origin;
} catch {
return null;
}
}
/**
* Safely restore sessionStorage when the page is on the expected origin
*/
private async restoreSessionStorage(
sessionData: Record<string, string>,
entryCount: number
): Promise<void> {
if (!this.page) {
log.warning(` ⚠️ Cannot restore sessionStorage without an active page`);
return;
}
const targetOrigin = this.getOriginFromUrl(this.notebookUrl);
if (!targetOrigin) {
log.warning(` ⚠️ Unable to determine target origin for sessionStorage restore`);
return;
}
let restored = false;
const applyToPage = async (): Promise<boolean> => {
if (!this.page) {
return false;
}
const currentOrigin = this.getOriginFromUrl(this.page.url());
if (currentOrigin !== targetOrigin) {
return false;
}
try {
await this.page.evaluate((data) => {
for (const [key, value] of Object.entries(data)) {
// @ts-expect-error - sessionStorage exists in browser context
sessionStorage.setItem(key, value);
}
}, sessionData);
restored = true;
log.success(` ✅ SessionStorage restored: ${entryCount} entries`);
return true;
} catch (error) {
log.warning(` ⚠️ Failed to restore sessionStorage: ${error}`);
return false;
}
};
if (await applyToPage()) {
return;
}
log.info(` ⏳ Waiting for NotebookLM origin before restoring sessionStorage...`);
const handleNavigation = async () => {
if (restored) {
return;
}
if (await applyToPage()) {
cleanup();
}
};
// Cleanup function to remove listener and prevent memory leak
const cleanup = () => {
this.page?.off('framenavigated', handleNavigation);
this.page?.off('close', cleanup);
};
this.page.on('framenavigated', handleNavigation);
// Also cleanup if page closes before restoration completes
this.page.once('close', cleanup);
}
/**
* Ask a question to NotebookLM
*
* @param question The question to ask
* @param sendProgress Progress callback for status updates
* @param sourceFormat Optional format for source citation extraction
* @returns AskResult with answer and optional citation data
*/
async ask(
question: string,
sendProgress?: ProgressCallback,
sourceFormat: SourceFormat = 'none'
): Promise<AskResult> {
const askOnce = async (): Promise<AskResult> => {
if (!this.initialized || !this.page || this.isPageClosedSafe()) {
log.warning(` ℹ️ Session not initialized or page missing → re-initializing...`);
await this.init();
}
log.info(`💬 [${this.sessionId}] Asking: "${question.substring(0, 100)}..."`);
const page = this.page!;
// Ensure we're still authenticated
await sendProgress?.('Verifying authentication...', 2, 5);
const isAuth = await this.authManager.validateCookiesExpiry(this.context);
if (!isAuth) {
log.warning(` 🔑 Session expired, re-authenticating...`);
await sendProgress?.('Re-authenticating session...', 2, 5);
const reAuthSuccess = await this.ensureAuthenticated();
if (!reAuthSuccess) {
throw new Error('Failed to re-authenticate session');
}
}
// Snapshot existing responses BEFORE asking
log.info(` 📸 Snapshotting existing responses...`);
const existingResponses = await snapshotAllResponses(page);
log.success(` ✅ Captured ${existingResponses.length} existing responses`);
// Ensure sources are selected before asking
await this.ensureSourcesSelected();
// Check for rate limit BEFORE trying to submit a question
log.info(` 🔍 Checking for rate limit before asking...`);
if (await this.detectRateLimitError()) {
throw new RateLimitError('NotebookLM daily limit reached - switching to another account');
}
// DEBUG: Take screenshot before asking to see UI state
try {
const debugPath = process.env.LOCALAPPDATA || 'C:\\Users\\romai\\AppData\\Local';
const screenshotPath = `${debugPath}\\notebooklm-mcp\\Data\\debug-before-ask.png`;
await page.screenshot({ path: screenshotPath, fullPage: true });
log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
} catch (e) {
log.warning(` ⚠️ Could not save debug screenshot: ${e}`);
}
// Find the chat input
const inputSelector = await this.findChatInput();
if (!inputSelector) {
throw new Error(
'Could not find visible chat input element. ' +
'Please check if the notebook page has loaded correctly.'
);
}
log.info(` ⌨️ Typing question with human-like behavior...`);
await sendProgress?.('Typing question with human-like behavior...', 2, 5);
await humanType(page, inputSelector, question, {
withTypos: true,
wpm: Math.max(CONFIG.typingWpmMin, CONFIG.typingWpmMax),
});
// Small pause before submitting
await randomDelay(500, 1000);
// Submit the question (Enter key)
log.info(` 📤 Submitting question...`);
await sendProgress?.('Submitting question...', 3, 5);
await page.keyboard.press('Enter');
// Small pause after submit
await randomDelay(1000, 1500);
// DEBUG: Take screenshot after submitting question
try {
const debugPath = process.env.LOCALAPPDATA || 'C:\\Users\\romai\\AppData\\Local';
const screenshotPath = `${debugPath}\\notebooklm-mcp\\Data\\debug-after-submit.png`;
await page.screenshot({ path: screenshotPath, fullPage: true });
log.info(` 📸 Debug screenshot (after submit) saved: ${screenshotPath}`);
} catch (e) {
log.warning(` ⚠️ Could not save debug screenshot: ${e}`);
}
// Wait for the response with streaming detection
log.info(` ⏳ Waiting for response (with streaming detection)...`);
await sendProgress?.('Waiting for NotebookLM response (streaming detection active)...', 3, 5);
const answer = await waitForLatestAnswer(page, {
question,
timeoutMs: 120000, // 2 minutes
pollIntervalMs: 1000,
ignoreTexts: existingResponses,
debug: true, // Enable debug to see exact text
});
if (!answer) {
throw new Error('Timeout waiting for response from NotebookLM');
}
// Check if the answer itself is a rate limit message
if (isRateLimitMessage(answer)) {
log.warning(` ⚠️ Rate limit detected in response: "${answer.substring(0, 50)}..."`);
throw new RateLimitError('NotebookLM daily limit reached - switching to another account');
}
// Check for rate limit errors in page elements AFTER receiving answer
log.info(` 🔍 Checking for rate limit errors...`);
if (await this.detectRateLimitError()) {
throw new RateLimitError(
'NotebookLM rate limit reached (50 queries/day for free accounts)'
);
}
// Update session stats
this.messageCount++;
this.updateActivity();
log.success(
`✅ [${this.sessionId}] Received answer (${answer.length} chars, ${this.messageCount} total messages)`
);
// Extract citations if requested (no additional API calls - just DOM interaction)
let citationResult: CitationExtractionResult | undefined;
if (sourceFormat !== 'none') {
await sendProgress?.('Extracting source citations...', 4, 5);
// Find the response container for citation extraction
const responseContainer = await page.$(
'.to-user-container:last-child .message-text-content'
);
citationResult = await extractCitations(page, answer, responseContainer, sourceFormat);
if (citationResult.success && citationResult.citations.length > 0) {
log.success(` 📚 Extracted ${citationResult.citations.length} source citations`);
}
}
// Return result with optional citation data
const result: AskResult = {
answer: citationResult?.formattedAnswer || answer,
originalAnswer: answer,
citationResult: sourceFormat !== 'none' ? citationResult : undefined,
};
return result;
};
try {
return await askOnce();
} catch (error: unknown) {
const msg = String(error instanceof Error ? error.message : error);
if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
log.warning(` ♻️ Detected closed page/context. Recovering session and retrying ask...`);
try {
this.initialized = false;
if (this.page) {
try {
await this.page.close();
} catch {
/* Ignore errors during cleanup */
}
}
this.page = null;
await this.init();
return await askOnce();
} catch (e2) {
log.error(`❌ Recovery failed: ${e2}`);
throw e2;
}
}
log.error(`❌ [${this.sessionId}] Failed to ask question: ${msg}`);
throw error;
}
}
/**
* Find the chat input element
*
* IMPORTANT: Matches Python implementation EXACTLY!
* - Uses SPECIFIC selectors from Python
* - Checks ONLY visibility (NOT disabled state!)
*
* Based on Python ask() method from browser_session.py:166-171
*/
private async findChatInput(): Promise<string | null> {
if (!this.page) {
return null;
}
// Use EXACT Python selectors (in order of preference)
const selectors = [
'textarea.query-box-input', // ← PRIMARY Python selector
'textarea[aria-label="Feld für Anfragen"]', // ← Python fallback
];
for (const selector of selectors) {
try {
const element = await this.page.$(selector);
if (element) {
const isVisible = await element.isVisible();
if (isVisible) {
// NO disabled check! Just like Python!
log.success(` ✅ Found chat input: ${selector}`);
return selector;
}
}
} catch {
continue;
}
}
log.error(` ❌ Could not find visible chat input`);
return null;
}
/**
* Detect if a rate limit error occurred
*
* Searches the page for error messages indicating rate limit/quota exhaustion.
* Free NotebookLM accounts have 50 queries/day limit.
*
* @returns true if rate limit error detected, false otherwise
*/
private async detectRateLimitError(): Promise<boolean> {
if (!this.page) {
return false;
}
// Error message selectors (common patterns for error containers)
const errorSelectors = [
'.error-message',
'.error-container',
"[role='alert']",
'.rate-limit-message',
'[data-error]',
'.notification-error',
'.alert-error',
'.toast-error',
];
// Keywords that indicate rate limiting (English + French)
// IMPORTANT: Be specific! Generic terms like "quota" can appear anywhere on the page
const keywords = [
'rate limit',
'limit exceeded',
'quota exhausted',
'daily limit reached',
'daily discussion limit',
'too many requests',
'query limit reached',
'request limit reached',
// French keywords - SPECIFIC phrases only
'limite quotidienne de discussions',
'atteint la limite quotidienne',
'vous avez atteint la limite',
'revenez plus tard',
];
// FIRST: Check entire page body for rate limit messages (most reliable)
try {
const bodyText = await this.page.evaluate(`document.body.innerText`);
const bodyLower = (bodyText as string).toLowerCase();
if (keywords.some((k) => bodyLower.includes(k))) {
log.error(`🚫 Rate limit detected in page body!`);
return true;
}
} catch {
// Continue with specific selectors
}
// Check error containers for rate limit messages
for (const selector of errorSelectors) {
try {
const elements = await this.page.$$(selector);
for (const el of elements) {
try {
const text = await el.innerText();
const lower = text.toLowerCase();
if (keywords.some((k) => lower.includes(k))) {
log.error(`🚫 Rate limit detected: ${text.slice(0, 100)}`);
return true;
}
} catch {
continue;
}
}
} catch {
continue;
}
}
// Check chat input for rate limit messages (placeholder, value, or disabled state)
try {
const inputSelector = 'textarea.query-box-input';
const input = await this.page.$(inputSelector);
if (input) {
// Check placeholder text (rate limit message often appears here)
const placeholder = await input.getAttribute('placeholder');
if (placeholder) {
const placeholderLower = placeholder.toLowerCase();
if (keywords.some((k) => placeholderLower.includes(k))) {
log.error(
`🚫 Rate limit detected in input placeholder: "${placeholder.substring(0, 80)}..."`
);
return true;
}
}
// Check input value (sometimes the message is in the field itself)
const inputValue = await input.inputValue();
if (inputValue) {
const valueLower = inputValue.toLowerCase();
if (keywords.some((k) => valueLower.includes(k))) {
log.error(`🚫 Rate limit detected in input value: "${inputValue.substring(0, 80)}..."`);
return true;
}
}
// Check if input is disabled
const isDisabled = await input.evaluate((el) => {
return (el as { disabled?: boolean }).disabled || el.hasAttribute('disabled');
});
if (isDisabled) {
// Check if there's an error message near the input
const parent = await input.evaluateHandle((el) => el.parentElement);
const parentEl = parent.asElement();
if (parentEl) {
try {
const parentText = await parentEl.innerText();
const lower = parentText.toLowerCase();
if (keywords.some((k) => lower.includes(k))) {
log.error(`🚫 Rate limit detected: Chat input disabled with error message`);
return true;
}
} catch {
// Ignore
}
}
}
}
} catch {
// Ignore errors checking input state
}
return false;
}
/**
* Ensure all sources are selected (checkbox checked)
* NotebookLM requires sources to be selected to answer questions
*/
private async ensureSourcesSelected(): Promise<void> {
if (!this.page) return;
try {
log.info(` 📋 Ensuring sources are selected...`);
// Look for "Select all sources" checkbox (French: "Sélectionner toutes les sources")
const selectAllSelectors = [
'text=/Sélectionner toutes les sources/i',
'text=/Select all sources/i',
'text=/Select all/i',
'text=/Tout sélectionner/i',
// Checkbox before the "Select all" text
'mat-checkbox:near(:text("Sélectionner"))',
'mat-checkbox:near(:text("Select all"))',
// First checkbox in sources panel (usually "select all")
'.sources-list mat-checkbox:first-child',
'[class*="source"] mat-checkbox:first-of-type',
];
for (const selector of selectAllSelectors) {
try {
const checkbox = this.page.locator(selector).first();
if (await checkbox.isVisible({ timeout: 2000 })) {
// Check if already selected
const isChecked = await checkbox
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- DOM element in browser context
.evaluate((el: any) => {
// Check various ways to determine if checked
const checkboxEl = el.querySelector('input[type="checkbox"]') || el;
return (
checkboxEl.checked ||
el.classList?.contains('mat-mdc-checkbox-checked') ||
el.getAttribute('aria-checked') === 'true'
);
})
.catch(() => false);
if (!isChecked) {
log.info(` ☑️ Clicking "Select all sources"...`);
await checkbox.click();
await randomDelay(500, 800);
} else {
log.info(` ✅ Sources already selected`);
}
return;
}
} catch {
continue;
}
}
// Fallback: Try clicking on the sources panel header to select all
log.info(` 🔍 "Select all" not found, trying to verify sources manually...`);
} catch (error) {
log.warning(` ⚠️ Could not ensure sources selected: ${error}`);
}
}
/**
* Reset the chat history (start a new conversation)
*/
async reset(): Promise<void> {
const resetOnce = async (): Promise<void> => {
if (!this.initialized || !this.page || this.isPageClosedSafe()) {
await this.init();
}
log.info(`🔄 [${this.sessionId}] Resetting chat history...`);
// Reload the page to clear chat history
await (this.page as Page).reload({ waitUntil: 'domcontentloaded' });
await randomDelay(2000, 3000);
// Wait for interface to be ready again
await this.waitForNotebookLMReady();
// Reset message count
this.messageCount = 0;
this.updateActivity();
log.success(`✅ [${this.sessionId}] Chat history reset`);
};
try {
await resetOnce();
} catch (error: unknown) {
const msg = String(error instanceof Error ? error.message : error);
if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
log.warning(` ♻️ Detected closed page/context during reset. Recovering and retrying...`);
this.initialized = false;
if (this.page) {
try {
await this.page.close();
} catch {
/* Ignore errors during cleanup */
}
}
this.page = null;
await this.init();
await resetOnce();
return;
}
log.error(`❌ [${this.sessionId}] Failed to reset: ${msg}`);
throw error;
}
}
/**
* Close the session
*/
async close(): Promise<void> {
log.info(`🛑 Closing session ${this.sessionId}...`);
if (this.page) {
try {
await this.page.close();
this.page = null;
log.success(` ✅ Page closed`);
} catch (error) {
log.warning(` ⚠️ Error closing page: ${error}`);
}
}
this.initialized = false;
log.success(`✅ Session ${this.sessionId} closed`);
}
/**
* Update last activity timestamp
*/
updateActivity(): void {
this.lastActivity = Date.now();
}
/**
* Check if session has expired (inactive for too long)
* @param timeoutSeconds - Timeout in seconds. 0 means never expires.
*/
isExpired(timeoutSeconds: number): boolean {
if (timeoutSeconds <= 0) {
return false; // 0 or negative timeout means never expires
}
const inactiveSeconds = (Date.now() - this.lastActivity) / 1000;
return inactiveSeconds > timeoutSeconds;
}
/**
* Get session information
*/
getInfo(): SessionInfo {
const now = Date.now();
return {
id: this.sessionId,
created_at: this.createdAt,
last_activity: this.lastActivity,
age_seconds: (now - this.createdAt) / 1000,
inactive_seconds: (now - this.lastActivity) / 1000,
message_count: this.messageCount,
notebook_url: this.notebookUrl,
};
}
/**
* Get the underlying page (for advanced operations)
*/
getPage(): Page | null {
return this.page;
}
/**
* Check if session is initialized
*/
isInitialized(): boolean {
return this.initialized && this.page !== null;
}
}