/**
* Auto-Discovery Module
*
* Automatically generates notebook metadata by querying NotebookLM itself.
* This enables autonomous resource discovery for AI orchestrators.
*
* Progressive Disclosure Pattern (inspired by Claude Skills best practices):
* - Level 0: Lightweight metadata loaded at startup
* - Level 1: Local matching without NotebookLM queries
* - Level 2: Deep query to NotebookLM only when needed
*/
import { SessionManager } from '../session/session-manager.js';
import { log } from '../utils/logger.js';
/**
* Metadata structure auto-generated from NotebookLM
*/
export interface AutoGeneratedMetadata {
name: string; // kebab-case, 3 words max
description: string; // 2 sentences, under 150 chars
tags: string[]; // 8-10 keywords
}
/**
* AutoDiscovery class
* Queries NotebookLM to auto-generate notebook metadata
*/
export class AutoDiscovery {
constructor(private sessionManager: SessionManager) {}
/**
* Query the notebook to auto-generate its metadata
* Includes retry logic (max 2 retries with 2s delay)
*
* @param notebookUrl NotebookLM notebook URL
* @param maxRetries Maximum retry attempts (default: 2)
* @returns Auto-generated metadata
*/
async discoverMetadata(notebookUrl: string, maxRetries = 2): Promise<AutoGeneratedMetadata> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
log.info(`🔍 Auto-discovering metadata (attempt ${attempt + 1}/${maxRetries + 1})...`);
// 1. Create session for this notebook
const session = await this.sessionManager.getOrCreateSession(undefined, notebookUrl);
// 2. Send discovery prompt
const prompt = this.generatePrompt();
log.dim(` Sending discovery prompt to NotebookLM...`);
const askResult = await session.ask(prompt);
const response = askResult.answer;
log.dim(` Received response (${response.length} chars)`);
// 3. Parse and validate response
const metadata = this.parseResponse(response);
this.validateMetadata(metadata);
log.success(`✅ Metadata auto-generated successfully`);
log.dim(` Name: ${metadata.name}`);
log.dim(` Tags: ${metadata.tags.length} keywords`);
return metadata;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (attempt === maxRetries) {
log.error(`❌ Failed to auto-discover metadata after ${maxRetries + 1} attempts`);
throw new Error(
`Auto-discovery failed: ${errorMessage}. Please verify the notebook is accessible.`
);
}
log.warning(`⚠️ Attempt ${attempt + 1} failed: ${errorMessage}. Retrying in 2s...`);
await this.delay(2000);
}
}
throw new Error('Auto-discovery failed after all retries');
}
/**
* Generate the discovery prompt for NotebookLM
*
* This prompt is CRITICAL - it must be ultra-clear with no ambiguity.
* NotebookLM will analyze its own content and return structured metadata.
*/
private generatePrompt(): string {
return `Analyze your complete content and respond ONLY in this JSON format:
{
"name": "example-notebook-name",
"description": "First sentence describes main topic. Second sentence adds key details.",
"tags": ["keyword1", "keyword2", "keyword3", "keyword4", "keyword5", "keyword6", "keyword7", "keyword8"]
}
Rules:
- name: 3 words maximum, kebab-case format (e.g., "react-hooks-guide", "n8n-api-docs")
- description: exactly 2 sentences, total length under 150 characters
- tags: between 8 and 10 keywords covering:
* Core concepts (3-4 tags like "react", "hooks", "state")
* Actions/verbs (2-3 tags like "debugging", "testing")
* Use contexts (2-3 tags like "frontend", "api", "automation")
OUTPUT ONLY VALID JSON. NO OTHER TEXT.`;
}
/**
* Parse and validate the NotebookLM response
*
* Handles common issues:
* - Markdown code blocks wrapping JSON
* - Extra whitespace
* - Invalid JSON syntax
*/
private parseResponse(response: string): AutoGeneratedMetadata {
try {
// 1. Clean markdown code blocks if present
const cleaned = response
.replace(/```json\n?/g, '')
.replace(/```\n?/g, '')
.trim();
// 2. Parse JSON
const parsed = JSON.parse(cleaned);
// 3. Ensure required fields exist
if (!parsed.name || !parsed.description || !parsed.tags) {
throw new Error('Missing required fields (name, description, or tags)');
}
return {
name: parsed.name,
description: parsed.description,
tags: parsed.tags,
};
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(
`Invalid JSON response from NotebookLM. Response: ${response.substring(0, 200)}...`
);
}
throw error;
}
}
/**
* Validate that metadata respects all rules
*
* Throws detailed errors to help debugging
*/
private validateMetadata(metadata: AutoGeneratedMetadata): void {
// Validate name: kebab-case, 3 words max, no spaces
const nameRegex = /^[a-z0-9]+(-[a-z0-9]+){0,2}$/;
if (!nameRegex.test(metadata.name)) {
throw new Error(
`Invalid name format: "${metadata.name}". Must be kebab-case with max 3 words (e.g., "my-notebook" or "my-notebook-name")`
);
}
// Auto-truncate description if too long (NotebookLM doesn't always respect the limit)
if (metadata.description.length > 150) {
log.warning(
`⚠️ Description too long (${metadata.description.length} chars), truncating to 150...`
);
// Truncate at last complete sentence before 147 chars (leave room for "...")
let truncated = metadata.description.substring(0, 147);
const lastPeriod = truncated.lastIndexOf('.');
if (lastPeriod > 50) {
// Keep complete sentence if it's not too short
truncated = truncated.substring(0, lastPeriod + 1);
} else {
// Otherwise just truncate and add ...
truncated = truncated.trim() + '...';
}
metadata.description = truncated;
log.dim(` Truncated to: "${truncated}"`);
}
// Validate tags: 8-10 elements
if (metadata.tags.length < 8 || metadata.tags.length > 10) {
throw new Error(
`Invalid tags count: ${metadata.tags.length} (must be 8-10). Tags: ${metadata.tags.join(', ')}`
);
}
// Validate tags are non-empty strings
for (const tag of metadata.tags) {
if (typeof tag !== 'string' || tag.trim().length === 0) {
throw new Error(`Invalid tag: "${tag}". Tags must be non-empty strings`);
}
}
}
/**
* Simple delay helper for retry logic
*/
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}