pikuri-core 0.0.7 → 0.1.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +45 -67
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +100 -24
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +14 -3
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ # The environment a child process should see when pikuri itself booted under
5
+ # Bundler. Merge it under whatever the child's own configuration asks for:
6
+ #
7
+ # Open3.popen3(BundlerEnv.clean_delta.merge(entry.env), *entry.command)
8
+ #
9
+ # Bundler implements +bundler/setup+ by rewriting the process environment —
10
+ # it injects +BUNDLE_GEMFILE+ and +RUBYOPT+, repoints +GEM_HOME+ /
11
+ # +GEM_PATH+, prepends the bundle's bin dir to +PATH+, and stashes each
12
+ # original under +BUNDLER_ORIG_<NAME>+. A child inherits all of it, and for a
13
+ # child that is itself Ruby that reads as the child being broken: a
14
+ # +bundle exec+ the agent runs in another project resolves against *pikuri's*
15
+ # Gemfile, and +ruby-lsp+ refuses to start at all ("can't find executable
16
+ # ruby-lsp for gem ruby-lsp") unless pikuri's Gemfile happens to list it. Such
17
+ # a child serves the *target project*, so it must see the environment the user
18
+ # had before pikuri started.
19
+ #
20
+ # == Implementation details
21
+ #
22
+ # A delta rather than a scrub, for two reasons. A spawn's +env:+ is additive,
23
+ # so removal has to be spelled as an explicit +nil+; and Bundler *changed*
24
+ # variables it did not invent, so the fix restores each +BUNDLER_ORIG_*+
25
+ # backup — a host-level +GEM_HOME+ from an apt-installed Ruby passes through,
26
+ # where a blanket +GEM_*+ wipe would have eaten it.
27
+ #
28
+ # This does not break the "environment is not a secret store" seam, which
29
+ # says pikuri never filters +ENV+ before spawning: nothing the user set is
30
+ # removed, only pikuri's own boot undone.
31
+ module BundlerEnv
32
+ # The delta that, merged onto the current (bundler-polluted) +ENV+,
33
+ # reconstructs +Bundler.unbundled_env+.
34
+ #
35
+ # Empty when pikuri runs without Bundler (gem-installed, or a host that
36
+ # never required +bundler/setup+), so the child inherits +ENV+ unchanged
37
+ # and no caller needs a branch of its own.
38
+ #
39
+ # @return [Hash{String=>(String,nil)}] an +env:+ for
40
+ # {Pikuri::Subprocess.spawn}, +Open3.popen3+ or +Process.spawn+; a +nil+
41
+ # value unsets that variable in the child.
42
+ def self.clean_delta
43
+ return {} unless defined?(Bundler) && Bundler.respond_to?(:unbundled_env)
44
+
45
+ env_delta(current: ENV.to_h, clean: Bundler.unbundled_env)
46
+ end
47
+
48
+ # The additive delta that turns +current+ into +clean+ when passed as a
49
+ # spawn's +env:+.
50
+ #
51
+ # env_delta(current: { 'BUNDLE_GEMFILE' => '/pikuri/Gemfile', 'PATH' => '/bin' },
52
+ # clean: { 'PATH' => '/bin' })
53
+ # # => { 'BUNDLE_GEMFILE' => nil }
54
+ #
55
+ # Keys equal in both are omitted, so a child's inherited environment is
56
+ # otherwise untouched.
57
+ #
58
+ # @param current [Hash{String=>String}]
59
+ # @param clean [Hash{String=>String}]
60
+ # @return [Hash{String=>(String,nil)}] +nil+ where +clean+ lacks the key.
61
+ def self.env_delta(current:, clean:)
62
+ delta = {}
63
+ clean.each { |k, v| delta[k] = v unless current[k] == v }
64
+ current.each_key { |k| delta[k] = nil unless clean.key?(k) }
65
+ delta
66
+ end
67
+ end
68
+ end
@@ -9,42 +9,32 @@ module Pikuri
9
9
  module Extractor
10
10
  # HTML → Markdown extractor.
11
11
  #
12
- # Matched by content-type only (+text/html+ /
13
- # +application/xhtml+xml+) — deliberately no byte sniff. The web
14
- # path always has the header; for local files a sniff would route
15
- # +Workspace::Read+ of an +.html+ source file through readability
16
- # extraction, when a developer reading an HTML file wants the
17
- # source. Local HTML stays on the {Passthrough} arm until a
18
- # consumer genuinely needs otherwise.
12
+ # Matched by content-type only (+text/html+ / +application/xhtml+xml+) —
13
+ # no byte sniff: the web path always has the header, and sniffing would
14
+ # route a +Workspace::Read+ of a local +.html+ *source* file through
15
+ # readability when the developer wants the source. Local HTML stays on the
16
+ # {Passthrough} arm.
19
17
  #
20
- # Always renders both views of the page when available:
18
+ # Renders both views when available, concatenated with a horizontal rule,
19
+ # so the LLM gets structured metadata and rendered body and picks whichever
20
+ # fits:
21
21
  #
22
- # 1. JSON-LD section. Any +<script type="application/ld+json">+ node
23
- # whose +@type+ matches a substantive schema.org content type
24
- # (Product, Article, Recipe, ...) is rendered as a header — title,
25
- # metadata bullets (brand, SKU, price, rating, author, published),
26
- # and the +articleBody+/+description+ copy when present.
27
- # 2. Readability section. The page is run through +Readability+ +
28
- # +reverse_markdown+, with a +<main>+/+<article>+ fallback for
29
- # pages whose content sits mostly outside +<p>+ tags.
22
+ # 1. JSON-LD — the first +<script type="application/ld+json">+ node whose
23
+ # +@type+ is a substantive schema.org type ({INTERESTING_TYPES}),
24
+ # rendered as title + metadata bullets + body copy.
25
+ # 2. Readability — +Readability+ + +reverse_markdown+, with a
26
+ # +<main>+/+<article>+ fallback for pages whose content sits outside
27
+ # +<p>+ tags.
30
28
  #
31
- # Concatenated with a horizontal rule, so the LLM gets both the
32
- # structured metadata and the rendered body and can pick whichever
33
- # is more useful for the task. Trades some duplication (when a
34
- # publisher embeds the article body in JSON-LD AND in HTML) for
35
- # fewer type-based heuristics on which branch should win — the
36
- # earlier "is this Article's +description+ a teaser or the real
37
- # body?" carve-out is no longer needed because both end up in
38
- # the output regardless.
29
+ # Emitting both trades some duplication (a body embedded in JSON-LD *and*
30
+ # HTML) for dropping type-based "teaser or real body?" heuristics.
39
31
  module HTML
40
32
  # @return [Array<String>] content-types this extractor claims.
41
33
  CONTENT_TYPES = %w[text/html application/xhtml+xml].freeze
42
34
 
43
- # @return [Array<String>] schema.org +@type+ values that we treat
44
- # as "the primary entity of this page" when picking a JSON-LD
45
- # node to render. Order does not matter — the first matching
46
- # node wins. Skips noise nodes (Organization, BreadcrumbList,
47
- # WebSite, ...) that ship on most pages but carry no page
35
+ # @return [Array<String>] schema.org +@type+ values treated as the page's
36
+ # primary entity when picking a JSON-LD node (first match wins). Skips
37
+ # noise nodes (Organization, BreadcrumbList, WebSite) that carry no
48
38
  # content.
49
39
  INTERESTING_TYPES = %w[
50
40
  Product Article NewsArticle BlogPosting Recipe Event Book Movie
@@ -63,20 +53,16 @@ module Pikuri
63
53
  # is dropped before Markdown conversion
64
54
  READABILITY_ATTRS = %w[href src alt title].freeze
65
55
 
66
- # @return [Float] minimum +<main>+/+<article>+ to Readability
67
- # text-length ratio that triggers the semantic-container
68
- # fallback in {.readability_to_markdown}. Picked low enough to
69
- # catch the failure mode (Readability collapsing a page that
70
- # uses divs/lists instead of +<p>+ — e.g. +vaadin.com/company+,
71
- # ~5x) but high enough that pages where both produce
72
- # comparable output keep Readability's noise filtering.
56
+ # @return [Float] minimum +<main>+/+<article>+-to-Readability text-length
57
+ # ratio that triggers the semantic-container fallback in
58
+ # {.readability_to_markdown}. Low enough to catch Readability collapsing
59
+ # a div/list-based page (vaadin.com/company, ~5x), high enough to keep
60
+ # its noise filtering where both outputs are comparable.
73
61
  MAIN_FALLBACK_RATIO = 2.0
74
62
 
75
- # @return [Integer] minimum text length the
76
- # +<main>+/+<article>+ container must hold before the fallback
77
- # in {.readability_to_markdown} can fire. Below this, the
78
- # ratio comparison is dominated by noise and we'd swap on
79
- # tiny pages where Readability is doing the right thing.
63
+ # @return [Integer] minimum text length the +<main>+/+<article>+ container
64
+ # must hold before the {.readability_to_markdown} fallback can fire —
65
+ # below it the ratio is noise-dominated and would swap on tiny pages.
80
66
  MAIN_FALLBACK_MIN_CHARS = 500
81
67
 
82
68
  # @return [Symbol] {Page#kind} tag.
@@ -92,12 +78,9 @@ module Pikuri
92
78
  CONTENT_TYPES.include?(content_type)
93
79
  end
94
80
 
95
- # Render the HTML document behind +io+ as Markdown by emitting
96
- # both the JSON-LD section (when an interesting node is present)
97
- # and the readability / +<main>+ section, joined by a horizontal
98
- # rule. Either section may be missing — pages with no JSON-LD
99
- # return only the readability output, and a malformed page with
100
- # no extractable body returns only the JSON-LD render.
81
+ # Render the HTML behind +io+ as Markdown: the JSON-LD section (when an
82
+ # interesting node is present) and the readability/+<main>+ section,
83
+ # joined by a horizontal rule. Either may be missing.
101
84
  #
102
85
  # @param io [IO, StringIO] IO over the HTML document.
103
86
  # @return [String] Markdown representation
@@ -108,22 +91,13 @@ module Pikuri
108
91
  sections.join("\n\n---\n\n")
109
92
  end
110
93
 
111
- # Pick the first JSON-LD node whose +@type+ matches one of
112
- # {INTERESTING_TYPES} and render it as Markdown. Returns +nil+
113
- # when no such node exists, in which case {.extract} emits only
114
- # the readability section.
115
- #
116
- # No content-field gating: a node carrying just +name+/+author+/
117
- # +datePublished+ still renders (as a metadata-only header),
118
- # because the readability pass independently produces the page
119
- # body. That is the trade-off that lets us drop the type-based
120
- # "is this teaser or article copy?" heuristics — duplication is
121
- # acceptable when both views are available, and the LLM can
122
- # pick whichever it needs.
94
+ # Pick the first JSON-LD node whose +@type+ is in {INTERESTING_TYPES} and
95
+ # render it. +nil+ when none matches. No content-field gating — a
96
+ # metadata-only node (name/author/date) still renders, since the
97
+ # readability pass independently produces the body.
123
98
  #
124
99
  # @param html [String] HTML document body
125
- # @return [String, nil] Markdown render of the picked JSON-LD
126
- # node, or +nil+ when nothing matched
100
+ # @return [String, nil] Markdown render, or +nil+ when nothing matched
127
101
  def self.jsonld_section(html)
128
102
  node = parse_jsonld(html).find do |n|
129
103
  Array(n['@type']).any? { |t| INTERESTING_TYPES.include?(t) }
@@ -131,14 +105,12 @@ module Pikuri
131
105
  node ? jsonld_to_markdown(node) : nil
132
106
  end
133
107
 
134
- # Collect every JSON-LD payload embedded in +html+, flattening
135
- # +@graph+ wrappers so callers see one flat array of schema.org
136
- # nodes. Malformed JSON blocks are silently skipped — sites
137
- # frequently ship broken JSON-LD and we only need at least one
138
- # parseable block.
108
+ # Collect every JSON-LD payload in +html+, flattening +@graph+ wrappers
109
+ # into one flat array. Malformed JSON blocks are skipped (sites ship
110
+ # broken JSON-LD; one parseable block suffices).
139
111
  #
140
112
  # @param html [String] HTML document body
141
- # @return [Array<Hash>] parsed JSON-LD nodes; possibly empty
113
+ # @return [Array<Hash>] parsed nodes; possibly empty
142
114
  def self.parse_jsonld(html)
143
115
  doc = Nokogiri::HTML(html)
144
116
  blobs = doc.css('script[type="application/ld+json"]').map(&:text)
@@ -156,18 +128,12 @@ module Pikuri
156
128
  end
157
129
  end
158
130
 
159
- # Render a single JSON-LD +node+ as Markdown: a top-level title
160
- # from +name+/+headline+, a bullet list of common useful fields
161
- # (brand, SKU, price, rating, author, published date, ...), the
162
- # body copy, and the lead image.
163
- #
164
- # When the node carries +articleBody+ (the full publisher-supplied
165
- # article text), that wins over +description+ — the description
166
- # is typically a lede teaser and would just repeat the article's
167
- # opening lines.
131
+ # Render a JSON-LD +node+ as Markdown: title from +name+/+headline+, a
132
+ # bullet list of common fields (brand, SKU, price, rating, author, date),
133
+ # body copy, and the lead image. +articleBody+ wins over +description+
134
+ # (the description is usually a lede teaser).
168
135
  #
169
- # @param node [Hash] JSON-LD node, typically picked by
170
- # {.jsonld_section}
136
+ # @param node [Hash] JSON-LD node
171
137
  # @return [String] Markdown representation
172
138
  def self.jsonld_to_markdown(node)
173
139
  out = +''
@@ -212,19 +178,13 @@ module Pikuri
212
178
  out
213
179
  end
214
180
 
215
- # Run +Readability+ over +html+ to isolate the main content node,
216
- # then convert that to Markdown via +reverse_markdown+. The page
217
- # +<title>+ is rendered as a top-level heading.
218
- #
219
- # When the page uses semantic HTML5 (+<main>+ or +<article>+) but
220
- # leaves most of its content outside +<p>+ tags — divs, lists,
221
- # spans — Readability's paragraph-density scoring collapses the
222
- # extraction to a sliver of the page. In that case we render the
223
- # +<main>+/+<article>+ container directly. The fallback only
224
- # fires when the container holds substantially more text than
225
- # Readability picked up (see {MAIN_FALLBACK_RATIO} /
226
- # {MAIN_FALLBACK_MIN_CHARS}); on pages where both agree we keep
227
- # Readability so its noise filtering still strips nav/ads/etc.
181
+ # Run +Readability+ over +html+, convert to Markdown via
182
+ # +reverse_markdown+, +<title>+ as a top-level heading. When the page
183
+ # uses HTML5 semantics (+<main>+/+<article>+) but leaves content outside
184
+ # +<p>+ tags, Readability's paragraph-density scoring collapses the
185
+ # extraction; then the container is rendered directly. The fallback fires
186
+ # only when the container holds substantially more text (see
187
+ # {MAIN_FALLBACK_RATIO} / {MAIN_FALLBACK_MIN_CHARS}).
228
188
  #
229
189
  # @param html [String] HTML document body
230
190
  # @return [String] Markdown representation
@@ -247,20 +207,14 @@ module Pikuri
247
207
  out
248
208
  end
249
209
 
250
- # If +html+ has a +<main>+ or +<article>+ element holding
251
- # substantially more text than Readability extracted, return that
252
- # container's HTML so the caller can render it instead. Returns
253
- # +nil+ when the fallback should not fire — when there is no
254
- # semantic container, when it's too small to be meaningful, or
255
- # when Readability's output is already comparable.
210
+ # Return the +<main>+/+<article>+ container's HTML when it holds
211
+ # substantially more text than Readability extracted, else +nil+ (no
212
+ # container, too small, or Readability comparable).
256
213
  #
257
- # @param html [String] full HTML document body, used to locate
258
- # the +<main>+/+<article>+ container
259
- # @param readability_html [String] HTML produced by
260
- # +Readability::Document#content+, used as the comparison
261
- # baseline
262
- # @return [String, nil] container HTML when the fallback should
263
- # fire, +nil+ otherwise
214
+ # @param html [String] full HTML document body
215
+ # @param readability_html [String] +Readability::Document#content+ output,
216
+ # the comparison baseline
217
+ # @return [String, nil] container HTML when the fallback should fire
264
218
  def self.main_fallback_html(html, readability_html)
265
219
  doc = Nokogiri::HTML(html)
266
220
  container = doc.at_css('main') || doc.at_css('article')
@@ -276,20 +230,19 @@ module Pikuri
276
230
  end
277
231
  private_class_method :main_fallback_html
278
232
 
279
- # JSON-LD fields can be a string, hash, or array of either.
280
- # Normalize to a single hash (the first one if it's a list) so
281
- # callers can +.dig+ safely.
233
+ # Normalize a JSON-LD field (string, hash, or array of either) to a single
234
+ # hash (first if a list) so callers can +.dig+ safely.
282
235
  #
283
236
  # @param value [Object] raw JSON-LD field value
284
- # @return [Hash] empty hash when +value+ does not contain a hash
237
+ # @return [Hash] empty hash when +value+ contains no hash
285
238
  def self.first_obj(value)
286
239
  value = value.first if value.is_a?(Array)
287
240
  value.is_a?(Hash) ? value : {}
288
241
  end
289
242
  private_class_method :first_obj
290
243
 
291
- # Same idea as {.first_obj} but preserves a bare string (e.g.
292
- # +brand: "Apple"+) instead of replacing it with +{}+.
244
+ # Like {.first_obj} but preserves a bare string (e.g. +brand: "Apple"+)
245
+ # instead of replacing it with +{}+.
293
246
  #
294
247
  # @param value [Object] raw JSON-LD field value
295
248
  # @return [String, Hash, nil]
@@ -2,24 +2,18 @@
2
2
 
3
3
  module Pikuri
4
4
  module Extractor
5
- # The terminal plain-text arm of the registry: content that *is*
6
- # already text needs no extraction, so it passes through verbatim
7
- # (forced to UTF-8 — invalid bytes are left in for downstream to
8
- # deal with, matching what +File.read+ with a UTF-8 encoding does).
9
- # Markdown, source files, JSON, robots.txt all land here.
5
+ # The terminal plain-text arm of the registry: content that *is* already
6
+ # text passes through verbatim (forced to UTF-8, invalid bytes left in, as
7
+ # +File.read+ does). Markdown, source files, JSON, robots.txt land here.
10
8
  #
11
- # Matching is split by whether the transport supplied a
12
- # content-type:
9
+ # Matching splits on whether the transport gave a content-type:
13
10
  #
14
- # * With a content-type (the web path): claim +text/*+ only.
15
- # A non-text type that no earlier extractor claimed is *not*
16
- # second-guessed by sniffing — a server declaring
17
- # +application/octet-stream+ gets the {Unsupported} refusal the
18
- # LLM can react to, same as before this registry existed.
19
- # * Without one (the local-file path, where {FileType.detect_mime}
20
- # returned +nil+ for "unrecognised"): claim anything that passes
21
- # the {FileType.binary?} heuristic on the sample. Opaque
22
- # binaries stay unclaimed and surface as {Unsupported}.
11
+ # * With one (the web path): claim +text/*+ only — a non-text type no
12
+ # earlier extractor claimed is *not* second-guessed by sniffing
13
+ # (+application/octet-stream+ gets the {Unsupported} refusal).
14
+ # * Without one (the local-file path, {FileType.detect_mime} → +nil+): claim
15
+ # anything passing {FileType.binary?} on the sample; opaque binaries stay
16
+ # unclaimed and surface as {Unsupported}.
23
17
  module Passthrough
24
18
  # @return [Symbol] {Page#kind} tag.
25
19
  def self.kind
@@ -37,25 +31,21 @@ module Pikuri
37
31
  end
38
32
 
39
33
  # @param io [IO, StringIO] IO over the text content.
40
- # @return [String] the content, tagged UTF-8. Deliberately NOT
41
- # derived from {.extract_lines} — a passthrough must stay
42
- # verbatim (trailing newline, CRLF line endings), which a
43
- # join of chomped lines would silently normalize away.
34
+ # @return [String] the content, tagged UTF-8. Deliberately NOT derived
35
+ # from {.extract_lines} — a passthrough stays verbatim (trailing
36
+ # newline, CRLF), which joining chomped lines would normalize away.
44
37
  def self.extract(io)
45
38
  io.read.force_encoding(Encoding::UTF_8)
46
39
  end
47
40
 
48
- # The lazy line stream for {Extractor.extract_paged}: the IO is
49
- # read line-by-line, so a window over the head of a gigabyte
50
- # log never loads the rest. Consuming the whole stream is a
51
- # cheap sequential read — which is why the paging window counts
52
- # this stream's tail for an exact +total_lines+ (see
53
- # {Extractor.extract_paged}).
41
+ # Lazy line stream for {Extractor.extract_paged}: read line-by-line, so a
42
+ # window over the head of a gigabyte log never loads the rest. The whole
43
+ # stream is a cheap sequential read — which is why paging counts this
44
+ # tail for an exact +total_lines+.
54
45
  #
55
- # @param io [IO, StringIO] IO over the text content; must
56
- # remain open while the enumerator is consumed.
57
- # @return [Enumerator::Lazy<String>] chomped lines, tagged
58
- # UTF-8.
46
+ # @param io [IO, StringIO] IO over the text content; must stay open while
47
+ # the enumerator is consumed.
48
+ # @return [Enumerator::Lazy<String>] chomped lines, tagged UTF-8.
59
49
  def self.extract_lines(io)
60
50
  io.each_line.lazy.map { |raw| raw.chomp.force_encoding(Encoding::UTF_8) }
61
51
  end