# Architecture This document is the canonical map of `notebooklm-py`'s current runtime shape. The historical refactor narrative (including the program that first established this layering) lives in [`docs/refactor-history.md`](./refactor-history.md). ## Layered overview ```text three thin, transport-specific adapters +----------------+ +----------------+ +----------------+ | CLI (cli/) | | MCP (mcp/) | | REST (server/) | | Click commands | | FastMCP tools | | FastAPI routes | +----------------+ +----------------+ +----------------+ \ | / \ | / +----------------+----------------+ ▼ +----------------------------------------------------------+ | Application Layer (src/notebooklm/_app/*) | | Transport-neutral business logic shared by all three | | adapters: id validation/resolution, plan-building, | | status projection, retry/wait orchestration, | | errors.classify (the single failure-category source), | | diagnostics. Imports no click / rich / fastmcp / | | fastapi (boundary lint-enforced; ADR-0021). | +----------------------------------------------------------+ ▼ +----------------------------------------------------------+ | Client Layer (client.py + feature APIs) | | NotebookLMClient + namespaced sub-clients: | | .notebooks .sources .artifacts .chat | | .notes .mind_maps .research .settings | | .sharing .labels | +----------------------------------------------------------+ ▼ +----------------------------------------------------------+ | Runtime Layer (client-owned collaborators) | | ClientComposed + RpcExecutor, RuntimeTransport, | | ClientLifecycle, Kernel. | +----------------------------------------------------------+ ▼ +----------------------------------------------------------+ | RPC Layer (src/notebooklm/rpc/*) | | types.py method IDs + enums (source of truth) | | encoder.py request encoding | | decoder.py response parsing | +----------------------------------------------------------+ ``` Three thin **transport adapters** fan into that one shared core; everything below `_app/` is then identical regardless of which adapter drove the call — there is exactly one client runtime and one RPC stack: | Adapter | Package | Transport | Console script | Install | Failures render as | | --- | --- | --- | --- | --- | --- | | **CLI** | `cli/` | terminal (Click) | `notebooklm` | base | exit codes + the byte-stable `--json` error envelope (ADR-0015) | | **MCP** | `mcp/` | Model Context Protocol (FastMCP) | `notebooklm-mcp` | `mcp` extra · experimental | MCP tool error content (`CODE: message`) | | **REST** | `server/` | HTTP (FastAPI) | `notebooklm-server` | `server` extra · experimental | HTTP status + `{"error": {"category": "...", "message": "..."}}` | ### Transport-neutral application layer (`_app/`) The CLI, the MCP server (`mcp/`), and the REST server (`server/`) are each thin adapters over `src/notebooklm/_app/` — transport-neutral business logic (id validation/resolution, plan-building, status projection, retry/wait orchestration, error classification, diagnostics) shared by all three front-ends. Each adapter parses its transport's inputs into typed `Request`/`Plan`/`Result` dataclasses, calls the neutral core (which receives the live client), and renders the typed result into its own envelope vocabulary; simple reads/mutations call the `client.*` namespaces directly, while multi-step flows go through the `_app/` cores. The package imports no transport framework — `click` / `rich` / `fastmcp` / `fastapi`, nor the `cli` / `server` / `rpc` sibling packages — with the boundary lint-enforced (`tests/_guardrails/test_app_boundary.py`). It raises only the public `notebooklm.exceptions` hierarchy, with `_app.errors.classify` as the single neutral source of the failure-category decision each adapter projects onto its own codes (CLI exit codes, MCP error shapes, REST HTTP statuses). See ADR-0021. The per-module index and the full tree are in [File map](#file-map) below. ## Library call flows `NotebookLMClient` is the composition root. It constructs the shared runtime collaborator graph, wires feature APIs to narrow runtime Protocols, and injects stateful services such as `SourceUploadPipeline`, `NoteService`, `NoteBackedMindMapService`, and `ArtifactDownloadService`. Feature modules build NotebookLM params and parse domain rows; client-owned collaborators own dispatch, transport, auth refresh, metrics, and lifecycle. ### Typed batchexecute RPCs Most public methods (`client.notebooks.list()`, `client.sources.rename()`, `client.settings.get()`, artifact generation, note CRUD, etc.) follow this path: ```text +----------------------------------------------------------------+ | CLI command / MCP tool / REST route / library call | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | NotebookLMClient..() | | feature API / service builds params and chooses RPCMethod | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | RpcExecutor.rpc_call(...) satisfies RpcCaller | | - pre-open guard via Kernel.get_http_client() | | - logical-RPC request id + rpc_calls_started metric | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | RpcExecutor._execute_once(...) | | - idempotency policy resolution | | - method-id resolution, request encoding, URL/body builder | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | RuntimeTransport.perform_authed_post(...) | | - loop-affinity guard, auth snapshot | | - RpcRequest materialization | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | ADR-0009 middleware chain | | Drain -> Metrics -> Sema -> Retry -> AuthRefresh | | -> ErrInj -> Tracing | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | MiddlewareChainHost._authed_post_chain_terminal(...) | | chain leaf — ADR-0014 Rule 4 | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | RuntimeTransport.terminal(...) | | - final auth-freshness rebuild immediately before POST | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | Kernel.post(...) -> _streaming_post -> httpx.AsyncClient | +----------------------------------------------------------------+ | v response unwinds back up +----------------------------------------------------------------+ | RpcExecutor decodes via rpc.decode_response(...) | | Feature API maps decoded payload -> typed/domain result | +----------------------------------------------------------------+ ``` Production wires `RpcExecutor` directly into each feature as its `RpcCaller` per ADR-0014 Rule 1; `NotebookLMClient.rpc_call` dispatches through the same `RpcExecutor` stored as `NotebookLMClient._rpc_executor` for the public raw-RPC escape hatch. `NotebookLMClient.rpc_call(method, params)` is the public raw-RPC escape hatch. It skips feature-specific param builders and result parsers, but still enters the same `RpcExecutor.rpc_call → RuntimeTransport → Kernel` pipeline. ### Chat ask path `ChatAPI.ask()` is the major transport-sharing exception to the pure `RpcExecutor` shape. Streaming chat has a custom request body and chat-flavored error mapping, so the first ask POST goes through: ```text +----------------------------------------------------------------+ | ChatAPI.ask(...) | | - loop_guard.assert_bound_loop() | | - source-id lookup | | - conversation lock / cache | | - reqid.next_reqid() | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | chat_aware_authed_post(transport, ...) | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | RuntimeTransport.perform_authed_post(...) | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | ADR-0009 middleware chain | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | RuntimeTransport.terminal(...) -> Kernel.post | +----------------------------------------------------------------+ | v streaming response +----------------------------------------------------------------+ | streaming chat parser + citation/reference parser | +----------------------------------------------------------------+ ``` `ChatAPI` holds the four collaborators it needs (`rpc`, `transport`, `reqid`, `loop_guard`) directly — there is no `ChatRuntime` composite or broad runtime transport indirection. For a new conversation, `ChatAPI.ask()` then calls `GET_LAST_CONVERSATION_ID` through the normal `RpcExecutor` path. Other chat methods such as `get_conversation_turns()` and `delete_conversation()` also use normal `rpc_call`. ### Uploads, downloads, and polling Some feature workflows intentionally combine RPC with non-RPC HTTP work: | Flow | Runtime shape | |------|---------------| | Source file upload | `SourcesAPI.add_file()` delegates to `SourceUploadPipeline.add_file()`. The pipeline opens an `operation_scope`, takes its own upload semaphore, registers the file source through `runtime.rpc_call(ADD_SOURCE_FILE)`, then uses a dedicated `httpx.AsyncClient` and live Kernel cookies for the Scotty resumable-upload start/finalize calls. Optional wait/rename steps return to `rpc_call`. | | Source URL/text/Drive add | `SourceAddService` wraps URL and Drive mutating RPCs in `idempotent_create(...)` because those flows have stable probes. Text-source adds are intentionally non-idempotent unless the caller handles dedupe externally. | | Artifact generation | `ArtifactsAPI` delegates the `generate_*` / `revise_slide` / `retry_failed` kickoff paths to `ArtifactGenerationService` (`_artifact/generation.py`), which builds `CREATE_ARTIFACT` params (via the `_artifact/payloads.py` builders) and uses the normal `rpc_call` path; the facade keeps thin signature-preserving delegators. `ArtifactPollingService` owns leader/follower polling with `operation_scope(...)` and a feature-local `PollRegistry`; `ArtifactsAPI` registers a close-time drain hook for poll cleanup. | | Artifact download | `ArtifactDownloadService` lists/selects artifacts through `RpcCaller`, but media downloads use a separate streaming `httpx.AsyncClient` with storage cookies, trusted-host checks, and a producer/writer split. They do not go through `RpcExecutor` or `Kernel.post`. | | Notes and mind maps | `NoteService` owns note-row CRUD/classification through `RpcCaller`. `NoteBackedMindMapService` adapts those note rows for artifact-facing mind-map behavior so notes and artifacts do not import each other. | ## Cross-cutting policies Three policies thread through the layers above and are easy to violate by accident. Each is pinned by an ADR. ### Loop affinity (ADR-0004) **Why we need it.** The client is built on `httpx.AsyncClient` plus a network of `asyncio` primitives — locks, semaphores, condition variables, queues, and a keepalive `Task`. Every one of those binds to the event loop on which it is first awaited. Re-using a client across loops either *deadlocks* (the wake-up is scheduled on a loop that will never run again) or raises a confusing `RuntimeError` from deep inside the primitive — both fail far away from the actual cause. The contract is the simplest mitigation that makes the failure mode visible: bind to one loop and fail loudly on the first violating call instead of hanging ten minutes later. The cost of cross-loop safety is paid once at the lifecycle layer instead of in every seam, so individual collaborators can use plain `asyncio.Lock` / `asyncio.Semaphore` without defensive re-binding logic. **The contract.** One `NotebookLMClient` instance is bound to its `open()`-time event loop. Cross-loop reuse (a different `asyncio.run`, a different thread's loop) is unsupported and raises `RuntimeError` at the first authed POST. Cross-thread reuse is unsupported for the same reason — every thread has its own default loop. Cross-tenant reuse is unsupported because a live client owns per-instance chat state and auth state. `ChatAPI._cache` keys on `conversation_id` without an `account_email` dimension, so tenant-switching a client risks mixing local chat history if a conversation id is reused across accounts. The contract is enforced by the free function `assert_bound_loop(...)` in [`_loop_affinity.py`](../src/notebooklm/_loop_affinity.py), which is called from every helper that captured a loop reference at `open()` time (transport drain, reqid counter, auth refresh, artifact polling, chat). The `LoopGuard` capability Protocol (`assert_bound_loop()`) is how feature APIs surface the same check without taking a `Session` dependency. See [ADR-0004](./adr/0004-loop-affinity-contract.md) and the consumer notes in [`docs/python-api.md`](./python-api.md#concurrency-contract). ### Idempotency (ADR-0005) **Why we need it.** `batchexecute` runs over HTTPS, so every mutating call (create, delete, refresh, share, generate, …) is exposed to a *commit-lost* failure: the server commits the write, then the response is lost in transit. A naive retry on top of a commit-lost failure produces a duplicate write — a duplicate notebook, a duplicate source, an extra LLM inference, a re-sent invite email — depending on the RPC. The transport's inner retry loop is *correct* for read-only RPCs and *dangerous* for mutating ones. Before the taxonomy existed, the only mitigation was a per-call-site `disable_internal_retries=True` flag that didn't document *why* a given RPC was retry-unsafe, so the decision was easy to lose during refactors. The taxonomy makes retry safety a **property of the RPC** (declared once in the registry) instead of a **property of the call site** (re-derived every time someone touches the code). **The classification.** Every active RPC is classified into one of five retry-safety profiles by the `IdempotencyRegistry` in [`_idempotency.py`](../src/notebooklm/_idempotency.py): | Policy | Meaning | Effect on the inner retry loop | |--------|---------|--------------------------------| | `UNCLASSIFIED` | Placeholder for hand-built test/future registries; not used by the production registry for active RPCs | Silent, retries enabled (preserves pre-taxonomy behavior) | | `PROBE_THEN_CREATE` | Caller owns a probe loop; transport must not blind-retry | Force-disable inner retries | | `IDEMPOTENT_SET_OP` | Replay-safe read-only, delete, rename, or set-state RPC | Retries are safe; left enabled | | `AT_LEAST_ONCE_ACCEPTED` | Caller has explicitly accepted duplicate side-effect cost (emails / billing / notifications) | Retries enabled; rate-limited WARN emitted so operators can see the trade-off | | `NON_IDEMPOTENT_NO_RETRY` | No dedupe key and no probe; first failure must surface | Force-disable inner retries | The axis is *closed*. A sixth policy would need an ADR update and an executor change in lockstep — the five-policy cap is intentional so a reviewer can hold the whole taxonomy in mind during a code review. `RpcExecutor._execute_once` consults the registry once per call to resolve the effective `disable_internal_retries`. The caller's explicit `disable_internal_retries=True` always wins over the registry default. Every `PROBE_THEN_CREATE` entry must carry a documented `notes` rationale describing how that mutation recovers (a probe/recovery wrapper exists) or why inner retries stay disabled. The registry-audit test `test_retry_disabled_entries_are_intentional_and_documented` fails if a new `PROBE_THEN_CREATE` policy is added without one. The production registry has explicit coverage for every active `RPCMethod`, including read-only RPCs. Read-only entries are registered as replay-safe `IDEMPOTENT_SET_OP` rows rather than left as production-`UNCLASSIFIED`; `UNCLASSIFIED` is retained only as a placeholder for tests and future development. See [ADR-0005](./adr/0005-idempotency-taxonomy.md). Side-effect probing (`idempotent_create(...)`) is a separate mechanism not owned by the registry; see the upload/source-add row in the "Uploads, downloads, and polling" table above. ### Schema validation (ADR-0011) Batchexecute responses are undocumented and Google reshapes them without notice. Decoders walk nested positional lists; a single index shift either crashes with raw `IndexError` from inside a feature module or silently degrades. The single helper that decoders use to navigate row shapes is `notebooklm.rpc.safe_index` in [`rpc/_safe_index.py`](../src/notebooklm/rpc/_safe_index.py). It always raises a typed shape-drift error: strict decoding is the only mode (the legacy soft-mode opt-out was retired in v0.7.0). The `RpcExecutor` decode path narrowly wraps `json.JSONDecodeError`, `KeyError`, `IndexError`, and `TypeError` into `RPCError`; other exception types (e.g. `AttributeError`) intentionally propagate as code bugs rather than being conflated with shape drift. See [ADR-0011](./adr/0011-schema-validation-policy.md). ## Per-capability protocol model ADR-0013 ("Composable Session Capabilities") is the design rationale: feature APIs depend on narrow capability Protocols rather than on the deleted concrete `Session` class. [ADR-0014](./adr/0014-feature-local-runtime-adapters.md) extends that intent at runtime: each feature receives the specific collaborator it needs, never a broad runtime facade. `NotebookLMClient.__init__` is the composition root that wires each feature with the satisfier it needs. Three shared Protocols live in [`_runtime/contracts.py`](../src/notebooklm/_runtime/contracts.py): `Kernel`, `RpcCaller`, and `LoopGuard`. `RpcCaller` and `LoopGuard` meet ADR-0013's "shared by at least two features" promotion bar. `Kernel` remains shared because it is the typed transport surface implemented by the concrete client-owned kernel and consumed by the upload pipeline. Single-consumer capabilities stay beside their owner: `AuthMetadata` lives in `_source/upload.py`, and `OperationScopeProvider` lives in `_artifact/polling.py`. The unused `AsyncWorkRuntime` composite and the feature-local composite runtime Protocols (`ChatRuntime`, `ArtifactsRuntime`, `UploadRuntime`) were deleted once they no longer represented independently varying production dependencies. **Module-level Protocols** (defined in [`_runtime/contracts.py`](../src/notebooklm/_runtime/contracts.py)): | Protocol | Responsibility | |----------|----------------| | `RpcCaller` | Exposes `rpc_call(method, params, ...)` — the chokepoint every feature API uses for batchexecute calls. | | `LoopGuard` | Exposes `assert_bound_loop()` — single-method cross-loop affinity check; consumed by anything that may touch the HTTP client. | | `Kernel` | Pure transport surface — `post()` method, `cookies` property, `aclose()`. Single consumer today: `SourceUploadPipeline`. | **Feature-module-local Protocols.** Single-consumer capability shapes live next to their owner (`AuthMetadata` in `_source/upload.py`, `OperationScopeProvider` in `_artifact/polling.py`). No feature-local composite-runtime unions or adapter dataclasses exist in production. Every multi-capability feature takes its collaborators by keyword-only constructor argument: - `ArtifactsAPI` and `SourceUploadPipeline` take `rpc: RpcCaller`, `drain: TransportDrainTracker`, `lifecycle: ClientLifecycle`. - `ChatAPI` takes `rpc: RpcCaller`, `transport: RuntimeTransport`, `reqid: ReqidCounter`, `loop_guard: LoopGuard`. Production satisfies shared Protocols via the underlying collaborators (ADR-0014 Rule 1: `RpcExecutor` satisfies `RpcCaller`, `ClientLifecycle` satisfies `LoopGuard`, and the concrete `Kernel` satisfies the `Kernel` Protocol). There is no production `Session` class in the runtime graph. Tests substitute [`tests/_fixtures/fake_core.py:FakeSession`](../tests/_fixtures/fake_core.py) (constructed via `make_fake_core(...)`) — the sanctioned ADR-0007 / ADR-0013 fixture pattern. `FakeSession` is a backward-compatible test-fixture name, not a production runtime class. Tests that inject narrow fakes into a single feature (e.g. `MagicMock(spec=RpcCaller, rpc_call=AsyncMock(...))`) construct the feature directly under ADR-0014. ### Executor takes its collaborators directly Per ADR-0014 Rule 5, `RpcExecutor` takes its kernel, transport, auth-refresh coordinator, and metrics tracker directly — there is no Session-shaped owner Protocol. The constructor takes `kernel: Kernel`, `transport: RuntimeTransport`, `auth_refresh: AuthRefreshCoordinator`, and `metrics: ClientMetrics` as keyword-only parameters, plus constructor-injected providers for timeout, refresh-callback enablement, and retry-delay values. The executor enters transport through `RuntimeTransport.perform_authed_post` directly; the middleware terminal is `MiddlewareChainHost._authed_post_chain_terminal → RuntimeTransport.terminal → Kernel.post`. The chain leaf lives on `MiddlewareChainHost` so the chain owns its own terminal and retry tunables (ADR-0014 Rule 4 chain-ownership carve-out). Request types, transport errors, and streaming helpers live in separate owning modules. This keeps feature APIs on narrow capability Protocols and the executor on direct collaborator dependencies. ## Client-owned runtime collaborator graph ```text +---------------------+ | NotebookLMClient | +----------+----------+ | +--------------------------+--------------------------+ | | | v v v _auth: AuthTokens _seams: ClientSeams feature API objects one mutable instance decode/sleep/auth-error notebooks/sources/ runtime callables artifacts/chat/... | v _collaborators: RuntimeCollaborators metrics | drain_tracker | reqid | auth_coord | kernel | lifecycle | cookie_persistence | v Kernel owns httpx.AsyncClient + cookie jar; ClientLifecycle opens/closes it +--------------------------+ | _composed: ClientComposed| +--------------------------+ | transport: RuntimeTransport | executor: RpcExecutor | chain_host: MiddlewareChainHost | chain_builder + middlewares | get_rpc_semaphore() +--------------------------+ | v RpcExecutor.rpc_call → RuntimeTransport.perform_authed_post → ADR-0009 chain → RuntimeTransport.terminal → Kernel.post → httpx ``` | Collaborator | Module | Responsibility | |--------------|--------|----------------| | `NotebookLMClient` | [`client.py`](../src/notebooklm/client.py) | Public surface and composition root. Owns `_auth`, `_seams`, `_composed`, `_collaborators`, `_rpc_executor`, and the ten feature API attributes (`notebooks`, `sources`, `artifacts`, `chat`, `notes`, `mind_maps`, `research`, `settings`, `sharing`, `labels`). `__aenter__`, `close`, `drain`, `is_connected`, `metrics_snapshot`, and `rpc_call` route directly to the owning collaborator. | | `ClientSeams` | [`_client_seams.py`](../src/notebooklm/_client_seams.py) | Mutable holder for runtime callables that closures re-read after construction: `decode_response`, `sleep`, and `is_auth_error`. Construction-only seams such as `async_client_factory` stay on `compose_client_internals(...)` and the client-shell test helper, not on the public constructor. | | `ClientComposed` | [`_client_composed.py`](../src/notebooklm/_client_composed.py) | Write-once holder for composition state: `transport`, `executor`, `chain_host`, `chain_builder`, `middlewares`, lazy RPC semaphore, and `runtime_collaborators`. Pre-binding access raises a clear `RuntimeError`; the holder deliberately does not expose a broad `.collaborators` alias. | | `RpcExecutor` | [`_rpc_executor.py`](../src/notebooklm/_rpc_executor.py) | Single logical batchexecute RPC dispatch path. Owns request-id/started-metric bracketing, idempotency policy lookup, method-ID resolution, request encoding, response decode, RPC error mapping, and decode-time auth refresh retry. Takes its `Kernel`, `RuntimeTransport`, `AuthRefreshCoordinator`, and `ClientMetrics` collaborators directly via keyword-only constructor parameters (ADR-0014 Rule 5). Enters transport through `RuntimeTransport.perform_authed_post`. | | `RuntimeTransport` | [`_runtime/transport.py`](../src/notebooklm/_runtime/transport.py) | Authed POST collaborator. Owns `perform_authed_post()` (loop guard, auth snapshot, request materialization, chain dispatch, queue-wait recording), `refresh_request_for_current_auth()`, and `terminal()` (freshness rebuild + `Kernel.post`). Called directly by `RpcExecutor` and by `chat_aware_authed_post` (ChatAPI's chat-flavoured transport call); the middleware chain leaf at `MiddlewareChainHost._authed_post_chain_terminal` continues to dispatch through `RuntimeTransport.terminal` per ADR-0014 Rule 4. | | `MiddlewareChainHost` | [`_middleware/chain_host.py`](../src/notebooklm/_middleware/chain_host.py) | Owns the wired middleware chain (`_authed_post_chain`), the chain leaf (`_authed_post_chain_terminal`), the three retry-budget tunables (`_rate_limit_max_retries`, `_server_error_max_retries`, `_refresh_retry_delay`), and the dynamic `await_refresh` delegate that the auth-refresh middleware captures. The chain's provider lambdas and the transport's `chain_provider` closure read the host's attributes live, so post-construction mutation (e.g. tests setting `client._composed.chain_host._rate_limit_max_retries = 0`) still steers the live chain. | | `AuthRefreshCoordinator` | [`_runtime/auth.py`](../src/notebooklm/_runtime/auth.py) | Owns the auth-snapshot lock and refresh task. Canonical implementation for `AuthRefreshCoordinator.snapshot(auth=...)`, `update_auth_tokens(auth=..., csrf=..., session_id=...)`, and `update_auth_headers(auth=..., kernel=...)`; callers pass explicit collaborators rather than a host object. | | `ClientLifecycle` | [`_runtime/lifecycle.py`](../src/notebooklm/_runtime/lifecycle.py) | HTTP-client open/close, keepalive task, cookie save coordination. Holds `_timeout`, `_bound_loop`, `_http_client`, `_keepalive_*`. | | `MiddlewareChainBuilder` | [`_middleware/chain.py`](../src/notebooklm/_middleware/chain.py) | Constructs the middleware chain in the canonical ADR-0009 order. | | `TransportDrainTracker` | [`_transport_drain.py`](../src/notebooklm/_transport_drain.py) | Tracks in-flight transport operations + the drain condition variable. Gates graceful shutdown. | | `ClientMetrics` | [`_client_metrics.py`](../src/notebooklm/_client_metrics.py) | Per-instance counters (`ClientMetricsSnapshot`) + the `on_rpc_event` user callback. | | `ReqidCounter` | [`_reqid_counter.py`](../src/notebooklm/_reqid_counter.py) | Monotonic `_reqid` for the chat backend; lock-protected `next_reqid(...)`. | | `CookiePersistence` | [`_cookie_persistence.py`](../src/notebooklm/_cookie_persistence.py) | Cookie-jar persistence + `__Secure-1PSIDTS` rotation. | | `IdempotencyRegistry` | [`_idempotency.py`](../src/notebooklm/_idempotency.py) | Policy/classification registry keyed by `(RPCMethod, operation_variant)`. The production registry explicitly covers every active `RPCMethod`; `UNCLASSIFIED` is retained only as a placeholder for hand-built test/future registries. `RpcExecutor._execute_once()` consults it to resolve `effective_disable_internal_retries`. It is part of the RPC dispatch path, not lifecycle state. Side-effect probing (`idempotent_create(...)`) is a separate mechanism not owned by this registry. | | `_request_types` | [`_request_types.py`](../src/notebooklm/_request_types.py) | Owns `AuthSnapshot`, `BuildRequest`, and request materialization shapes shared by RPC, chat, auth refresh, and the chain terminal. | | `_transport_errors` | [`_transport_errors.py`](../src/notebooklm/_transport_errors.py) | Owns transport-level exceptions, `Retry-After` parsing, and raw `Kernel.post` error mapping consumed by `RetryMiddleware` and `AuthRefreshMiddleware`. | | `_streaming_post` | [`_streaming_post.py`](../src/notebooklm/_streaming_post.py) | Low-level streaming POST helper with the response-size cap used by `Kernel.post`. | | `Kernel` | [`_kernel.py`](../src/notebooklm/_kernel.py) | Pure transport core. Owns the `httpx.AsyncClient` and cookie jar; exposes `post()`, the `cookies` property, and `aclose()` (the close path wraps it in `asyncio.shield` from `ClientLifecycle.close()`). Concrete class behind the `Kernel` Protocol in `_runtime/contracts.py`; constructed by `build_collaborators(...)` and called from the middleware leaf via `RuntimeTransport.terminal → Kernel.post`. | | `_runtime/init` | [`_runtime/init.py`](../src/notebooklm/_runtime/init.py) | Construction-time helpers for `NotebookLMClient`: `validate_constructor_args` (kwarg validation/normalization), `build_collaborators` (the seven collaborators in dependency order: `metrics`, `drain_tracker`, `reqid`, `auth_coord`, `kernel`, `lifecycle`, `cookie_persistence`), `build_runtime_transport`, `wire_middleware_chain`, and `compose_client_internals`. It binds the runtime graph into `ClientComposed` and returns `ClientInternals(collaborators, executor)`. | | `_loop_affinity` | [`_loop_affinity.py`](../src/notebooklm/_loop_affinity.py) | Tiny free-function `assert_bound_loop(bound_loop)` shared by every helper that captures a loop reference at `open()` time (`TransportDrainTracker`, `ReqidCounter`, `AuthRefreshCoordinator`, `ArtifactPollingService`, `ChatAPI`). Enforces ADR-0004 without coupling those helpers to the public client. | ### Shipped runtime invariants [ADR-0016](./adr/0016-auth-identity-and-core-logger-compatibility.md) pins two compatibility-sensitive details that survive the session-elimination work: - `NotebookLMClient._auth` is the authoritative mutable `AuthTokens` instance. Refresh paths mutate that object in place, and collaborators that observe auth must alias it rather than holding detached copies. - `CORE_LOGGER_NAME` intentionally remains the literal `"notebooklm._core"` even though the `_core.py` compatibility module was deleted. Runtime code keeps using this logger key through `CORE_LOGGER_NAME` for downstream log filters and `caplog` selectors. Treat it as a logging compatibility contract, not evidence that `notebooklm._core` is an active module or that a concrete `Session` owner remains in the runtime graph. ## Domain-service collaborators Beyond the client-owned runtime graph, several feature APIs are implemented via dedicated domain services and helper modules: | Service / Module | Module | Responsibility | |-------------------|--------|----------------| | `NoteService` | [`_note_service.py`](../src/notebooklm/_note_service.py) | Service layer managing note CRUD, note-backed content generation, and sync. | | `NoteBackedMindMapService` | [`_mind_map.py`](../src/notebooklm/_mind_map.py) | Specific adapter service representing mind-maps, backed by standard notebook notes. | | `ArtifactDownloadService` | [`_artifact/downloads.py`](../src/notebooklm/_artifact/downloads.py) | Asynchronous download coordinator for finished artifacts. | | `ArtifactGenerationService` | [`_artifact/generation.py`](../src/notebooklm/_artifact/generation.py) | Generation kickoff service (`generate_*`, `revise_slide`, `retry_failed`) extracted from `ArtifactsAPI`. | | `_artifact_formatters` | [`_artifact/formatters.py`](../src/notebooklm/_artifact/formatters.py) | Markdown, HTML, and plain text formatters for artifacts. | | `_artifact/listing` | [`_artifact/listing.py`](../src/notebooklm/_artifact/listing.py) | Listing and filtering operations for notebook artifacts. | | `_row_adapters*` | [`_row_adapters/artifacts.py`](../src/notebooklm/_row_adapters/artifacts.py), [`_row_adapters/chat.py`](../src/notebooklm/_row_adapters/chat.py), [`_row_adapters/labels.py`](../src/notebooklm/_row_adapters/labels.py), [`_row_adapters/notebooks.py`](../src/notebooklm/_row_adapters/notebooks.py), [`_row_adapters/notes.py`](../src/notebooklm/_row_adapters/notes.py), [`_row_adapters/research.py`](../src/notebooklm/_row_adapters/research.py), [`_row_adapters/sources.py`](../src/notebooklm/_row_adapters/sources.py) | Wire-shape adapters that wrap raw batchexecute rows (`ArtifactRow`, `LabelRow`, `NoteRow`, `SourceRow`, the `POLL_RESEARCH` rows, the `SUGGEST_PROMPTS` suggestion rows) and the streamed-chat rows (`AnswerRow`/`CitationRow`/…) behind named accessors so downloads, polling, listing, labels, research, and the chat parser don't open-code positional indices. Strict decode behavior is pinned in `tests/unit/test_row_adapters.py`, `tests/unit/test_chat_row_adapter.py`, `tests/unit/test_notebooks_row_adapter.py`, and `tests/unit/test_research_row_adapter.py`. | | `_research_task_parser` | [`_research_task_parser.py`](../src/notebooklm/_research_task_parser.py) | Parses deep-research task results from raw rows. Returns dict-shaped output today; a typed-model migration is not yet complete. | | `_types/` | [`_types/`](../src/notebooklm/_types) | Private package holding the dataclass and `Protocol` implementations behind the public `types.py` / per-feature public schemas. Split per domain (`artifacts.py`, `chat.py`, `labels.py`, `mind_maps.py`, `notebooks.py`, `notes.py`, `research.py`, `sharing.py`, `sources.py`, plus `common.py` for shared shapes like `ConnectionLimits`). | ## Authentication subpackage [`auth.py`](../src/notebooklm/auth.py) is a thin public facade that re-exports the canonical implementations under [`_auth/`](../src/notebooklm/_auth). ADR-0014 closed ADR-0003's deferred flat-re-export goal: `AuthTokens` and `load_auth_from_storage()` now live in `_auth.tokens`, `_validate_required_cookies` is a direct `_auth.cookie_policy` re-export, and `async def enumerate_accounts` is the only remaining `auth.py` function body because it binds `_poke_session` as the default dependency. | Module | Responsibility | |--------|----------------| | [`_auth/tokens.py`](../src/notebooklm/_auth/tokens.py) | Token dataclass + storage-loading helpers. | | [`_auth/paths.py`](../src/notebooklm/_auth/paths.py) | Storage paths and filesystem helpers. | | [`_auth/storage.py`](../src/notebooklm/_auth/storage.py) | Profile/state persistence on disk. | | [`_auth/extraction.py`](../src/notebooklm/_auth/extraction.py) | Cookie/token extraction from browser sessions. | | [`_auth/headers.py`](../src/notebooklm/_auth/headers.py) | HTTP header construction. | | [`_auth/cookies.py`](../src/notebooklm/_auth/cookies.py) | Cookie maps + `_update_cookie_input` helper. | | [`_auth/cookie_policy.py`](../src/notebooklm/_auth/cookie_policy.py) | Domain allowlist, cookie-domain builder (`build_cookie_domain_allowlist`), and cookie policy decisions. | | [`_auth/browser_capture.py`](../src/notebooklm/_auth/browser_capture.py) | Transport-neutral browser launch→navigate→capture→filter→persist core (lazy `playwright`); shared by the interactive CLI login adapter and the layer-3 headless re-auth layer (ADR-0021). The headless arm classifies the landing URL (authenticated→capture, redirected-to-login→`HeadlessLoginRequiredError`). `run_cdp_capture` is an alternative credential source: attach to an operator-pointed already-running Chrome over CDP (`connect_over_cdp`, disconnect-only teardown) using the SAME landing classification + cookie-domain allowlist. | | [`_auth/headless_reauth.py`](../src/notebooklm/_auth/headless_reauth.py) | Layer-3 (deepest) auth recovery: when first-party cookies are dead, drive a headless browser against the persistent profile to silently re-mint cookies. Typed honest outcomes (`HeadlessReauthStatus` UNAVAILABLE/FAILED/SUCCESS — never silent `None`). Opt-in only (`refresh_auth(allow_headless=True)` or `NOTEBOOKLM_HEADLESS_REAUTH=1`); local-unattended-only, never the remote/MCP auth path. Alternative credential source: `NOTEBOOKLM_HEADLESS_REAUTH_CDP_URL` (or `attempt_headless_reauth(cdp_url=...)`) attaches to an operator-pointed running Chrome instead of the dedicated profile (freshness mitigation). Also exposes `headless_reauth_readiness()` — a credential-free, browser-free probe (profile present + playwright installed) surfaced by `doctor`. | | [`_auth/account.py`](../src/notebooklm/_auth/account.py) | Account profile + multi-account switching. | | [`_auth/session.py`](../src/notebooklm/_auth/session.py) | `refresh_auth_session(auth=..., kernel=..., auth_coord=..., lifecycle=..., cookie_persistence=...)` implementation called by `AuthRefreshCoordinator`. Takes five explicit keyword-only collaborators instead of a Session-shaped owner Protocol; the previous `RefreshAuthCore` Protocol and the `update_auth_tokens` / `update_auth_headers` Session-level forwards have been removed. | | [`_auth/refresh.py`](../src/notebooklm/_auth/refresh.py) | Token refresh driver (external login command, coalesced runs, secret redaction). | | [`_auth/keepalive.py`](../src/notebooklm/_auth/keepalive.py) | Cookie keepalive + `__Secure-1PSIDTS` rotation. | | [`_auth/psidts_recovery.py`](../src/notebooklm/_auth/psidts_recovery.py) | Inline PSIDTS recovery for cold-start (see issue #865). | | [`_auth/master_token.py`](../src/notebooklm/_auth/master_token.py) | Headless master-token auth: mint/persist web cookies from a durable Google master token; layer-4 re-mint recovery (ADR-0023). | The cookie lifecycle — what gets written, who rotates, what the keepalive contract is — is documented separately in [`docs/auth-cookie-lifecycle.md`](./auth-cookie-lifecycle.md). ## CLI layer (ADR-0008) The CLI is intentionally a thin adapter over the public Python client. It does not build raw batchexecute payloads, import the RPC layer, or reach into private `notebooklm._*` implementation modules. Click commands in [`src/notebooklm/cli/*_cmd.py`](../src/notebooklm/cli) own argument parsing, user-visible rendering, JSON envelopes, and exit codes; workflow logic lives in [`src/notebooklm/cli/services/`](../src/notebooklm/cli/services). This separation is the [ADR-0008](./adr/0008-cli-services-extraction-pattern.md) extraction pattern. The console-script entry point is [`notebooklm_cli.py`](../src/notebooklm/notebooklm_cli.py). It declares the root `notebooklm` Click group with [`SectionedGroup`](../src/notebooklm/cli/grouped.py), owns process-wide options (`--storage`, `--profile`, `--verbose`, `--quiet`), canonicalizes the storage path into `ctx.obj`, stores the selected profile/quiet values there, and registers the top-level commands plus command groups. `SectionedGroup` is a presentation concern only: it bins commands in help output, and [`tests/unit/cli/test_grouped.py`](../tests/unit/cli/test_grouped.py) rejects new unbinned commands. A typical authenticated command follows this path: ```text +----------------------------------------------------------------+ | notebooklm_cli.cli root group | | - SectionedGroup | | - process-wide options: | | --storage / --profile / --verbose / --quiet | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | cli/_cmd.py Click command | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | cli.auth_runtime.with_auth_and_errors(...) | | or run_client_workflow(...) | | - handle_errors(...) wraps command-body failures | | - AuthSource resolves precedence: | | --storage > NOTEBOOKLM_AUTH_JSON > active profile storage | | - get_auth_tokens(...) builds AuthTokens | | - cli.runtime.run_async(...) -> one top-level asyncio.run | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | async with NotebookLMClient(auth) as client | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | cli/services/.py plan/executor | | or direct public client call | +----------------------------------------------------------------+ | v +----------------------------------------------------------------+ | command module: | | - renders text / JSON | | - applies exit-code policy | +----------------------------------------------------------------+ ``` | Layer | Owns | Does NOT own | |-------|------|--------------| | `notebooklm_cli.py` | Root Click group, global options, profile/storage setup, command registration | Per-command workflows, rendering of command results | | `cli/*_cmd.py` | Click decorators, option parsing, stdout/stderr rendering, JSON output, exit codes | Business logic, RPC dispatch, retry loops | | `cli/services/*.py` | Workflow orchestration, plan dataclasses, result types, retry/wait policy | Click context, `console.print`, `SystemExit` (target end-state; some modules are still mid-migration) | Command modules are named `*_cmd.py` (e.g. `source_cmd.py`, `notebook_cmd.py`) to avoid Python's package-attribute shadowing — the historical short names (`source`, `notebook`, …) are re-exported from `cli/__init__.py` so existing imports keep working. The shadowing invariant is pinned by `tests/_guardrails/test_no_module_shadowing.py`. CLI services are organised by feature family; notable examples include `cli/services/login/` (browser-profile enumeration split across Chromium and Firefox cookie jars), `cli/services/source_*` (URL/file/research source flows), and `cli/services/generate.py`. The CLI service-layer boundary is guarded by [`tests/unit/cli/test_services_boundary.py`](../tests/unit/cli/test_services_boundary.py): new service modules must either be fully cleaned of Click/rendering/exit ownership or be added to the explicit transitional inventory with the current violations and rationale. The cross-command helpers form a small internal CLI stack: | Module | Role | |--------|------| | [`cli/runtime.py`](../src/notebooklm/cli/runtime.py) | Leaf runtime helpers: root `--quiet` lookup and the single `asyncio.run(...)` bridge for sync Click handlers. | | [`cli/auth_runtime.py`](../src/notebooklm/cli/auth_runtime.py) | Shared auth bootstrap, command-body error wrapping, and optional opened-client workflow helper. | | [`cli/master_token_login.py`](../src/notebooklm/cli/master_token_login.py) | Command driver for `notebooklm login --master-token[-refresh]`, rendering over the master-token login service [`cli/services/login/master_token.py`](../src/notebooklm/cli/services/login/master_token.py) (mint/persist/refresh + browser `oauth_token` capture; ADR-0023). | | [`cli/services/auth_source.py`](../src/notebooklm/cli/services/auth_source.py) | Single resolver for CLI auth-source precedence (`--storage`, `NOTEBOOKLM_AUTH_JSON`, active profile). | | [`cli/context.py`](../src/notebooklm/cli/context.py) | Profile/storage-scoped `context.json` persistence for active notebook, conversation, and account metadata. | | [`cli/resolve.py`](../src/notebooklm/cli/resolve.py) | Notebook/source/artifact/note ID resolution, including partial-ID matching against public client list calls. | | [`cli/options.py`](../src/notebooklm/cli/options.py) + [`cli/completion.py`](../src/notebooklm/cli/completion.py) | Shared Click option decorators and best-effort shell completion. Completion providers may load auth and list public client resources, but swallow all failures so shells never print diagnostics during TAB completion. | | [`cli/rendering.py`](../src/notebooklm/cli/rendering.py) | Rich/text/JSON rendering helpers. Status lines in JSON mode go to stderr so stdout remains parseable JSON. | | [`cli/error_handler.py`](../src/notebooklm/cli/error_handler.py) | Canonical CLI error-to-exit mapping. Under `--json`, command-body failures use the typed error envelope from [ADR-0015](./adr/0015-json-envelope-contract-for-post-parse-click-exceptions.md). Parse-time Click parser errors remain Click-owned. | | [`cli/helpers.py`](../src/notebooklm/cli/helpers.py) | Backward-compatible facade for historical imports and test patch targets. New production code should import from the owning helper module instead. | The boundary is enforced statically by [`tests/_guardrails/test_cli_boundary.py`](../tests/_guardrails/test_cli_boundary.py): CLI modules may import public `notebooklm` modules and their own intra-CLI private helpers, but not `notebooklm._*`, `notebooklm.rpc.*`, or private names from public modules. **Two sanctioned exceptions** to the `notebooklm._*` rule are whitelisted in that gate: `notebooklm._app` (the transport-neutral business-logic layer every adapter consumes) and the single module `notebooklm._auth.browser_capture` (the transport-neutral browser launch→capture→filter→persist core that the Playwright login adapter [`cli/services/playwright_login.py`](../src/notebooklm/cli/services/playwright_login.py) sits over, per ADR-0021 — interactive presentation stays in `cli/` while the neutral core moves down to `_auth`, reachable by the client runtime and the future headless re-auth layer). No other `_auth.*` module may be imported by the CLI — the rest stays behind the `auth.py` facade. The same test keeps low-level helpers (`runtime`, `context`, `resolve`, `rendering`, `auth_runtime`, `options`) from growing upward dependencies on command modules or the `cli.helpers` compatibility facade. ## MCP adapter (`mcp/`) The MCP server is a second thin adapter beside `cli/`, opt-in behind the `mcp` extra and **experimental** (preview). `create_server()` builds a FastMCP server that exposes the `_app/` cores as MCP tools driving a single long-lived `NotebookLMClient`; run it with the `notebooklm-mcp` console script (stdio or loopback HTTP). It imports no `click` / `rich` / `cli` — like the CLI, it is built on the `_app/` cores only (enforced by `tests/_guardrails/test_mcp_boundary.py`). Failures surface as `CODE: message` strings projected from `_app.errors.classify`, and mutating tools are confirmation-gated (they return a `needs_confirmation` preview unless called with `confirm=true`). `notebooklm mcp install ` wires it into Claude Desktop/Code, Cursor, or Windsurf, and `desktop-extension/` packages a one-click `.mcpb` bundle. Full guide: [`docs/mcp-guide.md`](./mcp-guide.md). ## REST server (`server/`) The single-tenant REST server is the third adapter (ADR-0021), opt-in behind the `server` extra and **experimental**. A FastAPI app maps `/v1` routes onto the `_app/` cores and the public client namespaces, with one `NotebookLMClient` opened once at the ASGI lifespan inside the server loop (honoring the ADR-0004 loop- affinity contract). Every `/v1` request requires a static bearer token (constant-time compare) plus a loopback `Host` literal (a DNS-rebinding guard); `/healthz` is the one public route, and the `/docs` / `/openapi.json` schema surface is disabled. Long-running work (source ingest, artifact generation) uses the **poll-the-resource** model — the create call returns immediately and the matching `GET` reports `pending` / `200` / `404` / `409` / `410`. Failures project from `_app.errors.classify` onto an HTTP status plus the `{"error": {"category": "...", "message": "..."}}` envelope. It imports no `click` / `rich` / `cli` (enforced by `tests/_guardrails/test_server_boundary.py`). Launch and configuration: [`docs/installation.md`](./installation.md#rest-api-server). Expensive route groups have lifespan-owned concurrency limiters, tuned by `NOTEBOOKLM_SERVER_*_CONCURRENCY` env vars, so source mutation/wait, artifact generation/download, research, and blocking chat work cannot unboundedly starve cheap reads or `/healthz`. ## Middleware chain (ADR-0009) The runtime chain order is pinned by [`tests/unit/test_chain_wiring.py`](../tests/unit/test_chain_wiring.py) (facade-level) and [`tests/unit/test_middleware_chain_builder.py`](../tests/unit/test_middleware_chain_builder.py) (builder-level). The order is load-bearing: changing it without simultaneously updating the pin tests (`test_chain_seeded_with_final_adr_009_ordering`) is a bug. The chain list in [`MiddlewareChainBuilder.build()`](../src/notebooklm/_middleware/chain.py) reads outermost-first (index 0 wraps everything below it): ```text DrainMiddleware outermost — admits and tracks for shutdown drain ↓ MetricsMiddleware starts timing here (latency includes queue wait) ↓ SemaphoreMiddleware max_concurrent_rpcs slot acquired AFTER Drain/Metrics, BEFORE Retry can re-enter (one slot per logical RPC) ↓ RetryMiddleware 429 / 5xx with Retry-After honor ↓ AuthRefreshMiddleware refresh-on-auth-error; capped retries ↓ ErrorInjectionMiddleware synthetic-error harness; no-op in prod ↓ TracingMiddleware innermost — structured-logging boundary (OpenTelemetry export is future work) ↓ Authed POST leaf (RuntimeTransport.terminal → Kernel → httpx) ``` ## Client as composition root `NotebookLMClient` is both the public surface and the composition root. It owns `ClientComposed`, the collaborator bundle, the RPC executor, and the feature API instances. `ClientLifecycle` owns open/close behavior (loop-affinity binding, keepalive task, cookie persistence, and transport teardown); `TransportDrainTracker` owns drain semantics. Concretely, the client-owned runtime retains: 1. **Late-bound composition slots.** `ClientComposed.transport`, the chain metadata slots (`chain_builder` / `middlewares`), and `ClientComposed.executor` are bound exactly once by `compose_client_internals(...)` through write-once binders. Pre-binding access trips the `ClientComposed` guard. `ClientComposed` exposes `runtime_collaborators`, not a broad `collaborators` alias. [`tests/_guardrails/test_client_composition.py`](../tests/_guardrails/test_client_composition.py) guards against inlining holder state back onto `NotebookLMClient`. 2. **Middleware-chain seams.** The chain leaf (`_authed_post_chain_terminal`), the chain slot (`_authed_post_chain`), the dynamic refresh delegate (`await_refresh`), and the three retry-budget tunables (`_rate_limit_max_retries`, `_server_error_max_retries`, `_refresh_retry_delay`) live on `MiddlewareChainHost`. `wire_middleware_chain` and `build_runtime_transport` take that host directly and read its attributes live. 3. **Lifecycle methods.** Public client `__aenter__`, `__aexit__`, `close`, `drain`, and `is_connected` call `ClientLifecycle` and `TransportDrainTracker` directly. `NotebookLMClient.rpc_call(method, params)` dispatches directly through `self._rpc_executor.rpc_call(...)` — the `RpcExecutor` captured during the shared `_client_assembly.py::_assemble_client(...)` construction path from `compose_client_internals(...)` and shared with every feature API. Feature APIs receive the collaborator they need (`RpcExecutor` for `RpcCaller`, `ClientLifecycle` for `LoopGuard`, the concrete `Kernel` for upload cookies/posting, and `TransportDrainTracker` for local operation scopes / close hooks) per ADR-0014 Rules 1 + 3. Features that need more than one capability — `ChatAPI`, `ArtifactsAPI`, and `SourceUploadPipeline` — take each collaborator by keyword-only constructor argument. The composition wiring is centralized in [`_client_assembly.py`](../src/notebooklm/_client_assembly.py), which is called by both `NotebookLMClient.__init__` and the canonical test factory. ## Testing patterns Two policies define how tests interact with the architecture above. ### Constructor-injection fixtures (ADR-0007) The forbidden patterns are `monkeypatch.setattr("notebooklm.…")` against module-level seams and direct attribute assignment like `target.rpc_call = AsyncMock(...)`. The sanctioned substitute is [`tests/_fixtures/fake_core.py:make_fake_core(...)`](../tests/_fixtures/fake_core.py), which returns a `FakeSession` configured to satisfy the narrow shared protocols plus the upload/polling local protocols used by legacy feature tests. The name is backward-compatible test vocabulary; it is not a production `Session` replacement. Multi-capability features (`ChatAPI`, `ArtifactsAPI`, `SourceUploadPipeline`) take their direct collaborators by keyword-only constructor argument, so unit tests can inject narrow `MagicMock(spec=RpcCaller, rpc_call=AsyncMock(...))`-style fakes directly via those constructors. The meta-lint at `tests/_guardrails/test_no_forbidden_monkeypatches.py` enforces the policy; the file-level allowlist shrinks as legacy tests migrate. See [ADR-0007](./adr/0007-test-monkeypatch-policy.md). ### Test suite taxonomy - **Unit tests** (`tests/unit/`): No network, decode/encode only. - **Integration tests** (`tests/integration/`): Mock HTTP responses or use VCR cassettes scrubbed per [ADR-0006](./adr/0006-vcr-scrubber-strategy.md). - **E2E tests** (`tests/e2e/`): Real API; require auth; marked `@pytest.mark.e2e` and excluded from the default run. Pin tests that lock architectural invariants (chain ordering, narrow Protocol membership, no forbidden monkeypatch) live in `tests/unit/` and `tests/_guardrails/` — changing the underlying invariant without updating the pin is a bug. A fuller taxonomy can be generated with [`scripts/test_taxonomy_inventory.py`](../scripts/test_taxonomy_inventory.py). ## Implementation surface convention (ADR-0012) `notebooklm-py` keeps a small set of public-named modules (`artifacts.py`, `auth.py`, `client.py`, `config.py`, `exceptions.py`, `io.py`, `log.py`, `migration.py`, `notebooklm_cli.py`, `paths.py`, `research.py`, `types.py`, `urls.py`, `utils.py`) and routes everything else through underscore-prefixed seam modules. Anything underscored is *not* a supported import surface; it can be moved, renamed, or deleted without a deprecation cycle. See [ADR-0012](./adr/0012-implementation-surface-convention.md). The corollary for contributors: if you find yourself reaching into `notebooklm._foo`, prefer a capability Protocol or a public function in one of the named modules. ## Boundary moratorium New architectural carve-outs are expensive: every ADR amendment and `tests/_guardrails/` pin becomes load-bearing for contributors who have to read the docs before touching the relevant seam. To keep that surface from drifting upward without bound, the following discipline applies to any future change that would *expand* the documented boundary set: - **Justify by failure mode.** A new ADR amendment or `tests/_guardrails/` pin must cite a concrete user-visible failure mode it prevents (loop-affinity break, auth-snapshot tear, transport drain regression, public-API breakage, etc.). "Future-proofing" or "in case someone refactors X" is not sufficient. - **Prefer deletion over carve-out.** When a compatibility seam can be removed instead of documented, remove it. Carve-outs are the fallback when removal is genuinely infeasible, not the default. - **One owner per rule.** A pin without a corresponding ADR clause (and vice versa) is a smell — it means the rule is enforced but not explained, or explained but not enforced. The intent is architectural: shrink the boundary set whenever the underlying code allows it, and resist growing it on speculative grounds. ## Glossary Vocabulary that recurs in this document and the surrounding code. | Term | Meaning | |------|---------| | `batchexecute` | Google's internal RPC protocol over HTTPS. The wire is positional lists keyed by an obfuscated method id; see [`rpc/types.py`](../src/notebooklm/rpc/types.py). | | Capability Protocol | A narrow structural `Protocol` (e.g. `RpcCaller`, `LoopGuard`) a feature depends on instead of taking the deleted concrete `Session` class or a broad runtime facade. See [ADR-0013](./adr/0013-composable-session-capabilities.md). | | Chain / leaf / terminal | The middleware chain's ordering vocabulary. The chain wraps outermost-first; the **leaf** is the innermost middleware (`TracingMiddleware`); the **terminal** is the authed-POST function (`RuntimeTransport.terminal → Kernel.post`) that ends the chain. | | Drain | Graceful-shutdown waiting on in-flight transport operations to complete. Owned by `TransportDrainTracker` and admitted by `DrainMiddleware`. | | `idempotent_create(...)` | Caller-owned probe-then-create wrapper used by source-add / Drive-add flows. Distinct from the `IdempotencyRegistry` (which only classifies retry safety inside the executor). | | `operation_variant` | Optional kwarg on `rpc_call(...)` that selects a method-variant-specific idempotency policy from the registry (e.g. `ADD_SOURCE` `"url"` vs `"drive"`). Unknown variants raise `IdempotencyVariantError`. | | RPC method id | A short obfuscated identifier (`rpcids=`) Google uses to route batchexecute calls. Source of truth: `RPCMethod` enum in `rpc/types.py`. | | Snapshot | An `AuthSnapshot` (see [`_request_types.py`](../src/notebooklm/_request_types.py)) — an immutable, point-in-time view of session id, CSRF token, authuser, and account email. Taken inside the auth-snapshot lock so a refresh racing with a transport build cannot tear. | ## File map Per-file index plus the full `src/notebooklm` + `tests` repository tree. The tree is the hand-maintained module map that [`scripts/check_claude_md_freshness.py`](../scripts/check_claude_md_freshness.py) checks in both directions (documented paths exist; every module is documented or intentionally omitted). ### Key Files | File | Purpose | |------|---------| | `client.py` | Main `NotebookLMClient` class | | `_client_assembly.py` | Single private assembly seam (`_assemble_client`) that wires every constructor-set attribute; shared by `NotebookLMClient.__init__` and the canonical test factory (`tests/_helpers/client_factory.py`) so the two construction paths cannot drift. | | `_client_composed.py` | Client-owned composition holder for transport, executor, chain host, middleware metadata, and runtime collaborator bundle. | | `_client_seams.py` | Constructor-only injectable seams used by tests and collaborator construction. | | `_runtime/init.py` | Constructor helpers that validate client runtime kwargs, build collaborators (returning a `RuntimeCollaborators` bundle), wire middleware, and bind `ClientComposed`. | | `_kernel.py` | Concrete `Kernel` transport core (owns `httpx.AsyncClient` + cookie jar) | | `_runtime/config.py` | `DEFAULT_*` knobs and module-level constants. `CORE_LOGGER_NAME = "notebooklm._core"` is intentionally preserved as a compatibility logging contract even though the `_core` module was deleted; renaming it silently breaks downstream `caplog`/logger filters. | | `_env.py`, `config.py` | Runtime environment defaults and the public config re-export surface | | `_logging.py`, `log.py` | Redaction/correlation logging internals and the public logging helper surface | | `_secrets.py` | Canonical runtime registry of must-scrub bare session-cookie names (`RUNTIME_SESSION_COOKIES`), `__Secure-*` / `__Host-*` prefix umbrellas (`SECURE_HOST_UMBRELLA_PATTERNS`, fail-closed for future names), and carrier-agnostic Google credential shapes (`AUTH_TOKEN_SHAPE_PATTERNS` — `g.a000-` / `sidts-` / `ya29.` tokens + the `AIza…` API key) that `_logging.py` redaction and `exceptions.py` scrubbing DERIVE from. Runtime code cannot import from `tests/`, so this restates the cassette sanitizer's must-scrub shapes; `tests/_guardrails/test_runtime_secret_registry_parity.py` asserts lockstep with `tests/cassette_patterns.py` on every axis — bare-cookie superset, umbrella coverage, and regex-string shape equality (issues #1517/#1518). | | `_callbacks.py` | Sync-or-async callback invocation helper used by telemetry/retry hooks | | `_lookup.py` | `unwrap_or_raise(obj, exc)` — the shared single-row-lookup helper backing the public `get`/`get_or_none` pair (ADR-0019 Enforcement tier-2). The four `sources`/`artifacts`/`notes`/`mind_maps` `get()` methods call it directly to raise their `*NotFoundError` on a miss (the v0.8.0 flip, issue #1247); `notebooks.get()` already raised on its own path and does not route through it. | | `_loop_bound.py` | `LoopBoundPrimitive` — template-method base for the loop-affinity `set_bound_loop` protocol. Owns the `_bound_loop` field + a `set_bound_loop` that always stores the binding and fires the `_on_loop_rebind(old, new)` hook only on a real loop change (hook before store). Trivial owners (`TransportDrainTracker`/`ReqidCounter`/`AuthRefreshCoordinator`) use the default no-op hook; clear-on-rebind owners (`ClientComposed`/`SourceUploadPipeline`/`ChatAPI`) override it to discard their cached loop-bound primitive/locks. Owns only the binding + rebind hook — the cross-loop *assert* stays in `_loop_affinity`, and each owner keeps its own `reset_after_open`. | | `_deprecation.py` | Deprecation helper, gated by `NOTEBOOKLM_QUIET_DEPRECATIONS`: `warn_deprecated` — generic gated primitive for one-off deprecations (e.g. awaiting `from_storage(...)`); pass `removal=None` when no removal version is pinned yet. `deprecations_quiet` / `_deprecations_quiet` / `_QUIET_ENV_VAR` back the suppression gate. ADR-0018 forbids inline `warnings.warn(..., DeprecationWarning)` outside this module — `tests/_guardrails/test_no_inline_deprecation_warnings.py` enforces it (governs the `DeprecationWarning` category only; an inline `RuntimeWarning`/`UserWarning` is allowed). Note `save_cookies_to_storage(original_snapshot=None)` is NOT a deprecation — it's a permanent back-compat shim emitting an inline `RuntimeWarning` race advisory, outside ADR-0018 scope and ungated (issue #1369). The v0.7.0 error-contract machinery (`NOTEBOOKLM_FUTURE_ERRORS`/`future_errors_enabled`, `warn_get_returns_none`, `deprecated_kwarg`, `MappingCompatMixin`) was **removed in v0.8.0** once every break it staged became the default (issue #1365). See `docs/deprecations.md`. | | `_runtime/helpers.py` | `is_auth_error`, `AUTH_ERROR_PATTERNS`, `_resolve_keepalive_interval` | | `_error_injection.py` | Synthetic-error env-var resolver + startup guard | | `_client_metrics.py` | `ClientMetrics` — `ClientMetricsSnapshot` counters + `on_rpc_event` callback | | `_transport_drain.py` | `TransportDrainTracker` — in-flight transport counters + `_TransportOperationToken` | | `_deadline.py` | `RuntimeDeadline` helper shared by retry and polling loops so aggregate timeouts clamp sleep consistently | | `_backoff.py` | Shared capped exponential-backoff calculation with deterministic test injection | | `_reqid_counter.py` | `ReqidCounter` — monotonic `_reqid` for the chat backend | | `_runtime/auth.py` | `AuthRefreshCoordinator` — refresh task + auth-snapshot lock | | `_auth_refresh_retry.py` | Shared auth refresh-and-retry core for the two retry layers (HTTP-status `AuthRefreshMiddleware` + decoded-RPC `RpcExecutor`): the once-per-logical-call `RefreshBudget` token and the common `refresh_and_count` body (log/refresh/sleep/`rpc_auth_retries` metric). Unifies the previously-divergent copies per issue #1205; the two layers keep their distinct triggers and refresh-failure exception shapes. | | `_runtime/lifecycle.py` | `ClientLifecycle` — loop-affinity guard + keepalive task | | `_runtime/transport.py` | `RuntimeTransport` — authed-POST transport wrapper that drives the middleware chain and typed transport response handling | | `_rpc_executor.py` | RPC dispatch executor. Takes its `Kernel`, `RuntimeTransport`, `AuthRefreshCoordinator`, and `ClientMetrics` collaborators directly via keyword-only constructor parameters (ADR-0014 Rule 5). Defines a single local `DecodeResponse` Protocol. | | `_request_types.py` | Shared authed POST request construction types: `AuthSnapshot`, `BuildRequest`, `PostBody`, and materialization helpers. | | `_transport_errors.py` | Transport exceptions, `Retry-After` parsing, and terminal `Kernel.post` error mapping for retry/auth middleware. | | `_streaming_post.py` | Size-capped streaming POST helper used by `Kernel.post`. | | `_middleware/core.py` | HTTP-shaped middleware request/response envelope, chain composition, and middleware Protocol | | `_middleware/context.py` | Canonical per-request context-key vocabulary for middleware | | `_middleware/chain_host.py` | Mutable owner for the live middleware chain slots and retry-budget tunables | | `_conversation_cache.py` | Per-instance true-LRU conversation cache for `ChatAPI` (caps conversation count via `MAX_CONVERSATION_CACHE_SIZE` and per-conversation turns via `MAX_TURNS_PER_CONVERSATION`) | | `_polling_registry.py` | Pending-poll registry for long-running artifact generations | | `_cookie_persistence.py` | Cookie-jar persistence + `__Secure-1PSIDTS` rotation | | `_runtime/contracts.py` | Shared runtime Protocols consumed by sub-clients | | `_idempotency.py` | Mutating-RPC idempotency policy registry and probe-then-retry wrapper; ADR-0005 is the taxonomy source | | `_idempotency_policy.py` | Declarative per-RPC idempotency classification data, applied to `IDEMPOTENCY_REGISTRY` via `register_default_policies` at `_idempotency` import time (#1331). Holds the load-bearing two-pass seeding order (pre-seed `register()` → `_seed_defaults()` → post-seed `register()` + the read/set-op loop). | | `_atomic_io.py`, `io.py` | Atomic JSON write/update internals and public I/O re-export surface for CLI boundary compliance | | `exceptions.py` | Public exception hierarchy plus safe diagnostic preview/redaction helpers | | `paths.py`, `migration.py` | Profile-aware path resolution and locked migration from the legacy flat layout | | `_types/`, `types.py` | Dataclass implementation package and public type/re-export facade | | `_types/labels.py` | `Label` pure-value type (source-label topic grouping; `source_ids` only, no artifact members) re-exported by `types.py` | | `_row_adapters/artifacts.py` | `ArtifactRow` typed view over raw positional artifact RPC rows, plus `ReportSuggestionRow` over `GET_SUGGESTED_REPORTS` rows | | `_row_adapters/chat.py` | Streamed-chat row adapters (`AnswerRow` / `CitationRow` / `CitationDetail` / `PassageRow` / `StreamFrameRow` / `ErrorPayloadRow` / `TextLeafRow`) that centralise the chat wire positions `_chat/wire.py` used to open-code (#1491) | | `_row_adapters/labels.py` | `LabelRow` strict typed view over the raw positional label tuple `[name, sources, id, emoji]` (fails loud on schema drift) | | `_row_adapters/notebooks.py` | `SUGGEST_PROMPTS` (`otmP3b`) suggestion-row view (`PromptSuggestionRow` / `unwrap_prompt_suggestions`) backing `NotebooksAPI.suggest_prompts` | | `_row_adapters/notes.py` | `NoteRow` typed view over raw positional note and mind-map RPC rows | | `_row_adapters/research.py` | `ResearchTaskRow` / `ResearchTaskInfoRow` / `ResearchResultRow` typed views over raw positional `POLL_RESEARCH` rows that centralise the single-level positions `_research_task_parser.py` used to open-code (#1501) | | `_row_adapters/sources.py` | `SourceRow` / `SourceRowShape` typed views over raw positional source RPC rows | | `artifacts.py`, `research.py`, `utils.py` | Public helper modules for artifact retry, research citation/report utilities, and common async helpers | | `_research_task_parser.py` | Internal parser for research task result-type selection | | `_notebooks.py` | `client.notebooks` API + source-id resolver | | `_notebook_payloads.py` | Stable `batchexecute` notebook RPC request payload builders (currently `SUGGEST_PROMPTS`) | | `_sources.py` | `client.sources` API | | `_artifacts.py` | `client.artifacts` API — owns artifact generation orchestration directly (see ADR-0012) | | `_chat/api.py` | `client.chat` API | | `_research.py` | `client.research` API | | `_notes.py` | `client.notes` API | | `_sharing.py` | `client.sharing` API | | `_labels.py` | `client.labels` API — source labels (topic groupings); pure-RPC like `SharingAPI`, plus a narrow `list_sources` callable for the membership→`Source` join in `sources()` | | `_settings.py` | `client.settings` API | | `_note_service.py` | Service layer managing note CRUD, note-backed content generation, and sync | | `_mind_map.py` | Specific adapter service representing mind-maps, backed by standard notes | | `_mind_maps_api.py` | `client.mind_maps` API — unified surface over both mind-map backends (note-backed JSON + interactive studio-artifact), dispatching each op to the correct RPC family (#1256) | | `_artifact/downloads.py` | Asynchronous download coordinator for finished artifacts | | `_artifact/_redirect_guard.py` | Per-redirect-hop host/scheme revalidation for downloads — rejects off-allowlist / non-HTTPS redirect targets before the request is sent (#1521) | | `_artifact/_download_client.py` | Download trusted-host allowlist + transport-aware client factory — wires the #1521 redirect guard for httpx (event hook) or the opt-in curl_cffi (`get_guarded` manual loop) | | `_artifact/formatters.py` | Markdown, HTML, and plain text formatters for artifacts | | `_artifact/payloads.py` | Stable CREATE_ARTIFACT / GENERATE_MIND_MAP request payload builders | | `_artifact/validation.py` | Input-validation guards for the `ArtifactsAPI` facade (`generate_report` format coercion, `export` exactly-one-of target), kept in a sibling module so the facade stays under the module-size ratchet (#1874) | | `_artifact/generation.py` | Generation kickoff service (`generate_*`, `revise_slide`, `retry_failed`) extracted from `ArtifactsAPI`; the facade keeps thin delegators | | `_artifact/listing.py` | Listing and filtering operations for notebook artifacts | | `_artifact/polling.py` | Poll coordination service for artifact generation tasks | | `_source/add.py` | Core service layer for adding text, URL, or Google Drive sources | | `_source/content.py` | Core service layer for fetching source HTML/markdown content | | `_source/listing.py` | Core service layer for listing notebook sources | | `_source/polling.py` | Poll coordination service for active source conversions | | `_source/upload.py` | Concurrency-gated upload pipeline for source files | | `_source/_upload_decode.py` | Pure decode/validation helpers for the upload pipeline (URL redaction, ADD_SOURCE_FILE source-id extraction, content-type policy), extracted from `upload.py` | | `_source/upload_payloads.py` | Stable source upload registration, rename, and resumable-upload request builders | | `_label/params.py` | Stable CREATE_LABEL / LIST_LABELS / UPDATE_LABEL / DELETE_LABEL request payload builders (with the shared `_opts()` request-options wrapper) | | `_notebook_metadata.py` | Metadata protocol schemas for sub-clients | | `_url_utils.py`, `urls.py` | URL parsing/validation internals and the public URL helper facade | | `_sharing_manager.py` | Direct sharing management logic | | `_version_check.py` | Dynamic client-side version deprecation guard | | `_version_info.py` | Human-facing `version_string()` — package version + short git commit (embedded by `hatch_build.py` at build time, or live `git` from a checkout) | | `_chat/notes.py` | Chat-adjacent note saving workflow adapter | | `_chat/wire.py` | Streamed-chat wire request construction + response parsing for the chat client | | `_chat/transport.py` | Chat-specific error mapping over the shared transport pipeline | | `_chat/deleted_tracker.py` | Bounded `RecentlyDeletedConversations` set — `delete_conversation` records the id (under the conversation lock) so a concurrent null-conversation ask, after acquiring that lock, detects a mid-flight delete and drops `resolved_id_override` to recover the server's real conversation id post-POST (#1875) | | `_middleware/chain.py` | Constructs the middleware chain in the canonical ADR-0009 order | | `_middleware/*.py` | Modular middleware implementations (drain, metrics, semaphore, retry, auth, error injection, tracing) | | `rpc/types.py` | RPC method IDs (source of truth) | | `auth.py` | Authentication facade — **almost pure re-exports** (the only remaining function body is `async def enumerate_accounts`, which binds `_poke_session` as a default dependency; ADR-0003 records the optional-`async` audit command). Every other top-level name forwards from the relevant `_auth/*` module: `auth._validate_required_cookies` is identity-equal to `_auth.cookie_policy._validate_required_cookies`, and `load_auth_from_storage` / `AuthTokens` live in `_auth/tokens.py`. **ADR-0003's flat-re-export goal was closed by ADR-0014.** Tests that need to rebind policy names patch `_auth.cookie_policy.X` directly. | | `_auth/paths.py` | Storage paths and filesystem helpers | | `_auth/extraction.py` | Cookie/token extraction from browser sessions | | `_auth/headers.py` | HTTP header construction | | `_auth/cookies.py` | Cookie map manipulation + `_update_cookie_input` | | `_auth/cookie_policy.py` | Cookie-domain allowlist, `build_cookie_domain_allowlist` builder, and policy decisions | | `_auth/browser_capture.py` | Transport-neutral browser launch→capture→filter→persist core (lazy `playwright`); shared by the interactive CLI login adapter (`cli/services/playwright_login.py`) and the layer-3 headless re-auth layer (ADR-0021) | | `_auth/headless_reauth.py` | Layer-3 headless re-auth decision layer: opt-in/profile-gated, typed honest outcomes (`HeadlessReauthStatus`); drives `run_browser_capture(headless=True, interactive=False)`. Local-unattended-only | | `cli/label_cmd.py` | `label` command group (list/sources/generate/create/rename/emoji/add/remove/delete); thin Click shells over `client.labels`, `_app.labels`, and the label-listing service (ADR-0008/0021) | | `cli/services/label_listing.py` | `label` CLI service: the `label list` members→source-titles join (`execute_label_list`/`LabelListPlan`). Re-exports `resolve_label_id` + `LabelResolutionError` from `_app/labels.py` (the composite `` resolver moved to the neutral layer; the re-export keeps `from .services.label_listing import resolve_label_id` resolving for the command layer + tests) | ### Repository Structure ```text src/notebooklm/ ├── __init__.py # Public exports ├── __main__.py # `python -m notebooklm` entry point ├── client.py # NotebookLMClient ├── auth.py # Authentication facade — almost pure re-exports (`enumerate_accounts` exception; ADR-0003 flat-re-export goal closed by ADR-0014; see file table above) ├── types.py # Dataclasses ├── artifacts.py # Public artifact-generation retry helpers ├── config.py # Public config facade over _env ├── exceptions.py # Public exception hierarchy ├── io.py # Public atomic-I/O facade for CLI boundary compliance ├── log.py # Public logging helper facade ├── migration.py # Legacy flat-layout to profile migration ├── paths.py # Profile-aware path resolution ├── research.py # Public research citation/report helpers ├── urls.py # Public URL helper facade ├── utils.py # Public async utility helpers ├── _atomic_io.py # Atomic JSON write/update helpers ├── _auth_refresh_retry.py # Shared auth refresh-and-retry core (RefreshBudget + refresh_and_count) for both retry layers ├── _backoff.py # Shared retry backoff calculation ├── _callbacks.py # Sync/async callback invocation helper ├── _client_assembly.py # Shared client-assembly seam (constructor + test factory) ├── _client_composed.py # Client-owned composition holder ├── _client_seams.py # Constructor-only injectable seams ├── _deadline.py # RuntimeDeadline helper for aggregate timeouts ├── _deprecation.py # Deprecation helper (warn_deprecated) gated by NOTEBOOKLM_QUIET_DEPRECATIONS ├── _env.py # Runtime environment/default endpoint helpers ├── _idempotency.py # Mutating-RPC idempotency registry + wrappers ├── _idempotency_policy.py # Declarative per-RPC idempotency classification data (register_default_policies) ├── _kernel.py # Concrete Kernel transport core ├── _logging.py # Redaction + correlation logging internals ├── _secrets.py # Canonical runtime secret registry (cookie names + secure/host umbrellas + token/API-key shapes) the redaction patterns derive from ├── _lookup.py # unwrap_or_raise — shared single-row-lookup helper for get/get_or_none ├── _serving.py # Shared bootstrap for both HTTP entry points: single-source loopback classification (IPv4-mapped-IPv6-aware) + non-loopback bind guard (mcp/server __main__ + server/_auth all route through it) ├── _loop_affinity.py # Event-loop affinity guard helper (assert_bound_loop free function) ├── _loop_bound.py # LoopBoundPrimitive mixin — template-method set_bound_loop + _on_loop_rebind hook for the loop-bound collaborators ├── _error_injection.py # Synthetic-error env-var resolver + startup guard ├── _request_types.py # AuthSnapshot, BuildRequest, PostBody, request materialization helpers ├── _transport_errors.py # Transport exceptions, Retry-After parsing, Kernel.post error mapping ├── _streaming_post.py # Size-capped streaming POST helper ├── _curl_cffi_transport.py # Opt-in curl_cffi browser-impersonation transport (NOTEBOOKLM_TRANSPORT=curl_cffi) ├── _rpc_executor.py # RPC dispatch executor ├── _client_metrics.py # Telemetry / metrics seam ├── _transport_drain.py # In-flight transport drain coordinator ├── _reqid_counter.py # Request-counter / request-id helpers ├── _conversation_cache.py # Per-instance true-LRU conversation cache (bounded conversation count + per-conversation turns) ├── _polling_registry.py # Artifact polling helpers ├── _cookie_persistence.py # Cookie-jar persistence + __Secure-1PSIDTS rotation ├── _note_service.py # NoteService ├── _mind_map.py # NoteBackedMindMapService ├── _mind_maps_api.py # MindMapsAPI — unified mind-map surface over both backends (#1256) ├── _notebook_metadata.py # Metadata protocols ├── _url_utils.py # URL validation helpers ├── _sharing_manager.py # Sharing management logic ├── _version_check.py # Deprecation version guard ├── _version_info.py # version_string(): version + short git commit ├── _research_task_parser.py # Research task result-type parser ├── _redact.py # Transport-neutral secret/home-path/file-link scrubber (redact(msg, max_length)); shared chokepoint under both mcp/_errors.py and server/_errors.py ├── _app/ # Transport-neutral business-logic layer (CLI/MCP/HTTP adapters share it) │ ├── __init__.py # Re-exports the neutral primitives │ ├── artifacts.py # Click-free artifact core: get/rename/delete/export + poll/wait/retry; kind-aware mind-map dispatch (mind_maps.list for rename, notes.list_mind_maps for delete), get_artifact raises ArtifactNotFoundError, typed Rename/Export results + ArtifactStatusView/status_view neutral status DTO (CLI builds every --json envelope from the typed fields) │ ├── auth_check.py # Click-free `auth check` diagnostics core: run_auth_check(plan, read_env_auth_json=…) -> AuthCheckResult (storage-exists/json-valid/cookies-present/SID + optional token-fetch); AuthCheckPlan carries pre-resolved values + the auth_source display label; inline-auth read injected (CLI owns the AuthSource plan-build + Rich table + exit code) │ ├── chat.py # Click-free chat core: conversation-id selection ladder + configure mode/goal/length dispatch + history fetch/format-as-data + ask save-as-note workflow (raises public ValidationError; status emitted into injected ProgressSink) │ ├── doctor.py # Click-free doctor core: run_checks(*, fix, paths) -> DoctorReport (five checks incl. headless-reauth readiness + fixes + has_failures; DoctorPaths injects the path helpers; CLI owns rendering/exit codes) │ ├── download.py # Click-free download core: DownloadPlan/Result/TypeSpec + build_download_plan/execute_download (injected resolvers; CLI builds the --json envelope from the typed DownloadResult) │ ├── errors.py # classify(exc) -> ClassifiedError (category + retriable); class-sensitive │ ├── events.py # ProgressEvent + ProgressSink Protocol (neutral progress seam) │ ├── generate.py # Click-free `generate` executor: execute_generation (injected notebook/source resolvers preserve the RPC fast paths) + GenerationExecutionResult; re-exports the plan/retry surface so `_app.generate` is the single import point │ ├── generate_plans.py # Click-free `generate` plan-building: enum/format maps, GenerationPlan/GenerationKind/GenerationPlanValidationError, build_generation_plan + per-kind builders (parameter_explicit/language_resolver injected) │ ├── generate_retry.py # Click-free `generate` retry/wait: GenerationOutcome, generate_with_retry, handle_generation_result, status extractors, spinner status-line formatter (wait_context/wait_start_sink neutral seams) │ ├── labels.py # Click-free label core: create/sources/generate/rename/emoji/add/remove/delete + the composite resolve_label_id () resolver + LabelResolutionError (injected notebook/source resolvers; members→titles JOIN render stays in cli/services/label_listing.py) │ ├── language.py # Click-free language core: SUPPORTED_LANGUAGES catalog + is_supported_language + LanguageConfigStore (injected config-path/home/atomic-update; get/save/get_language/set_language) │ ├── mcp_install.py # Click-free `mcp install ` core: supported-client catalog (claude-desktop/claude-code/cursor/windsurf) + per-OS resolve_config_path + uvx build_server_block + merge_server_config read-modify-merge into mcpServers (created/updated/unchanged; never clobbers unrelated keys); UnsupportedClientError. CLI owns the atomic write (cli/mcp_cmd.py) │ ├── notebooks.py # Click-free notebook core: create/delete/rename/describe(summary)/metadata fetch+compute (injected resolve_notebook_id; summary/metadata serializers stay in cli/notebook_cmd.py) │ ├── notes.py # Click-free note core: create/get/save/rename/delete (typed-facade only — notes.create returns a Note) + content-preserving rename (resolve_note_content); found-flag results map to the CLI NOT_FOUND/exit-1 path (injected notebook/note resolvers) │ ├── pagination.py # Transport-neutral bounded-slice paginate(items, limit, offset) -> (page, {total,offset,has_more}) with bound validation; the shared slice under both the MCP *_list tools and the REST list-route envelope (Option B-lite) │ ├── profile.py # Click-free profile core: gather_profile_list -> ProfileEntry rows (injected list_profiles/resolve_profile/get_storage_path/read_account_metadata), is_protected_profile delete-guard decision, set_default/retarget_default config.json mutators (CLI keeps the locked _atomic_write_config + click.confirm + Rich render) │ ├── research.py # Click-free `research` status/wait core: poll_and_classify -> ResearchStatusResult, ResearchWaitPlan/Result + execute_research_wait (resolver/importer/wait-context injected), validate_research_wait_flags (-> ValidationError); returns typed results only (CLI owns the --json envelope) │ ├── resolve.py # Click-free validate_id + resolve_ref (AmbiguousIdError/Resolution) │ ├── serialize.py # to_jsonable(obj) recursive JSON-able conversion (enum-before-primitive) │ ├── session.py # Click-free session-context core: `use` verify_and_set_notebook (injected resolve_notebook_id) + `status` read_status(StatusInputs) read+project -> StatusReport + `auth logout` execute_logout(LogoutInputs) filesystem-teardown -> typed LogoutOutcome (path/context/clear_context helpers injected via bundles; CLI owns Rich render + exit codes) │ ├── sharing.py # Click-free sharing core: status/set_public/set_view_level/add_user/update_user/remove_user (injected resolve_notebook_id; permission/view-level display + str→enum parse stay in cli/share_cmd.py) │ ├── skill.py # Click-free skill-install core: TARGETS/SCOPES catalog + path/version helpers + classify_target (create/up_to_date/overwrite) + report_mixed_no_clobber_up_to_date (CLI owns the atomic write + packaged-source loader) │ ├── source_add.py # Click-free `source add` core: input detection + URL SSRF/upload-path validation + add workflow (SourceAddPlan/Result; CLI builds the --json source-summary from the typed result via the neutral serialize.source_summary helper) │ ├── source_batch.py # Transport-neutral batch-add policy shared by the MCP tool + REST route (#1871): MAX_BATCH_URLS cap + batch_item_is_fatal (fatal auth/rate-limit/5xx classification via _app.errors.classify → abort the batch; per-URL 4xx-input failures isolate). Neutral frozenset, not server's CATEGORY_STATUS (the _app boundary forbids fastapi); parity pinned by tests/server/test_source_batch_parity.py │ ├── source_clean.py # Click-free `source clean` core: junk-source classification + batched-deletion orchestration (SourceCleanResult; injected list/delete/confirm callables) │ ├── source_content.py # Click-free read-only source-content fetchers for get/fulltext/guide/stale (typed plan/result pairs) │ ├── source_listing.py # Click-free `source list` fetch core: fetch_sources (label_filter resolution; label_resolver injected) │ ├── source_mutations.py # Click-free source delete/delete-by-title/rename/refresh/add-drive core: resolvers + SourceMutationError + typed results (validate_id/resolve_source_id injected; confirmer injected) │ ├── source_research.py # Click-free `source add-research` start/wait/import workflow + validate_add_research_flags (importer injected; SourceAddResearchPlan/Result) │ ├── source_wait.py # Click-free `source wait` readiness-poll core: execute_source_wait + typed SourceWaitOutcome (wait_context injected) + wait_all_sources (single-snapshot loop via client.sources.wait_all_until_ready — one notebook poll per tick, order-preserving; #1870) shared by the MCP tool + REST route (#1871) + the MAX_WAIT_TIMEOUT / MAX_WAIT_SOURCE_IDS caps │ └── views.py # Transport-neutral output-projection views: share_status_view (access/permission/view_level enum→label), source_view (kind/status_label added), ask_result_view (raw_response debug blob stripped); shared by the MCP tools + REST routes so both emit the identical enriched shape (Option B) ├── _runtime/ # Client-runtime subpackage (promoted from flat _runtime_*.py, #1328) │ ├── __init__.py # Re-exports the cluster's public names │ ├── auth.py # AuthRefreshCoordinator (refresh task + auth-snapshot lock) │ ├── config.py # DEFAULT_* knobs + module-level constants │ ├── contracts.py # Shared runtime Protocols consumed by feature APIs │ ├── helpers.py # is_auth_error / AUTH_ERROR_PATTERNS / keepalive helpers │ ├── init.py # Runtime collaborator construction + validation │ ├── lifecycle.py # Open/close lifecycle seam (loop affinity + keepalive task) │ └── transport.py # Middleware-chain transport wrapper ├── _middleware/ # Middleware subpackage (promoted from flat _middleware*.py, #1328) │ ├── __init__.py # Re-exports the cluster's public names │ ├── core.py # Middleware envelope + Protocol + chain composition primitive (was _middleware.py) │ ├── context.py # Middleware context-key vocabulary │ ├── chain.py # Middleware chain builder │ ├── chain_host.py # Live middleware chain slots and retry tunables │ ├── tracing.py # Tracing middleware │ ├── metrics.py # Metrics middleware │ ├── drain.py # Drain middleware │ ├── error_injection.py # Error injection middleware │ ├── retry.py # Retry middleware │ ├── auth_refresh.py # Auth refresh middleware │ └── semaphore.py # Concurrency semaphore middleware ├── _source/ # Source-feature subpackage (promoted from flat _source_*.py, #1328) │ ├── __init__.py # Re-exports the cluster's public service classes │ ├── _upload_decode.py # Pure URL/source-id/content-type decode + validation helpers (extracted from upload.py) │ ├── add.py # Source addition coordinator │ ├── content.py # Source content fetcher │ ├── listing.py # Source listing helper │ ├── polling.py # Source polling coordinator │ ├── upload.py # Gated source upload service │ └── upload_payloads.py # Source upload request payload builders ├── _artifact/ # Artifact-feature subpackage (promoted from flat _artifact_*.py, #1328) │ ├── __init__.py # Re-exports the cluster's public service classes/builders │ ├── _download_client.py # Download trusted-host allowlist + transport-aware client factory (httpx event hook / curl_cffi get_guarded) │ ├── _redirect_guard.py # Per-redirect-hop host/scheme revalidation for downloads (#1521) │ ├── downloads.py # Artifact download coordinator │ ├── formatters.py # Artifact formatting helpers │ ├── generation.py # Artifact generation kickoff service (generate_*, revise_slide, retry_failed) │ ├── payloads.py # Stable artifact request payload builders │ ├── validation.py # Facade input-validation guards (generate_report coercion, export exactly-one-of) (#1874) │ ├── listing.py # Artifact listing helper │ └── polling.py # Artifact polling coordinator ├── _label/ # Source-label feature subpackage: stable RPC payload builders │ ├── __init__.py # Re-exports the label param builders │ └── params.py # Source-label RPC payload builders (CREATE/LIST/UPDATE/DELETE_LABEL) ├── _row_adapters/ # Positional-RPC-row adapters subpackage (promoted from flat _row_adapters_*.py, #1328) │ ├── __init__.py # Re-exports the typed row views │ ├── artifacts.py # Artifact + GET_SUGGESTED_REPORTS row adapters (ArtifactRow / ReportSuggestionRow) │ ├── chat.py # Streamed-chat row adapters (AnswerRow / CitationRow / CitationDetail / PassageRow / StreamFrameRow / ErrorPayloadRow / TextLeafRow) — closes the chat positional-decode perimeter (#1491) │ ├── labels.py # Source-label row adapter │ ├── notebooks.py # SUGGEST_PROMPTS suggestion-row adapter (PromptSuggestionRow / unwrap_prompt_suggestions) │ ├── notes.py # Note and mind-map row adapter │ ├── research.py # POLL_RESEARCH row adapters (ResearchTaskRow / ResearchTaskInfoRow / ResearchResultRow) — drains the research parser's single-level positional reads (#1501) │ └── sources.py # Source row adapter ├── _chat/ # Chat-feature subpackage — facade + helpers unified (#1328) │ ├── __init__.py # Re-exports ChatAPI so `from ._chat import ChatAPI` keeps resolving │ ├── api.py # ChatAPI facade (was _chat.py) │ ├── notes.py # Note saving workflow adapter │ ├── wire.py # Streamed-chat wire request/response parser │ ├── transport.py # Chat error mapping │ └── deleted_tracker.py # Bounded RecentlyDeletedConversations set — serializes null-ask vs delete (#1875) ├── _auth/ # Auth subpackage (forwarded through auth.py facade) │ ├── __init__.py │ ├── paths.py # Storage paths and filesystem helpers │ ├── extraction.py # Cookie/token extraction from browser sessions │ ├── headers.py # HTTP header construction │ ├── cookies.py # Cookie maps + _update_cookie_input │ ├── cookie_policy.py # Domain allowlist + cookie-domain builder and policy │ ├── browser_capture.py # Transport-neutral browser launch→capture→filter→persist core (lazy playwright) │ ├── headless_reauth.py # Layer-3 headless re-auth (opt-in; typed outcomes; local-unattended-only) │ ├── account.py # Account profile + multi-account switching │ ├── session.py # Auth-session refresh implementation via `refresh_auth_session()` and explicit collaborators │ ├── storage.py # Profile/state persistence on disk │ ├── keepalive.py # Cookie keepalive + __Secure-1PSIDTS rotation │ ├── psidts_recovery.py # Inline PSIDTS recovery for cold-start (issue #865) │ ├── master_token.py # Headless master-token auth: mint cookies + layer-4 re-mint (ADR-0023) │ ├── refresh.py # Token refresh driver (external login cmd, coalesced runs, redaction) │ └── tokens.py # AuthTokens container + load_auth_from_storage loader ├── _types/ # Dataclass implementation package re-exported by types.py │ ├── __init__.py │ ├── artifacts.py │ ├── chat.py │ ├── common.py │ ├── labels.py # Label pure-value type (source membership; no kind/artifact_ids) │ ├── mind_maps.py # MindMap + MindMapKind pure-value types (#1256) │ ├── notebooks.py │ ├── notes.py │ ├── research.py # ResearchStatus enum + ResearchTask/ResearchSource/ResearchStart/MindMapResult/SourceGuide typed returns (#1209) │ ├── sharing.py │ └── sources.py ├── _notebooks.py # NotebooksAPI ├── _notebook_payloads.py # batchexecute notebook RPC payload builders (SUGGEST_PROMPTS) ├── _sources.py # SourcesAPI ├── _artifacts.py # ArtifactsAPI ├── _research.py # ResearchAPI ├── _notes.py # NotesAPI ├── _sharing.py # SharingAPI ├── _settings.py # SettingsAPI ├── _labels.py # LabelsAPI — client.labels (source labels: generate/create/list/…) ├── notebooklm_cli.py # Entry-point assembler — imports + registers cli/ groups ├── mcp/ # MCP server (opt-in `mcp` extra) — transport-neutral adapter over _app/, sibling to cli/ │ ├── __init__.py # Re-exports create_server / SERVER_NAME / SERVER_INSTRUCTIONS │ ├── __main__.py # `notebooklm-mcp` entrypoint: argparse (--profile/--transport/--host/--port/--log-level), stderr logging, loopback HTTP bind guard + fail-closed auth guard (non-loopback bind requires a bearer token AND/OR self-hosted OAuth); composes the auth provider (build_auth) and passes it to create_server on the http path │ ├── server.py # create_server(profile, client_factory, auth): FastMCP server; lifespan binds one NotebookLMClient; register_all tool-registration seam; auth passed explicitly (never reads the token env) │ ├── _auth.py # Remote-transport bearer auth: McpBearerAuthProvider(TokenVerifier) with constant-time hmac.compare_digest over NOTEBOOKLM_MCP_TOKEN (env-only, never logged/repr'd); build_auth_provider/get_configured_token; build_auth(token, oauth) composes bearer | OAuth | MultiAuth | None (IdP-agnostic) — mirrors server/_auth.py, NOT fastmcp StaticTokenVerifier │ ├── _oauth.py # Optional self-hosted OAuth 2.1 AS for claude.ai (OAuth-only connector UI): SelfHostedOAuthProvider(InMemoryOAuthProvider) + a password-gated /login (override authorize()→stash SDK-validated (client,params) under a single-use sid→/login→InMemoryOAuthProvider.authorize); scrypt password digest + per-IP throttle, capped DCR + evict-oldest pending stash, atomic 0600 persistence of clients+tokens; get_oauth_config/build_oauth_provider (env NOTEBOOKLM_MCP_OAUTH_PASSWORD + _BASE_URL). Composed with the bearer via MultiAuth │ ├── _host_guard.py # LoopbackHostGuardMiddleware: ASGI guard that rejects HTTP requests with a non-loopback Host header (403; DNS-rebinding guard, #1869) on the loopback-bound HTTP transport via _serving.host_header_is_loopback; skipped when allow_external (REST-parity bearer/OAuth auth is mandatory there) — mirrors server/_auth │ ├── _urlcheck.py # _validate_bare_https_origin(url, env) — shared "bare public https origin" check (https scheme, host, no path/query/fragment); guards the OAuth base URL AND the file-transfer public URL so a /mcp-suffixed/non-https value can't mint broken links │ ├── _filelink.py # HMAC-signed self-describing file-transfer tokens (ADR-0024): FileLinkSigner.sign(payload, ttl→injects exp)/verify(token, op) (stdlib hmac/base64/json; pre-decode length cap, base64url re-pad, compare_digest, exp+op check) + FileTransferConfig(signer, base_url).upload_url/download_url (UPLOAD_TTL 15m / DOWNLOAD_TTL 30m); FileLinkError │ ├── _fileroutes.py # register_file_routes(mcp, config): the /files/{dl,ul} custom routes mounted on the FastMCP http app (ADR-0024). GET /files/dl streams the artifact (download core → FileResponse, meaningful filename, inside-tempdir assert, BackgroundTask cleanup); GET /files/ul = minimal upload page (file picker + raw-body fetch POST); POST|PUT /files/ul streams request.stream() into a 0600 temp under a running byte cap (real DoS guard) + Content-Length early 413 → neutral source_add core. Signed token is the sole auth (custom routes bypass the bearer gate); HTML pages set no-referrer/no-store/DENY; local _safe_upload_name (no server/ import) │ ├── _context.py # AppState dataclass (client + optional file_transfer) + get_client(ctx) / get_file_transfer(ctx) (lifespan-bound) + get_client_from_app(request) (the guarded private-attr accessor for the bare-Request custom routes) │ ├── _errors.py # Structured tool-error projection (CATEGORY_TABLE/ERROR_CODES/mcp_errors/to_tool_error/tool_error_payload) over _app.errors.classify │ ├── _resolve.py # resolve_notebook/resolve_source/resolve_note/resolve_artifact — name + partial-id resolution over _app.resolve plus exact-title matching │ ├── _confirm.py # needs_confirmation() both-mode envelope + READ_ONLY/DESTRUCTIVE ToolAnnotations │ ├── _coerce.py # coerce_list(value) — tolerant list-param normalizer (real list/tuple, JSON-array string, comma string, scalar → list[str]; None stays None for the "all sources" contract); used by studio_generate/chat_ask source_ids │ ├── _paginate.py # paginate(items, limit) — bounded page + {total, has_more} for the *_list tools (client-side slice; RPCs don't page); DEFAULT_LIMIT=50 │ └── tools/ # Per-domain tool modules; each exposes register(mcp) wired by server.register_all │ ├── __init__.py # Tools package marker (no click/rich/cli) │ ├── _content_sanity.py # _annotate_thin_warnings/_thin_content_warning — advisory thin/soft-404 web-page warning over _app.source_content (used by source_wait + source_add batch) │ ├── _fileupload.py # file-transfer slice of the source tools: _broker_upload (signed-URL upload_required) + _decode_upload_b64/_add_bytes (in-channel base64 byte upload for source_upload_bytes) + the shared _add_one plan/execute seam (split from sources.py for the ADR-0008 size budget) │ ├── _passthrough.py # Shared pass-through resolvers (passthrough_notebook_id/passthrough_child_id) for the CLI-shaped _app executors │ ├── _preview.py # title_for_id() — shared id→title lookup for the delete tools' needs_confirmation previews │ ├── _studio_items.py # cross-type Studio plumbing: studio_items (merge notes+artifacts into one items list) + resolve_studio_item (cross-type ref → StudioResolvedItem) for studio_list/studio_rename/studio_delete (split from studio.py for the ADR-0008 size budget) │ ├── _studio_download.py # download plumbing shared by studio.py + _fileroutes.py: _DOWNLOAD_SPECS registry (rebuilt from _app.download) + DownloadType + _resolve_artifact_id / _broker_download / _is_http_transport / _passthrough_download_notebook (split from studio.py for the ADR-0008 size budget) │ ├── _waitagg.py # source-wait outcome aggregation shared by source_wait + source_add_and_wait: _wait_all_sources (concurrent per-source wait) + _aggregate_wait_outcomes (typed SourceWaitOutcome → {ok, ready, timed_out, failed, not_found} + thin-warning annotation) (split from sources.py for the ADR-0008 size budget) │ ├── notebooks.py # notebook_list/create/describe/rename/delete over _app.notebooks │ ├── sources.py # source_list/read/rename/delete/wait/add over _app.source_* (add: url/text/file/youtube via source_add, drive via source_mutations) + source_add_and_wait (single-mode add + wait composed via _waitagg) + source_upload_bytes (in-channel small-file byte upload via _fileupload) │ ├── chat.py # chat_ask (client.chat.ask + get_history recall + suggest_followups) + chat_configure (_app.chat.execute_configure) + suggest_prompts (client.notebooks.suggest_prompts surface selector) │ ├── notes.py # note_save (create-or-update upsert) over _app.notes; note reading/renaming/deleting fold into the cross-type Studio tools │ ├── studio.py # hosts the Studio tools: studio_list (merges notes+artifacts via _studio_items.studio_items) / generate / status / download (via _studio_download) / rename / retry / get_prompt / studio_delete — both rename and delete are cross-type via _studio_items.resolve_studio_item (note→_app.notes.execute_note_rename/execute_note_delete, artifact→_app.artifacts kind-aware core); enum dispatch over _app.generate + _app.download; stateless poll via _app.artifacts.poll_artifact │ ├── research.py # research_start (client.research.start) + research_status (_app.research.poll_and_classify) + research_import │ ├── sharing.py # share_status/set_access/set_user/remove_user (thin adapters over client.sharing; set_access folds public+view_level, set_user upserts add/update; string-labeled enums; view_level surfaced only when set) │ └── meta.py # server_info — package version + auth-health over _app.auth_check (no notebook arg) ├── rpc/ # RPC protocol layer │ ├── types.py # Method IDs and enums │ ├── encoder.py # Request encoding │ ├── decoder.py # Response parsing │ ├── _safe_index.py # Strict bounds-checked positional access for decoded RPC payloads │ └── overrides.py # Runtime RPC ID override policy (env-driven) ├── cli/ # CLI implementation ├── __init__.py # Re-exports click groups under historical names from *_cmd modules ├── _chromium_profiles.py # Multi-user-data-profile cookie extraction for Chromium browsers ├── _cookie_import.py # `auth import-cookies` helpers: parse/normalize/validate cookie JSON + backup-then-atomic-write storage_state ├── _download_specs.py # Registry data for `download ` leaf commands ├── _encoding.py # Encoding-safe CLI output helpers ├── _firefox_containers.py # Container-aware Firefox cookie extraction ├── _session_render.py # Session-command render helpers (status/auth tables) ├── _source_render.py # Source CLI render/validation helpers (extracted from source_cmd.py) ├── agent_cmd.py # agent show commands ├── agent_templates.py # agent prompts and configurations ├── artifact_cmd.py # artifact commands ├── auth_runtime.py # CLI authentication + command runtime helpers ├── chat_cmd.py # ask, configure, history ├── completion.py # Best-effort shell-completion providers for live IDs ├── context.py # CLI context persistence helpers ├── doctor_cmd.py # diagnostic/repair tool ├── download_cmd.py # download commands ├── download_helpers.py # Helper functions for download commands ├── error_handler.py # Centralized CLI error handling ├── generate_cmd.py # generate audio, video, etc. ├── grouped.py # Custom Click group with sectioned help output ├── helpers.py # Shared Click utilities ├── input.py # CLI prompt and stdin input helpers ├── label_cmd.py # label list/sources/generate/create/rename/emoji/add/remove/delete ├── language_cmd.py # Language configuration CLI commands ├── master_token_login.py # Command driver for `login --master-token[-refresh]` (ADR-0023) ├── mcp_cmd.py # `mcp install ` command — thin Click adapter over `_app/mcp_install.py`; resolves the client config path (`--config-path` override) and applies the merge inside `notebooklm.io.atomic_update_json` (locked, crash-safe, merge-not-clobber) ├── notebook_cmd.py # list, create, delete, rename ├── note_cmd.py # note commands ├── options.py # Shared CLI option decorators ├── playwright_login_io.py # Command-side LoginIO sink + wrappers for the Playwright login service (#1391) ├── polling_ui.py # Command-layer UI helpers for long-running polling ├── profile_cmd.py # Profile management CLI commands ├── rendering.py # CLI rendering helpers ├── research_cmd.py # Research management CLI commands ├── research_import.py # Research import helpers shared by CLI commands ├── resolve.py # CLI notebook/entity ID resolution helpers ├── runtime.py # CLI runtime primitives ├── session_cmd.py # login, use, status, clear ├── share_cmd.py # Sharing management CLI commands ├── skill_cmd.py # Skill management commands ├── source_cmd.py # source add, list, delete └── services/ # CLI-specific service layer (ADR-0008 Click-to-service extraction) ├── __init__.py ├── auth_diagnostics.py # `auth check` CLI adapter over `_app/auth_check.py` — re-exports AuthCheckPlan/Result; builds the plan from the AuthSource Click-context precedence (plan_from_click_context + the auth_source display label) and injects read_env_auth_json into the neutral run_auth_check ├── auth_source.py # Single source of truth for the active CLI auth source (Click-context precedence resolver; stays in cli/ — reads ctx.obj + NOTEBOOKLM_AUTH_JSON) ├── confirming_mutation.py # Shared confirmed-mutation pipeline for CLI resources ├── download.py # CLI adapter over _app/download.py: re-exports plan types, injects cli.resolve resolvers (keeps resolve_notebook_id patch seam), projects DownloadResult → envelope dict ├── generate.py # `generate` CLI adapter over `_app/generate.py` — re-exports plan/result/error + build_generation_plan; injects cli.resolve resolve_notebook_id/resolve_source_ids (read at call time, preserving the resolve_module monkeypatch seam) into the neutral execute_generation; re-exports _INFOGRAPHIC_STYLE_MAP from `_app/generate_plans.py` for generate_cmd ├── label_listing.py # `label list` members→titles join service; re-exports resolve_label_id + LabelResolutionError from _app/labels.py ├── listing.py # Shared list-command pipeline for CLI resources ├── login/ # Browser-cookie login helper package │ ├── __init__.py # re-export-only patch surface │ ├── browser_accounts.py │ ├── chromium_accounts.py │ ├── cookie_domains.py │ ├── cookie_jar.py │ ├── cookie_writes.py │ ├── exceptions.py │ ├── firefox_accounts.py │ ├── io_seam.py # Caller-injected LoginIO Protocol + resolver (#1393) │ ├── master_token.py # Headless master-token bootstrap/refresh + browser oauth_token capture (ADR-0023) │ ├── outcomes.py │ ├── profile_targets.py │ ├── refresh.py │ └── rookiepy_errors.py ├── playwright_login.py # Playwright-driven Google login service ├── playwright_redaction.py # Subprocess-output redaction helpers for the Playwright login service ├── polling.py # Shared polling helpers for CLI wait commands ├── research.py # `research wait` CLI adapter over `_app/research.py` — re-exports plan/result/outcome; injects cli.resolve.resolve_notebook_id + cli.research_import.import_research_sources defaults (preserves their patch seams) ├── session_context.py # Notebook-context CLI adapter over `_app/session.py` for `use`/`status`/`auth logout` — re-exports the typed result classes; builds the injected StatusInputs/LogoutInputs bundles from its own session_context-namespace path helpers (read at call time, preserving the get_context_path/get_storage_path/clear_context patch seams) ├── source_listing.py # `source list` CLI adapter over `_app/source_listing.py` — owns the ListSpec/prepare_list presentation half; injects resolve_label_id into the neutral fetch_sources ├── source_mutations.py # Source-mutation CLI adapter over `_app/source_mutations.py` — re-exports plan/result/error/helpers; injects cli.resolve validate_id + resolve_source_id (preserves the resolve_source_id monkeypatch seam) and the click.confirm confirmer ├── source_research.py # `source add-research` CLI adapter — thin wrapper over `_app/source_research.py` (injects the rich-coupled importer; re-exports plan/result + validate_add_research_flags; preserves the import_research_sources monkeypatch seam) └── source_serializers.py # Shared JSON serializers for source CLI output └── server/ # Single-tenant REST API adapter (the third _app adapter, after cli/ and mcp/; behind the optional `server` extra). EXPERIMENTAL: /v1 surface may change, excluded from the api-compat gate. Imports no click/rich/cli. ├── __init__.py # Re-exports create_app + SERVER_NAME; importing it without the `server` extra fails on the fastapi import ├── __main__.py # `notebooklm-server` entry: argparse + NOTEBOOKLM_SERVER_* env defaults + loopback-bind guard + fail-closed token check ├── app.py # create_app(*, client_factory=None) -> FastAPI; ASGI lifespan binds one client; public /healthz; auth-gated /v1 mount (docs/redoc/openapi disabled) ├── _context.py # AppState (lifespan-bound client + pending registry) + get_client / get_pending FastAPI dependencies ├── _limits.py # Lifespan-owned REST route-group concurrency limiters for expensive source/chat/research/artifact work ├── _auth.py # Bearer-token (constant-time, 401) + loopback-Host (DNS-rebinding guard, 403) dependency for /v1 ├── _errors.py # ErrorCategory -> HTTP status table + _redact + the classify-once exception handler emitting {error:{category,message}} ├── _pagination.py # Opt-in, non-breaking list-route envelope: paginate_envelope(items, key=…, limit, offset, **extra) — default (no limit) returns the full list under its existing key unchanged; ?limit= slices via _app.pagination.paginate + adds a meta:{total,has_more,limit,offset} block (Option B-lite) ├── _pending.py # In-process pending-id registry (per-notebook provenance for poll -> 200-pending vs 404) └── routes/ # Per-resource FastAPI routers; handlers call _app.serialize.to_jsonable directly ├── __init__.py # Aggregates the resource routers for the app factory ├── _passthrough.py # Pass-through resolvers handed to the _app cores (REST works in full ids) ├── notebooks.py # /v1/notebooks list/get/create/rename(PATCH)/delete + GET /{id}/suggested-prompts (client.notebooks.suggest_prompts; surface→mode map pinned to MCP) ├── sources.py # /v1/notebooks/{id}/sources list/get/add(url·text·file·drive·batch)/rename(PATCH)/wait/delete + poll-the-resource status ├── notes.py # /v1/notebooks/{id}/notes list/get/create/update(PUT)/delete — thin adapter over client.notes ├── chat.py # POST /v1/notebooks/{id}/chat — blocking ask (no SSE) + POST /chat/configure over _app.chat.execute_configure ├── artifacts.py # /v1/notebooks/{id}/artifacts list/generate/poll/download/rename(PATCH)/retry/delete + GET /{id}/prompt (per-kind generate-option validation pinned to core maps; registry-projected poll; server-generated temp download path) ├── research.py # /v1/notebooks/{id}/research start(202)/status/cancel/import — split-tool shape over client.research + _app.research.poll_and_classify (poll_id = report_id or task_id) ├── share.py # /v1/notebooks/{id}/share status/public/users/view-level over _app.sharing └── meta.py # GET /v1/server/info — version + local auth-health probe (run_auth_check) + opt-in account block; scrubs the on-disk storage path (mirrors MCP server_info) ``` ## ADR cross-references - [ADR-0001](./adr/0001-layered-core-seams-and-property-bridge-policy.md) — Layered seams + property-bridge policy (superseded; shims retired). - [ADR-0002](./adr/0002-capability-protocol-pattern.md) — Capability Protocol pattern (Superseded by ADR-0013). - [ADR-0003](./adr/0003-auth-facade-write-through.md) — `auth.py` write-through facade (Superseded — closed by [ADR-0014](./adr/0014-feature-local-runtime-adapters.md); `auth.py` is now almost pure re-exports with `enumerate_accounts` as the sole function-body exception). - [ADR-0004](./adr/0004-loop-affinity-contract.md) — Loop-affinity contract (Accepted; enforced by `_loop_affinity.assert_bound_loop`). - [ADR-0005](./adr/0005-idempotency-taxonomy.md) — Mutating-RPC idempotency taxonomy (Accepted; enforced by `_idempotency.IdempotencyRegistry`). - [ADR-0006](./adr/0006-vcr-scrubber-strategy.md) — VCR cassette scrubber strategy (Accepted). - [ADR-0007](./adr/0007-test-monkeypatch-policy.md) — Constructor-injection test pattern via `tests/_fixtures/` (Accepted; enforced by `tests/_guardrails/test_no_forbidden_monkeypatches.py`). - [ADR-0008](./adr/0008-cli-services-extraction-pattern.md) — `cli/services/` extraction pattern (Accepted). - [ADR-0009](./adr/0009-middleware-chain.md) — Middleware chain ordering (Accepted; load-bearing). - [ADR-0010](./adr/0010-session-kernel-split.md) — Session/Kernel split (Superseded by ADR-0013). - [ADR-0011](./adr/0011-schema-validation-policy.md) — Schema validation policy (Accepted; `safe_index` is the canonical decode helper). - [ADR-0012](./adr/0012-implementation-surface-convention.md) — Implementation surface convention (Accepted; underscore-prefix = unsupported import surface). - [ADR-0013](./adr/0013-composable-session-capabilities.md) — Composable Session Capabilities (the composable session-capability model). - [ADR-0014](./adr/0014-feature-local-runtime-adapters.md) — Feature-local runtime adapters (Accepted; features receive direct collaborators instead of `Session`). - [ADR-0015](./adr/0015-json-envelope-contract-for-post-parse-click-exceptions.md) — Typed JSON error envelope for post-parse CLI failures (Accepted). - [ADR-0016](./adr/0016-auth-identity-and-core-logger-compatibility.md) — Auth identity + core logger compatibility (Accepted). - [ADR-0017](./adr/0017-public-facade-private-implementation.md) — Public-facade / private-implementation re-export convention (Accepted). - [ADR-0018](./adr/0018-deprecation-strategy.md) — Deprecation strategy (Accepted). - [ADR-0019](./adr/0019-error-and-return-contract.md) — Error-and-return contract for the public API (Accepted; the breaking half shipped in v0.8.0). - [ADR-0020](./adr/0020-sealed-async-result-types.md) — Sealed async result types for artifact generation (Accepted). - [ADR-0021](./adr/0021-transport-neutral-app-layer.md) — Transport-neutral application layer (`_app/`) (Accepted; boundary enforced by `tests/_guardrails/test_app_boundary.py`, classify↔error_handler agreement by `tests/_guardrails/test_classify_error_handler_consistency.py`). - [ADR-0022](./adr/0022-regenerable-baselines.md) — Regenerable test baselines (Accepted). - [ADR-0023](./adr/0023-master-token-headless-auth.md) — Master-token headless auth (Accepted; the L4 unattended re-mint path, `[headless]` extra). - [ADR-0024](./adr/0024-mcp-remote-file-transfer.md) — Remote-MCP file transfer via signed-URL side-channel (Accepted). - [ADR-0025](./adr/0025-mcp-tool-granularity.md) — MCP tool granularity (Accepted). - [ADR-0026](./adr/0026-mcp-studio-surface.md) — MCP Studio surface — notes + artifacts unified (Accepted). ## See also - [`CLAUDE.md`](../CLAUDE.md) — quick-start commands, common pitfalls, and the PR workflow for AI agents working in this repo (the per-file index + repository tree now live in [File map](#file-map) above). - [`docs/development.md`](./development.md) — how to add a new feature API. - [`docs/refactor-history.md`](./refactor-history.md) — historical narrative of the multi-phase refactor + downstream migration tables. - [`docs/python-api.md`](./python-api.md) — public Python API surface. - [`docs/auth-cookie-lifecycle.md`](./auth-cookie-lifecycle.md) — cookie keepalive, rotation, and PSIDTS recovery. - [`docs/rpc-development.md`](./rpc-development.md) — capturing and debugging new RPCs. - [`docs/rpc-reference.md`](./rpc-reference.md) — RPC payload structures.