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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +14 -3
data/lib/pikuri/extractor.rb
CHANGED
|
@@ -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
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
|
19
|
+
# Each {.registry} entry implements:
|
|
30
20
|
#
|
|
31
|
-
# * +matches?(sample:, content_type:)+ → +Boolean+ — claim the
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# +
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
# * +
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
|
46
|
-
# produced incrementally:
|
|
31
|
+
# plus one *optional* method:
|
|
47
32
|
#
|
|
48
|
-
# * +extract_lines(io)+ → +Enumerator<String>+ — the same content
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
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
|
|
62
|
-
#
|
|
63
|
-
#
|
|
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
|
|
69
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
# * +
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
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
|
-
#
|
|
144
|
-
# +
|
|
145
|
-
#
|
|
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
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
# a
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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 —
|
|
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
|
-
#
|
|
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
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
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+
|
|
184
|
-
# +
|
|
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
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
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
|
|
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
|
|
199
|
-
#
|
|
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]
|
|
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
|
-
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
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
|
-
#
|
|
231
|
-
#
|
|
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
|
|
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
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
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]
|
data/lib/pikuri/file_type.rb
CHANGED
|
@@ -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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
|
18
|
-
# {.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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}
|
|
28
|
-
#
|
|
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
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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]
|
|
61
|
-
#
|
|
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
|
|
89
|
-
#
|
|
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]
|
|
94
|
-
#
|
|
95
|
-
#
|
|
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
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
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]
|
|
122
|
-
#
|
|
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+
|
|
140
|
-
# through
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
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
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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 (
|
|
169
|
-
#
|
|
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+
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
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
|
|
191
|
-
#
|
|
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]
|
|
197
|
-
# @raise [ArgumentError] if +path+ isn't a +Pathname+, is a
|
|
198
|
-
#
|
|
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
|
|
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
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
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
|
|
240
|
-
# +
|
|
241
|
-
#
|
|
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
|
|
249
|
-
#
|
|
250
|
-
#
|
|
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
|