notebooklm 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e55f36aa3d5bc491e1c271b662aa8897b9629c3e15dbfd9468d9d9016ed2cb7e
4
+ data.tar.gz: e42aaea281852a835e1929d9de0493cb50c7772667c2a32b292402eaf2377f44
5
+ SHA512:
6
+ metadata.gz: 56dcb0bacd5ed50bcd92037614d4634e6ca8c3afb1f313abbeaa619672962abd9a35d8bedcdb0f9f6a025e9e254620f7eb581f74ceab16faa50710f69e8b7f90
7
+ data.tar.gz: 1f9d912626bd6165e6f2caf120148e2a7d63ba330774a9a2bafbdfee38302b6ceb8d48160e8237c3fa2d64bdc2889db292b4d3c44482e9bab088ec0977b511e0
data/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # notebooklm
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/notebooklm.svg)](https://rubygems.org/gems/notebooklm)
4
+ [![CI](https://github.com/roomi-fields/notebooklm-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/roomi-fields/notebooklm-mcp/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A Ruby client for **Gemini Notebook** (formerly NotebookLM): citation-backed
8
+ answers, passage retrieval, Studio generation, and the whole of a local
9
+ server's REST API as documented methods rather than hashes you have to guess at.
10
+
11
+ ## Features
12
+
13
+ - **All 56 operations**, generated from the server's OpenAPI specification, so
14
+ a route added upstream reaches this gem on the release that ships it —
15
+ rather than whenever someone remembers to add a method.
16
+ - **Every shape documented**: each field the specification describes has a
17
+ reader with its type, and a field the server adds later is still reachable
18
+ through `[]` and `to_h`. An unknown shape is never a wall.
19
+ - **Citation-backed answers**: `ask` returns the answer and the quoted passage
20
+ behind every marker.
21
+ - **Retrieval that spends no allowance**: `search_passages` returns the indexed
22
+ passages for a query without generating anything.
23
+ - **The indexed text itself**: `read_source` returns what the service actually
24
+ extracted from a PDF or a page, which the web interface only shows in
25
+ fragments.
26
+ - **Studio generation** — audio, video, infographic, report, presentation, data
27
+ table — plus study aids, mind maps, labels and notes.
28
+ - **The UTCP manual**: `get_utcp_manual` returns the document a
29
+ [UTCP](https://www.utcp.io/) client reads to call the API with no client at
30
+ all.
31
+ - **Honest absence**: a passage with no offset reads as `nil`, never as `0`.
32
+ Guessing a zero is how a quotation gets attributed to the top of a long
33
+ document.
34
+ - **A refusal keeps its words**: the server's own explanation travels in
35
+ `NotebookLM::Refused`, and a request that never arrived raises a different
36
+ class from one that was declined — `error.transport?` separates the two.
37
+ - **No runtime dependencies.** Net::HTTP and JSON, nothing else. The tests have
38
+ no dependencies either.
39
+
40
+ ## Usage
41
+
42
+ ```ruby
43
+ # Gemfile
44
+ gem 'notebooklm', '~> 4.7'
45
+ ```
46
+
47
+ or `gem install notebooklm`.
48
+
49
+ ```ruby
50
+ require 'notebooklm'
51
+
52
+ client = NotebookLM.local
53
+
54
+ unless client.get_health.authenticated
55
+ abort 'no Google session yet — run the setup command once'
56
+ end
57
+
58
+ notebook = client.list_notebooks.notebooks.first
59
+
60
+ # Retrieval: the evidence, with no generation allowance spent.
61
+ client.search_passages(id: notebook.id, query: 'ikigai', limit: 5).passages.each do |passage|
62
+ puts "#{passage.rank} — #{passage.text}"
63
+ end
64
+
65
+ # Or an answer, with the quotations behind it.
66
+ answer = client.ask(notebook.id, 'How is ikigai defined?')
67
+ puts answer.answer
68
+ answer.citations.each { |citation| puts " [#{citation.number}] #{citation.source_text}" }
69
+ ```
70
+
71
+ Every operation takes its parameters as keyword arguments under the names the
72
+ specification gives them, and returns a shape whose readers are documented:
73
+
74
+ ```ruby
75
+ client.ask_question(question: 'How is ikigai defined?', notebook_id: notebook.id,
76
+ source_format: 'json')
77
+ client.generate_report(id: notebook.id, prompt: 'A literature review')
78
+ client.get_utcp_manual.tools.size # => 56
79
+ ```
80
+
81
+ ## It needs a server, and that is deliberate
82
+
83
+ Google publishes no API for the consumer product. Everything any tool does
84
+ rides on the endpoints the web application itself calls, and those require a
85
+ logged-in Google session. Keeping one means three things that are easy to
86
+ underestimate:
87
+
88
+ - the session is **bound to the machine** it was created on — copied cookies
89
+ are refused even while their local expiry still looks valid;
90
+ - Google **expires sessions server-side** while those cookies look fine, so the
91
+ only reliable check is navigating and seeing where you land;
92
+ - an unpatched automated browser **gets flagged**, and the patches have to be
93
+ maintained against a moving target.
94
+
95
+ So this gem owns no browser. It talks to a process that does:
96
+
97
+ ```bash
98
+ npx -y @roomi-fields/notebooklm-mcp # the server
99
+ npx -y -p @roomi-fields/notebooklm-mcp notebooklm-mcp-setup-auth # once
100
+ ```
101
+
102
+ On loopback the server needs no token. As soon as you expose it, it refuses to
103
+ start without one:
104
+
105
+ ```ruby
106
+ client = NotebookLM::Client.new(base: 'http://10.0.0.2:3000', api_key: ENV.fetch('NOTEBOOKLM_API_KEY'))
107
+ ```
108
+
109
+ `NOTEBOOKLM_API_KEY` and `NOTEBOOKLM_BASE_URL` are read from the environment
110
+ when you pass neither. The key is never printed: `inspect` redacts it.
111
+
112
+ ## Errors
113
+
114
+ ```ruby
115
+ begin
116
+ client.ask(notebook.id, 'How is ikigai defined?')
117
+ rescue NotebookLM::Refused => e
118
+ warn e.reason # the server's own words, for a human
119
+ rescue NotebookLM::TransportError => e
120
+ retry if (tries += 1) < 3 # nothing arrived; retrying can work
121
+ rescue NotebookLM::ShapeError => e
122
+ warn "client and server disagree on #{e.route}"
123
+ end
124
+ ```
125
+
126
+ `NotebookLM::EmptyPayload` means the server reported success and sent nothing —
127
+ its contract breach, reported rather than returned as a `nil` for you to trip
128
+ over three lines later.
129
+
130
+ ## Versions
131
+
132
+ The gem version is the server version it speaks to. There is one release per
133
+ server release, and 4.6 works against a 4.6 server; a mismatch is the one thing
134
+ this gem cannot check for you.
135
+
136
+ Ruby **3.1** or later.
137
+
138
+ ## Documentation
139
+
140
+ - [Server documentation](https://notebooklm.roomi-fields.com/)
141
+ - [Driving it from Ruby](https://notebooklm.roomi-fields.com/blog/notebooklm-ruby)
142
+ — why the gem is shaped this way
143
+ - [REST API reference](https://notebooklm.roomi-fields.com/notebooklm-rest-api)
144
+
145
+ ## License
146
+
147
+ MIT. See [LICENSE](https://github.com/roomi-fields/notebooklm-mcp/blob/main/LICENSE).
148
+
149
+ ### Contribution
150
+
151
+ Issues and pull requests go to
152
+ [roomi-fields/notebooklm-mcp](https://github.com/roomi-fields/notebooklm-mcp),
153
+ which holds the server and this gem together. The surface is generated: change
154
+ `deployment/docs/openapi.yaml` and run `npm run ruby:sync`, never
155
+ `lib/notebooklm/generated.rb` by hand.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module NotebookLM
8
+ # A client for one local server.
9
+ #
10
+ # Every operation lives in {Operations}, generated from the server's OpenAPI
11
+ # specification. This class is only the plumbing underneath: where to send a
12
+ # request, how to carry the token, and how to turn the envelope into either a
13
+ # shape or an error.
14
+ #
15
+ # @example
16
+ # client = NotebookLM::Client.new
17
+ # client.get_health.authenticated # => true
18
+ class Client
19
+ # Where the server listens when nothing says otherwise.
20
+ DEFAULT_BASE = 'http://127.0.0.1:3000'
21
+
22
+ # A question can legitimately take minutes, and Net::HTTP would otherwise
23
+ # give up after sixty seconds — reporting a timeout for a call that was
24
+ # working.
25
+ DEFAULT_READ_TIMEOUT = 300
26
+
27
+ # @return [String] the address this client talks to
28
+ attr_reader :base
29
+
30
+ # @param base [String] where the server listens
31
+ # @param api_key [String, nil] the token the server requires as soon as it
32
+ # is exposed beyond loopback. Defaults to `NOTEBOOKLM_API_KEY`, and is
33
+ # never printed by {#inspect}.
34
+ # @param read_timeout [Numeric] how long to wait for one answer
35
+ # @param open_timeout [Numeric] how long to wait for the connection
36
+ def initialize(base: nil, api_key: :from_env, read_timeout: DEFAULT_READ_TIMEOUT,
37
+ open_timeout: 10)
38
+ @base = base || ENV['NOTEBOOKLM_BASE_URL'] || DEFAULT_BASE
39
+ @api_key = api_key == :from_env ? ENV.fetch('NOTEBOOKLM_API_KEY', nil) : api_key
40
+ raise ApiKeyError if @api_key && @api_key.match?(/[\u0000-\u001f\u007f]/)
41
+
42
+ @uri = parse_base(@base)
43
+ @read_timeout = read_timeout
44
+ @open_timeout = open_timeout
45
+ @mutex = Mutex.new
46
+ end
47
+
48
+ # The api key is held here, so a printed client would leak it.
49
+ def inspect
50
+ "#<#{self.class.name} base=#{@base.inspect} api_key=#{@api_key ? '<redacted>' : 'nil'}>"
51
+ end
52
+
53
+ # One request, for the generated surface: the route, an optional body, an
54
+ # optional query block, and the envelope unwrapped.
55
+ #
56
+ # `route` is only ever used to say which call could not be read, which is
57
+ # the difference between "the server changed" and "my code is wrong".
58
+ #
59
+ # @api private
60
+ def call(route, method, path, body: nil, query: nil, enveloped: true)
61
+ response = perform(route, method, path, body, query)
62
+ payload = parse(route, response)
63
+ return payload unless enveloped
64
+
65
+ unwrap(route, payload)
66
+ end
67
+
68
+ private
69
+
70
+ def parse_base(base)
71
+ uri = URI.parse(base)
72
+ raise AddressError, "invalid server address: #{base}" unless uri.is_a?(URI::HTTP)
73
+
74
+ uri
75
+ rescue URI::InvalidURIError => e
76
+ raise AddressError, "invalid server address: #{base} (#{e.message})"
77
+ end
78
+
79
+ METHODS = {
80
+ get: Net::HTTP::Get,
81
+ post: Net::HTTP::Post,
82
+ put: Net::HTTP::Put,
83
+ patch: Net::HTTP::Patch,
84
+ delete: Net::HTTP::Delete
85
+ }.freeze
86
+ private_constant :METHODS
87
+
88
+ def perform(route, method, path, body, query)
89
+ target = route_uri(path, query)
90
+ request = METHODS.fetch(method).new(target)
91
+ request['Accept'] = 'application/json'
92
+ request['User-Agent'] = "notebooklm-ruby/#{VERSION}"
93
+ request['Authorization'] = "Bearer #{@api_key}" if @api_key
94
+ if body
95
+ request['Content-Type'] = 'application/json'
96
+ request.body = JSON.generate(body)
97
+ end
98
+ send_request(request)
99
+ rescue KeyError
100
+ raise ShapeError.new(route, "no such HTTP method: #{method}")
101
+ end
102
+
103
+ def send_request(request)
104
+ @mutex.synchronize { http.request(request) }
105
+ rescue StandardError => e
106
+ raise TransportError.new(@base, e)
107
+ end
108
+
109
+ # One connection, reused. Net::HTTP is not thread-safe, so the mutex above
110
+ # serialises calls rather than pretending otherwise.
111
+ def http
112
+ return @http if @http&.started?
113
+
114
+ @http = Net::HTTP.new(@uri.host, @uri.port)
115
+ @http.use_ssl = @uri.scheme == 'https'
116
+ @http.read_timeout = @read_timeout
117
+ @http.open_timeout = @open_timeout
118
+ @http.start
119
+ @http
120
+ end
121
+
122
+ def route_uri(path, query)
123
+ uri = @uri.merge(path)
124
+ pairs = (query || {}).reject { |_, value| value.nil? }
125
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
126
+ uri
127
+ rescue URI::Error => e
128
+ raise AddressError, "could not join #{path} onto #{@base} (#{e.message})"
129
+ end
130
+
131
+ def parse(route, response)
132
+ body = response.body.to_s
133
+ raise ShapeError.new(route, "the server answered #{response.code} with an empty body") if body.empty?
134
+
135
+ JSON.parse(body)
136
+ rescue JSON::ParserError => e
137
+ raise ShapeError.new(route, e.message)
138
+ end
139
+
140
+ # The envelope carries the refusal, so the HTTP status is not consulted: a
141
+ # refused call answers 4xx *and* `success: false`, and the body is the half
142
+ # that says why.
143
+ def unwrap(route, payload)
144
+ raise ShapeError.new(route, 'the answer was not a JSON object') unless payload.is_a?(Hash)
145
+
146
+ data = payload['data']
147
+ reason = payload['error']
148
+ return data if payload['success'] == true && !data.nil?
149
+ raise Refused, reason.to_s unless reason.nil?
150
+
151
+ raise EmptyPayload, route
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotebookLM
4
+ # How the quotations behind an answer should come back.
5
+ #
6
+ # The wire takes a string; this is the list the server accepts, so a typo
7
+ # raises here instead of coming back as an answer with no citations.
8
+ SOURCE_FORMATS = %w[none json inline footnotes expanded].freeze
9
+
10
+ # The few helpers the generated surface cannot have.
11
+ #
12
+ # Everything else in this gem comes out of the OpenAPI specification, which
13
+ # is what keeps it complete. A generator cannot know that a question usually
14
+ # wants its quotations as data, or that the session id of one answer is the
15
+ # handle for the next — so those live here.
16
+ #
17
+ # There are fewer of these than the Rust crate needs, and that is Ruby's
18
+ # doing: keyword arguments with defaults already read like a builder, so a
19
+ # request object would add nothing.
20
+ module Ergonomics
21
+ # Ask a notebook in the local library, with the quotations returned as
22
+ # data. The long form is {Operations#ask_question}.
23
+ #
24
+ # @param notebook_id [String] a notebook id from {Operations#list_notebooks}
25
+ # @param question [String] the question, in prose
26
+ # @param session_id [String, nil] continue an earlier chat instead of
27
+ # starting a new one — pass the `session_id` the previous answer carried
28
+ # @param source_format [String] one of {SOURCE_FORMATS}
29
+ # @return [AskQuestionData]
30
+ def ask(notebook_id, question, session_id: nil, source_format: 'json')
31
+ check_source_format(source_format)
32
+ ask_question(
33
+ question: question,
34
+ notebook_id: notebook_id,
35
+ session_id: session_id,
36
+ source_format: source_format
37
+ )
38
+ end
39
+
40
+ # The same question, against a notebook that is not in the library.
41
+ #
42
+ # @param notebook_url [String] the notebook's address
43
+ # @param question [String] the question, in prose
44
+ # @param session_id [String, nil] an earlier chat to continue
45
+ # @param source_format [String] one of {SOURCE_FORMATS}
46
+ # @return [AskQuestionData]
47
+ def ask_url(notebook_url, question, session_id: nil, source_format: 'json')
48
+ check_source_format(source_format)
49
+ ask_question(
50
+ question: question,
51
+ notebook_url: notebook_url,
52
+ session_id: session_id,
53
+ source_format: source_format
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def check_source_format(format)
60
+ return if SOURCE_FORMATS.include?(format.to_s)
61
+
62
+ raise ArgumentError,
63
+ "source_format must be one of #{SOURCE_FORMATS.join(', ')}, got #{format.inspect}"
64
+ end
65
+ end
66
+
67
+ class AskQuestionData
68
+ # The quotations behind this answer, or an empty list when the question
69
+ # did not ask for them. Saves callers one hop through `sources`.
70
+ #
71
+ # @return [Array<AskQuestionDataSourcesCitationsItem>]
72
+ def citations
73
+ sources.citations
74
+ end
75
+ end
76
+
77
+ class Client
78
+ include Ergonomics
79
+ end
80
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotebookLM
4
+ # What can go wrong between a Ruby program and the local server.
5
+ #
6
+ # The subclasses separate three situations a single error class usually
7
+ # blurs: the request never arrived, the server refused the request and said
8
+ # why, and the server answered in a shape we cannot read. Only the second is
9
+ # the caller's fault, and only the third is ours.
10
+ class Error < StandardError
11
+ # True when retrying could plausibly succeed. A refusal is not retryable:
12
+ # the server already decided.
13
+ def transport?
14
+ false
15
+ end
16
+ end
17
+
18
+ # The request never completed: nothing listening, a timeout, a broken pipe.
19
+ class TransportError < Error
20
+ # @return [String] the address the client was pointed at
21
+ attr_reader :base
22
+
23
+ # @return [StandardError] what the HTTP stack reported
24
+ attr_reader :cause_error
25
+
26
+ def initialize(base, cause_error)
27
+ @base = base
28
+ @cause_error = cause_error
29
+ super("the server at #{base} could not be reached: #{cause_error.message}")
30
+ end
31
+
32
+ def transport?
33
+ true
34
+ end
35
+ end
36
+
37
+ # The server answered the envelope with `success: false` and a reason. The
38
+ # reason is the server's own wording, meant to be shown to a human.
39
+ class Refused < Error
40
+ # @return [String] the server's reason
41
+ attr_reader :reason
42
+
43
+ def initialize(reason)
44
+ @reason = reason
45
+ super("the server refused the request: #{reason}")
46
+ end
47
+ end
48
+
49
+ # The server reported success but sent no payload — a contract breach on its
50
+ # side, reported rather than returned as nil for the caller to trip over.
51
+ class EmptyPayload < Error
52
+ def initialize(route)
53
+ super("the server reported success with no payload for #{route}")
54
+ end
55
+ end
56
+
57
+ # The body did not match the shape this version of the client expects, which
58
+ # usually means the client and the server are different versions.
59
+ class ShapeError < Error
60
+ # @return [String] which call could not be read
61
+ attr_reader :route
62
+
63
+ def initialize(route, detail)
64
+ @route = route
65
+ super("could not read the server's answer to #{route}: #{detail}")
66
+ end
67
+ end
68
+
69
+ # The base address could not be parsed, or a route could not be joined onto it.
70
+ class AddressError < Error; end
71
+
72
+ # The api key held a byte that cannot travel in an HTTP header. Caught here
73
+ # rather than sent, because a stray newline in a header is a request
74
+ # smuggling primitive, not a typo.
75
+ class ApiKeyError < Error
76
+ def initialize
77
+ super('the api key cannot be sent as a header: it holds a newline or a control byte')
78
+ end
79
+ end
80
+ end