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
@@ -1,82 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pikuri
4
- # The format→text extraction seam: one registry of extractors that
5
- # turn an +IO+ of some recognised format (HTML and plain text out
6
- # of the box; PDF / office formats via the pikuri-pdf /
7
- # pikuri-extractors plug-in gems) into Markdown-flavoured UTF-8
8
- # text, consumed through two front doors:
4
+ # The format→text extraction seam: one ordered registry of extractors
5
+ # turning an +IO+ of a recognised format (HTML + plain text in core;
6
+ # PDF / office formats via the pikuri-pdf / pikuri-extractors gems) into
7
+ # Markdown-flavoured UTF-8, behind two front doors:
9
8
  #
10
- # * {.extract} — the whole document as one String. The shape the
11
- # indexing / caching callers want ({Pikuri::VectorDb}'s indexer,
12
- # {Tool::WebScrape}'s URL cache): no windowing, no presentation.
13
- # * {.extract_paged} — the LLM-tool shape: the same extraction,
14
- # windowed to a line range with a byte cap, returned as a {Page}
15
- # the caller renders. Backs +Workspace::Read+ and
16
- # +VectorDb::Tools::Read+ (via the {FileType} path wrappers) so
17
- # the offset/limit/byte-cap logic lives in one tested place.
9
+ # * {.extract} — the whole document as one String (no windowing).
10
+ # * {.extract_paged} — the same, windowed to a line range with a byte
11
+ # cap, as a {Page} the caller renders.
18
12
  #
19
- # Both front doors — +Tool::Scraper+ dispatching on the HTTP
20
- # +Content-Type+ header for the web tools, and {FileType} resolving
21
- # local paths — route through this one registry, so both share one
22
- # set of format truths and "support a new format" is a registry
23
- # entry (pikuri-pdf and pikuri-extractors plug PDF and office
24
- # formats in without pikuri-core knowing), not a new special case
25
- # in two dispatchers.
13
+ # +Tool::Scraper+ (dispatching on the HTTP +Content-Type+) and {FileType}
14
+ # (resolving local paths) both route through this registry, so "support a
15
+ # new format" is a registry entry, not a new dispatcher arm.
26
16
  #
27
17
  # == The extractor duck type
28
18
  #
29
- # Each {.registry} entry implements three methods:
19
+ # Each {.registry} entry implements:
30
20
  #
31
- # * +matches?(sample:, content_type:)+ → +Boolean+ — claim the
32
- # content. +sample+ is the leading {FileType::SAMPLE_BYTES} bytes
33
- # (for magic-byte sniffs); +content_type+ is the normalized HTTP
34
- # +Content-Type+ for web content, the {FileType.detect_mime}
35
- # result for local files, and may be +nil+ ("no transport
36
- # metadata — sniff if you can").
37
- # * +extract(io)+ → +String+ — the whole document as
38
- # Markdown-flavoured UTF-8 text. Raises {Error} on content the
39
- # extractor claimed but cannot parse (malformed PDF, ...).
40
- # * +kind+ → +Symbol+ — a short tag (+:text+ / +:pdf+ / +:html+)
41
- # carried on {Page#kind} so rendering callers can word
42
- # format-specific trailers ("End of PDF", the scanned-image
43
- # hint) without re-sniffing.
21
+ # * +matches?(sample:, content_type:)+ → +Boolean+ — claim the content.
22
+ # +sample+ is the leading {FileType::SAMPLE_BYTES} (magic-byte sniff);
23
+ # +content_type+ is the normalized HTTP/{FileType.detect_mime} type, or
24
+ # +nil+ ("no metadata — sniff if you can").
25
+ # * +extract(io)+ → +String+ — whole document as Markdown-flavoured UTF-8.
26
+ # Raises {Error} on content it claimed but can't parse.
27
+ # * +kind+ → +Symbol+ — a short tag (+:text+ / +:pdf+ / +:html+) carried
28
+ # on {Page#kind} so callers word format-specific trailers without
29
+ # re-sniffing.
44
30
  #
45
- # plus one *optional* method for formats whose lines can be
46
- # produced incrementally:
31
+ # plus one *optional* method:
47
32
  #
48
- # * +extract_lines(io)+ → +Enumerator<String>+ — the same content
49
- # as +extract+, as a lazy stream of already-+chomp+ed lines.
50
- # {.extract_paged} prefers this when present and stops consuming
51
- # the moment the window fills, so the rest of the document is
52
- # never parsed (pikuri-pdf's extractor: pdf-reader's page list
53
- # parses on access; {Passthrough}: the IO is read line-by-line).
54
- # The enumerator
55
- # must be consumed while +io+ is still open, and may raise
56
- # {Error} mid-iteration. Extractors that need the whole document
57
- # to produce anything ({HTML}: Readability walks the full DOM —
58
- # true of any subprocess-based extractor too) simply omit it;
59
- # {.extract_paged} then extracts in full and windows the result.
33
+ # * +extract_lines(io)+ → +Enumerator<String>+ — the same content as a
34
+ # lazy stream of +chomp+ed lines. {.extract_paged} prefers it and stops
35
+ # the moment the window fills, so the document's tail is never parsed
36
+ # (pikuri-pdf parses pages on access). Must be consumed while +io+ is
37
+ # open; may raise {Error} mid-iteration. Extractors needing the whole
38
+ # document ({HTML}, any subprocess-based one) omit it, and
39
+ # {.extract_paged} extracts in full then windows.
60
40
  #
61
- # Windowing itself (offset / limit / byte cap / line truncation) is
62
- # presentation and deliberately lives once in {.extract_paged}, not
63
- # per extractor — +extract_lines+ is line *production*, the only
64
- # genuinely format-specific half of paging.
41
+ # Windowing (offset / limit / byte cap / truncation) is presentation and
42
+ # lives once in {.extract_paged}, never per extractor — +extract_lines+ is
43
+ # line *production*, the only format-specific half of paging.
65
44
  #
66
45
  # == Errors
67
46
  #
68
- # Both failure modes are failures the *caller's* LLM can react to,
69
- # so they share one rescuable root:
47
+ # Both modes are failures the caller's LLM can react to, under one
48
+ # rescuable root:
70
49
  #
71
- # * {Unsupported} — nothing in {.registry} claimed the content
72
- # (opaque binary, an unhandled content-type).
73
- # * {Error} (the root) — an extractor claimed the content but the
74
- # parse failed (malformed PDF, ...).
75
- #
76
- # Callers map them to their own conventions:
77
- # +Tool::Scraper+ re-raises both as +FetchError+;
78
- # {FileType.read_as_text} maps {Unsupported} to the +ArgumentError+
79
- # binary refusal and {Error} to a +RuntimeError+ carrying the path.
50
+ # * {Unsupported} — nothing in {.registry} claimed the content.
51
+ # * {Error} (the root) — an extractor claimed it but the parse failed.
80
52
  module Extractor
81
53
  module_function
82
54
 
@@ -108,71 +80,49 @@ module Pikuri
108
80
  # {PAGE_MAX_LINE_LENGTH}.
109
81
  PAGE_LINE_TRUNCATION_MARKER = "... (line truncated to #{PAGE_MAX_LINE_LENGTH} chars)"
110
82
 
111
- # One windowed slice of a document, returned by {.extract_paged}.
112
- # The caller turns this into an observation; this struct carries
113
- # everything a trailer needs without the caller re-reading the
114
- # document.
115
- #
116
- # == Fields
83
+ # One windowed slice of a document, returned by {.extract_paged},
84
+ # carrying everything a trailer needs without re-reading the document.
117
85
  #
118
- # * +lines+ — +Array<String>+, the collected window. Already
119
- # per-line truncated (with {PAGE_LINE_TRUNCATION_MARKER}); *not*
120
- # line-numbered — numbering is presentation the caller adds. For
121
- # a PDF the array includes the +"--- Page N ---"+ marker lines
122
- # pikuri-pdf's extractor emits, which count toward +limit+ / the
123
- # byte cap like any other line.
124
- # * +start_line+ — the 1-indexed line number of +lines.first+
125
- # (i.e. the +offset+ the caller asked for). +lines.last+ is at
126
- # +start_line + lines.length - 1+.
127
- # * +total_lines+ — total line count of the document when known,
128
- # else +nil+. Known when the read reached EOF, when the format
129
- # was extracted in full (no +extract_lines+ — e.g. HTML), or
130
- # when the lazy stream is cheap enough to count to the end
131
- # (plain text). +nil+ when a lazy stream stopped early — the
132
- # byte cap fired, or a PDF filled the window before its last
133
- # page (counting the rest would mean parsing every page,
134
- # defeating the laziness).
135
- # * +more+ — +true+ if content remains past this window (the
136
- # caller should offer +offset = start_line + lines.length+).
137
- # * +byte_capped+ — +true+ if the byte cap (not the line limit)
138
- # was the stopping criterion.
139
- # * +kind+ — the matched extractor's +kind+ tag (+:text+ /
140
- # +:pdf+ / +:html+); lets the caller word format-specific
141
- # trailers and the empty-document message.
86
+ # * +lines+ — +Array<String>+, the collected window; already per-line
87
+ # truncated (with {PAGE_LINE_TRUNCATION_MARKER}), *not* line-numbered
88
+ # (numbering is the caller's presentation). Includes any +"--- Page N
89
+ # ---"+ marker lines, which count toward +limit+/the byte cap.
90
+ # * +start_line+ — 1-indexed line number of +lines.first+ (the requested
91
+ # +offset+).
92
+ # * +total_lines+ — +Integer+ document line count when known, else +nil+.
93
+ # Known at EOF, when extracted in full (HTML), or on a cheap-to-count
94
+ # stream (text); +nil+ when a lazy stream stopped early (byte cap, or a
95
+ # PDF window filled before the last page — counting the rest would
96
+ # parse every page).
97
+ # * +more+ — +true+ if content remains past this window.
98
+ # * +byte_capped+ — +true+ if the byte cap (not the line limit) stopped it.
99
+ # * +kind+ — the matched extractor's +kind+ tag.
142
100
  #
143
- # An empty document yields +lines: []+, +total_lines: 0+; an
144
- # +offset+ past EOF yields +lines: []+ with +total_lines+ set to
145
- # the real (non-zero) count — the caller distinguishes the two.
101
+ # Empty document → +lines: []+, +total_lines: 0+; +offset+ past EOF →
102
+ # +lines: []+ with +total_lines+ at the real count — the caller
103
+ # distinguishes the two.
146
104
  Page = Data.define(:lines, :start_line, :total_lines, :more, :byte_capped, :kind)
147
105
 
148
- # The extractor registry, consulted in order — first match wins.
149
- # Core ships two entries: {HTML} matches on content-type, and
150
- # {Passthrough} is the terminal plain-text arm. A gem adding a
151
- # format picks its insertion point by the strength of its claim:
152
- # a magic-byte sniff that never misfires on text goes at the
153
- # *front* so it beats {HTML}'s content-type match even under a
154
- # lying header (+registry.unshift(X)+ — pikuri-pdf does this);
155
- # a content-type / weaker-sniff claimer inserts before the
156
- # terminal entry (+registry.insert(-2, X)+ — pikuri-extractors
157
- # does this).
106
+ # The extractor registry, consulted in order — first match wins. Core
107
+ # ships {HTML} (content-type) then the terminal {Passthrough} plain-text
108
+ # arm. A gem picks its insertion point by claim strength: a magic-byte
109
+ # sniff that never misfires on text goes at the *front* to beat {HTML}
110
+ # under a lying header (+registry.unshift(X)+ — pikuri-pdf); a
111
+ # content-type/weaker claimer inserts before the terminal entry
112
+ # (+registry.insert(-2, X)+ — pikuri-extractors).
158
113
  #
159
- # @return [Array<#matches?>] mutable, deliberately — this is the
160
- # plug-in seam.
114
+ # @return [Array<#matches?>] mutable, deliberately — the plug-in seam.
161
115
  def registry
162
116
  @registry ||= [HTML, Passthrough]
163
117
  end
164
118
 
165
- # Extract the whole document behind +io+ as one Markdown-flavoured
166
- # UTF-8 String. May be empty (empty text file, scanned-image PDF
167
- # with no extractable text).
119
+ # Extract the whole document behind +io+ as one Markdown-flavoured UTF-8
120
+ # String. May be empty (empty text file, scanned-image PDF).
168
121
  #
169
- # @param io [IO, StringIO] seekable IO positioned at the start of
170
- # the content; this method reads a leading sample for the
171
- # +matches?+ sniff and rewinds before extracting.
172
- # @param content_type [String, nil] normalized content-type when
173
- # the transport supplies one (HTTP header, {FileType.detect_mime}
174
- # result); +nil+ when unknown — extractors then rely on their
175
- # byte sniffs.
122
+ # @param io [IO, StringIO] seekable IO at the content start; reads a
123
+ # leading sample for the sniff and rewinds before extracting.
124
+ # @param content_type [String, nil] normalized content-type when the
125
+ # transport supplies one; +nil+ when unknown (extractors sniff).
176
126
  # @return [String]
177
127
  # @raise [Unsupported] when no registry entry claims the content.
178
128
  # @raise [Error] when the matched extractor cannot parse it.
@@ -180,41 +130,34 @@ module Pikuri
180
130
  extractor_for(io, content_type).extract(io)
181
131
  end
182
132
 
183
- # Extract +io+ and return a windowed {Page}: the lines from
184
- # +offset+ (1-indexed) up to +limit+ of them, stopping early if
185
- # +max_bytes+ is reached, with over-long lines truncated at
133
+ # Extract +io+ into a windowed {Page}: lines from +offset+ (1-indexed) up
134
+ # to +limit+, stopping early at +max_bytes+, over-long lines truncated at
186
135
  # +max_line_length+.
187
136
  #
188
- # Lazy where the format allows: extractors that implement
189
- # +extract_lines+ (plain text; pikuri-pdf's PDF) are consumed
190
- # only until the window fills — reading the first window of a
191
- # 500-page PDF parses a handful of pages, and the first page of
192
- # a gigabyte log never loads it. Extractors without it (HTML) are extracted
193
- # in full and then windowed, which is also what makes their
194
- # +total_lines+ always exact.
137
+ # Lazy where the format allows: +extract_lines+ extractors (text,
138
+ # pikuri-pdf) are consumed only until the window fills — a 500-page PDF's
139
+ # first window parses a handful of pages. Extractors without it (HTML) are
140
+ # extracted in full then windowed, which is why their +total_lines+ is
141
+ # always exact.
195
142
  #
196
- # @param io [IO, StringIO] seekable IO positioned at the start.
143
+ # @param io [IO, StringIO] seekable IO at the start.
197
144
  # @param content_type [String, nil] as for {.extract}.
198
- # @param offset [Integer] 1-indexed first line to include. The
199
- # caller is responsible for validating +offset >= 1+.
200
- # @param limit [Integer] maximum lines to collect. Caller
201
- # validates +limit >= 1+.
145
+ # @param offset [Integer] 1-indexed first line; caller validates +>= 1+.
146
+ # @param limit [Integer] max lines to collect; caller validates +>= 1+.
202
147
  # @param max_bytes [Integer] hard byte cap on collected content.
203
148
  # @param max_line_length [Integer] per-line truncation threshold.
204
- # @return [Page] the windowed slice.
149
+ # @return [Page]
205
150
  # @raise [Unsupported] when no registry entry claims the content.
206
151
  # @raise [Error] when the matched extractor cannot parse it.
207
152
  def extract_paged(io, content_type: nil, offset: 1, limit: PAGE_DEFAULT_LIMIT,
208
153
  max_bytes: PAGE_MAX_BYTES, max_line_length: PAGE_MAX_LINE_LENGTH)
209
154
  extractor = extractor_for(io, content_type)
210
155
  if extractor.respond_to?(:extract_lines)
211
- # count_tail is a per-format economics call: once the window
212
- # fills, counting the rest of a plain-text stream is a cheap
213
- # sequential read (so the trailer can say "of N"), while for
214
- # a PDF it would mean parsing every remaining page — exactly
215
- # what extract_lines exists to avoid. Plugged-in extractors
216
- # (pikuri-pdf's included) get the conservative default (stop
217
- # early, total unknown).
156
+ # count_tail is a per-format economics call: once the window fills,
157
+ # counting the rest of a text stream is a cheap sequential read (so
158
+ # the trailer says "of N"), but for a PDF it would parse every
159
+ # remaining page — what extract_lines exists to avoid. Plugged-in
160
+ # extractors get the conservative default (stop early, total unknown).
218
161
  window(extractor.extract_lines(io),
219
162
  offset: offset, limit: limit, max_bytes: max_bytes,
220
163
  max_line_length: max_line_length, kind: extractor.kind,
@@ -227,11 +170,10 @@ module Pikuri
227
170
  end
228
171
  end
229
172
 
230
- # Find the first registry entry claiming +io+'s content: read the
231
- # leading {FileType::SAMPLE_BYTES} for the sniff, rewind, and ask
232
- # each extractor in order.
173
+ # First registry entry claiming +io+'s content: read the leading
174
+ # {FileType::SAMPLE_BYTES} for the sniff, rewind, ask each in order.
233
175
  #
234
- # @param io [IO, StringIO] seekable IO positioned at the start.
176
+ # @param io [IO, StringIO] seekable IO at the start.
235
177
  # @param content_type [String, nil]
236
178
  # @return [#extract] the matched extractor.
237
179
  # @raise [Unsupported] when nothing matches.
@@ -244,15 +186,12 @@ module Pikuri
244
186
  end
245
187
  private_class_method :extractor_for
246
188
 
247
- # Collect a {Page} window out of +lines+ (an Array or a lazy
248
- # Enumerator of already-+chomp+ed lines). +known_total+ is the
249
- # full line count when the caller extracted everything up front
250
- # (Array case), +nil+ for a lazy stream — then +total_lines+ is
251
- # exact only if the iteration reached EOF: +count_tail+ keeps
252
- # the loop counting (without collecting) past the line limit
253
- # when consuming the rest of the stream is cheap; without it the
254
- # loop breaks and leaves the total unknown. The byte cap always
255
- # aborts the count.
189
+ # Collect a {Page} window from +lines+ (Array or lazy Enumerator of
190
+ # +chomp+ed lines). +known_total+ is the full count when extracted up
191
+ # front (Array), +nil+ for a lazy stream — then +total_lines+ is exact
192
+ # only if iteration reached EOF: +count_tail+ keeps counting past the
193
+ # limit when the tail is cheap, else the loop breaks and the total stays
194
+ # unknown. The byte cap always aborts the count.
256
195
  #
257
196
  # @param lines [Enumerable<String>]
258
197
  # @param known_total [Integer, nil]
@@ -2,66 +2,37 @@
2
2
 
3
3
  module Pikuri
4
4
  # Magic-byte content sniffing, plus the path-aware front over the
5
- # {Extractor} registry. Two responsibilities:
5
+ # {Extractor} registry. Two core responsibilities:
6
6
  #
7
- # * {.detect_mime} — recognise a file from its leading bytes. Returns
8
- # a MIME String for formats pikuri knows how to handle specially
9
- # ({+application/pdf+}, the four image formats), or +nil+ for
10
- # "unrecognised — could be text, could be opaque binary; caller
11
- # decides".
12
- # * {.binary?} — heuristic text-vs-binary classifier. Independent of
13
- # {.detect_mime}: a file can be both recognised (e.g. PDF) *and*
14
- # binary. {.detect_mime} tells you what the bytes are;
15
- # {.binary?} tells you whether they're safe to render as text.
7
+ # * {.detect_mime} — recognise a file from its leading bytes. Returns a
8
+ # MIME String for formats pikuri handles specially (+application/pdf+,
9
+ # the four image formats), or +nil+ for "unrecognised".
10
+ # * {.binary?} — text-vs-binary heuristic, independent of {.detect_mime}
11
+ # (a file can be both recognised, e.g. PDF, *and* binary): what the
12
+ # bytes are vs. whether they're safe to render as text.
16
13
  #
17
- # On top of those sit the two +Pathname+ conveniences,
18
- # {.read_as_text} (whole document, the {Pikuri::VectorDb} indexer's
19
- # shape) and {.read_as_text_paged} (line-windowed, the Read tools'
20
- # shape). Both are thin wrappers: they own the *path-level* refusals
21
- # (missing file, directory, image) and the exception mapping, then
22
- # hand the opened IO to {Extractor.extract} /
23
- # {Extractor.extract_paged} — which format the bytes are and how
24
- # they become text is entirely the registry's business, so a gem
25
- # plugging a new extractor in extends these wrappers for free.
14
+ # On top sit two +Pathname+ conveniences, {.read_as_text} (whole document)
15
+ # and {.read_as_text_paged} (line-windowed). Both own the *path-level*
16
+ # refusals (missing file, directory, image) and the exception mapping,
17
+ # then hand the IO to {Extractor}; format-to-text is the registry's
18
+ # business, so a new extractor extends these wrappers for free.
26
19
  #
27
- # {.detect_mime} and {.binary?} accept either a +String+ of bytes
28
- # (sample taken by the caller) or a +Pathname+ — when given a path,
29
- # the module opens the file in binary mode and reads {SAMPLE_BYTES}
30
- # for the sniff itself. The Pathname form is the convenience path;
31
- # the bytes form is for callers that already have the sample or are
32
- # calling both methods on the same file and want to avoid a second
33
- # open.
34
- #
35
- # == Why a separate module
36
- #
37
- # Without this module, magic-byte tables and the binary heuristic
38
- # ended up scattered through whichever tool needed them — first PDF
39
- # in {Workspace::Read}, then images alongside it, then a copy of
40
- # {.binary?} reached for by {Workspace::Edit}. Collecting the
41
- # detection logic here lets {Read} focus on routing
42
- # (mime-to-formatter), {Edit} drop its cross-tool reach, and new
43
- # tools share one set of magic-byte truths.
20
+ # {.detect_mime}/{.binary?} take either a +String+ of bytes (caller
21
+ # sampled) or a +Pathname+ (opened binary, {SAMPLE_BYTES} read).
44
22
  #
45
23
  # == Deliberate non-goals
46
24
  #
47
- # * *Not a full MIME database.* The set grows when a pikuri tool
48
- # needs a new format, not speculatively. Keeps the "audit in an
49
- # evening" ceiling honest.
50
- # * *No path / extension fallback.* Extensions lie (a renamed
51
- # +.png+ → opaque garbage); magic-byte detection on the actual
52
- # content is the source of truth. Callers that need
53
- # extension-based behaviour can layer it themselves.
54
- # * *No convenience predicates* like +image?+ / +pdf?+. Callers do
55
- # +mime == 'application/pdf'+ or +mime&.start_with?('image/')+ —
56
- # one extra character, zero added API surface.
25
+ # * *Not a full MIME database.* The set grows when a tool needs a format,
26
+ # not speculatively.
27
+ # * *No path/extension fallback.* Extensions lie (a renamed +.png+);
28
+ # magic-byte detection on content is the source of truth.
29
+ # * *No +image?+/+pdf?+ predicates.* Callers do +mime == 'application/pdf'+
30
+ # — one character, zero added surface.
57
31
  module FileType
58
32
  module_function
59
33
 
60
- # @return [Integer] recommended number of bytes to sample for
61
- # {.detect_mime} and {.binary?}. Big enough to catch every
62
- # prefix pikuri sniffs today (the largest is WebP's 12-byte
63
- # container header) with comfortable slack; small enough that
64
- # reading it off any reasonable filesystem is effectively free.
34
+ # @return [Integer] bytes to sample for {.detect_mime}/{.binary?}. Covers
35
+ # every prefix sniffed today (largest: WebP's 12-byte header) with slack.
65
36
  SAMPLE_BYTES = 4096
66
37
 
67
38
  # @return [Float] fraction of the sample that may be non-printable
@@ -85,15 +56,13 @@ module Pikuri
85
56
  # with this five-byte ASCII sequence per ISO 32000-1 §7.5.2.
86
57
  PDF_MAGIC = '%PDF-'
87
58
 
88
- # Recognise a file from its leading bytes. Returns the MIME type
89
- # as a String for formats pikuri handles specially, or +nil+ for
90
- # "unrecognised" — callers interpret +nil+ themselves (text,
59
+ # Recognise a file from its leading bytes. Returns the MIME String for
60
+ # formats pikuri handles specially, or +nil+ for "unrecognised" (text,
91
61
  # opaque binary, ...).
92
62
  #
93
- # @param input [String, Pathname] the bytes to inspect, or a
94
- # +Pathname+ that this method opens in binary mode and reads up
95
- # to {SAMPLE_BYTES} from. Caller is responsible for verifying the
96
- # path exists; missing-file errors propagate as +Errno::ENOENT+.
63
+ # @param input [String, Pathname] bytes to inspect, or a +Pathname+
64
+ # opened binary and read up to {SAMPLE_BYTES}. Caller verifies the path
65
+ # exists; missing-file errors propagate as +Errno::ENOENT+.
97
66
  # @return [String, nil]
98
67
  def detect_mime(input)
99
68
  bytes = sample_of(input)
@@ -109,19 +78,13 @@ module Pikuri
109
78
  nil
110
79
  end
111
80
 
112
- # Heuristic text-vs-binary classifier matching opencode's: any
113
- # +NUL+ byte forces +true+; otherwise count bytes outside the
114
- # printable +\t \n \v \f \r+ + ASCII-32..126 range and ratio
115
- # against the sample size. UTF-8 continuation bytes (0x80-0xBF)
116
- # are >127 so they sit outside the non-printable ranges and pass
117
- # through unflagged, letting UTF-8 text read fine. An empty
118
- # sample is treated as not-binary (callers reading an empty file
119
- # take the empty-text path).
81
+ # Text-vs-binary heuristic (opencode's): any +NUL+ forces +true+; else
82
+ # ratio bytes outside printable (+\t\n\v\f\r+ + ASCII 32..126) against the
83
+ # sample. UTF-8 continuation bytes (0x80-0xBF) sit above 127 so they pass
84
+ # unflagged; an empty sample is not-binary.
120
85
  #
121
- # @param input [String, Pathname] the bytes to inspect, or a
122
- # +Pathname+ that this method opens in binary mode and reads up
123
- # to {SAMPLE_BYTES} from. Caller is responsible for verifying
124
- # the path exists.
86
+ # @param input [String, Pathname] bytes, or a +Pathname+ opened binary
87
+ # and read up to {SAMPLE_BYTES}. Caller verifies the path exists.
125
88
  # @return [Boolean]
126
89
  def binary?(input)
127
90
  bytes = sample_of(input)
@@ -136,37 +99,19 @@ module Pikuri
136
99
  non_printable.to_f / bytes.bytesize > BINARY_NONPRINTABLE_THRESHOLD
137
100
  end
138
101
 
139
- # Read +path+ and return its content as plain UTF-8 text, routed
140
- # through the {Extractor} registry: anything
141
- # unrecognised-but-textual passes through verbatim
142
- # ({Extractor::Passthrough}); with pikuri-pdf registered, PDFs
143
- # are extracted with +"--- Page N ---"+ markers (a scanned-image
144
- # PDF with no extractable text comes back as the empty String, a
145
- # deliberate silent skip callers detect by length if they care).
146
- #
147
- # Refusal cases — all raise rather than returning a sentinel
148
- # because the callers are internal pikuri code, not an LLM
149
- # tool. The LLM-facing +Workspace::Read+ does its own routing and
150
- # returns "Error: ..." observations; that's a separate concern.
151
- #
152
- # * Path doesn't exist → +Errno::ENOENT+.
153
- # * Path is a directory → +ArgumentError+.
154
- # * Image (PNG / JPEG / GIF / WebP per {.detect_mime}) →
155
- # +ArgumentError+; images aren't text.
156
- # * Content no extractor claims (opaque binary) →
157
- # +ArgumentError+, mapped from {Extractor::Unsupported}.
158
- # * Extraction failure (malformed PDF, ...) → +RuntimeError+ with
159
- # the path included, mapped from {Extractor::Error} so callers
160
- # don't need to know any extractor's exception hierarchy.
102
+ # Read +path+ as plain UTF-8 text through the {Extractor} registry:
103
+ # unrecognised-but-textual passes through verbatim; with pikuri-pdf
104
+ # registered, PDFs get +"--- Page N ---"+ markers (a scanned-image PDF
105
+ # comes back empty — a deliberate silent skip callers detect by length).
106
+ # Refusals raise (callers are internal pikuri code, not an LLM tool).
161
107
  #
162
108
  # @param path [Pathname] file to read.
163
- # @return [String] UTF-8 text. May be empty (empty text file, or
164
- # scanned-image PDF).
165
- # @raise [ArgumentError] if +path+ isn't a +Pathname+, points at
166
- # a directory, is an image, or is binary.
109
+ # @return [String] UTF-8 text; may be empty (empty file, scanned PDF).
110
+ # @raise [ArgumentError] if +path+ isn't a +Pathname+, is a directory, an
111
+ # image, or opaque binary (the last mapped from {Extractor::Unsupported}).
167
112
  # @raise [Errno::ENOENT] if +path+ doesn't exist.
168
- # @raise [RuntimeError] on an extraction failure (malformed /
169
- # unsupported PDF, ...).
113
+ # @raise [RuntimeError] on an extraction failure (mapped from
114
+ # {Extractor::Error}, path included).
170
115
  def read_as_text(path)
171
116
  mime = guard_extractable(path)
172
117
  path.open('rb') { |io| Extractor.extract(io, content_type: mime) }
@@ -176,29 +121,21 @@ module Pikuri
176
121
  raise "Cannot extract text from #{path}: #{e.message}"
177
122
  end
178
123
 
179
- # Extract +path+ and return a windowed {Extractor::Page}: the
180
- # lines from +offset+ (1-indexed) up to +limit+ of them, stopping
181
- # early if +max_bytes+ is reached, with over-long lines truncated
182
- # at +max_line_length+. Same routing and refusal contract as
183
- # {.read_as_text}; the windowing semantics (including the lazy
184
- # +extract_lines+ consumption that stops parsing once the window
185
- # fills) are {Extractor.extract_paged}'s.
186
- # The LLM-facing callers map the exceptions into +"Error: ..."+
187
- # observations themselves.
124
+ # Extract +path+ into a windowed {Extractor::Page} (lines from +offset+ up
125
+ # to +limit+, byte-capped, long lines truncated). Same routing and refusal
126
+ # contract as {.read_as_text}; windowing semantics are
127
+ # {Extractor.extract_paged}'s.
188
128
  #
189
129
  # @param path [Pathname] file to read.
190
- # @param offset [Integer] 1-indexed first line to include. The
191
- # caller is responsible for validating +offset >= 1+.
192
- # @param limit [Integer] maximum lines to collect. Caller
193
- # validates +limit >= 1+.
130
+ # @param offset [Integer] 1-indexed first line; caller validates +>= 1+.
131
+ # @param limit [Integer] max lines to collect; caller validates +>= 1+.
194
132
  # @param max_bytes [Integer] hard byte cap on collected content.
195
133
  # @param max_line_length [Integer] per-line truncation threshold.
196
- # @return [Extractor::Page] the windowed slice.
197
- # @raise [ArgumentError] if +path+ isn't a +Pathname+, is a
198
- # directory, an image, or binary.
134
+ # @return [Extractor::Page]
135
+ # @raise [ArgumentError] if +path+ isn't a +Pathname+, is a directory, an
136
+ # image, or binary.
199
137
  # @raise [Errno::ENOENT] if +path+ doesn't exist.
200
- # @raise [RuntimeError] on an extraction failure (malformed /
201
- # unsupported PDF, ...).
138
+ # @raise [RuntimeError] on an extraction failure.
202
139
  def read_as_text_paged(path, offset: 1, limit: Extractor::PAGE_DEFAULT_LIMIT,
203
140
  max_bytes: Extractor::PAGE_MAX_BYTES,
204
141
  max_line_length: Extractor::PAGE_MAX_LINE_LENGTH)
@@ -213,16 +150,13 @@ module Pikuri
213
150
  raise "Cannot extract text from #{path}: #{e.message}"
214
151
  end
215
152
 
216
- # The shared path-level refusals for {.read_as_text} /
217
- # {.read_as_text_paged}: must be an existing non-directory
218
- # +Pathname+, and not an image (images are data for a vision
219
- # model, never text). Returns the {.detect_mime} result so the
220
- # caller can pass it to the {Extractor} as the content-type hint.
153
+ # Shared path-level refusals for the read-as-text pair: an existing
154
+ # non-directory +Pathname+, not an image (images are vision-model data).
155
+ # Returns the {.detect_mime} result as the content-type hint.
221
156
  #
222
157
  # @param path [Pathname]
223
158
  # @return [String, nil] the sniffed MIME type.
224
- # @raise [ArgumentError] on a non-Pathname, a directory, or an
225
- # image.
159
+ # @raise [ArgumentError] on a non-Pathname, a directory, or an image.
226
160
  # @raise [Errno::ENOENT] if +path+ doesn't exist.
227
161
  def guard_extractable(path)
228
162
  raise ArgumentError, "expected Pathname, got #{path.class}" unless path.is_a?(Pathname)
@@ -236,20 +170,14 @@ module Pikuri
236
170
  end
237
171
  private_class_method :guard_extractable
238
172
 
239
- # Coerce an +input+ argument into a bytes String for the sniffs.
240
- # +String+ inputs are returned as-is (caller already sampled);
241
- # +Pathname+ inputs are opened in binary mode and up to
242
- # {SAMPLE_BYTES} are read off the front. Empty files come back
243
- # as an empty String — {.binary?} treats that as not-binary and
244
- # {.detect_mime} returns +nil+ for it, which is what the
245
- # empty-text path wants.
173
+ # Coerce +input+ into a bytes String for the sniffs: a +String+ is
174
+ # returned as-is, a +Pathname+ is opened binary and up to {SAMPLE_BYTES}
175
+ # read. Empty files → empty String (not-binary, +nil+ mime).
246
176
  #
247
177
  # @param input [String, Pathname]
248
- # @return [String] raw bytes (ASCII-8BIT encoding for the path
249
- # case; whatever the caller passed for the bytes case)
250
- # @raise [ArgumentError] if +input+ is neither a +String+ nor a
251
- # +Pathname+ — refuses to guess, since a bare String could be
252
- # either a path or actual bytes.
178
+ # @return [String] raw bytes.
179
+ # @raise [ArgumentError] if neither — refuses to guess, since a bare
180
+ # String could be a path or actual bytes.
253
181
  def sample_of(input)
254
182
  case input
255
183
  when String