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
@@ -6,58 +6,43 @@ require 'uri'
6
6
 
7
7
  module Pikuri
8
8
  class Tool
9
- # HTTP side of the web tools ({Tool::WEB_SCRAPE} and {Tool::FETCH}):
10
- # GET the URL with a real-browser User-Agent, follow redirects, and
11
- # hand the response body to {Pikuri::Extractor.extract} with the
12
- # response's +Content-Type+ as the hint. HTML/XHTML render via
13
- # {Extractor::HTML}, any other +text/*+ type passes through
14
- # verbatim, and plug-in extractors extend the set (with pikuri-pdf
15
- # registered, +application/pdf+ extracts — by header or by +%PDF-+
16
- # magic, so a PDF served under a lying header still works); the
17
- # remaining types raise {FetchError} so the LLM observes the
18
- # failure instead of receiving an empty rendering.
9
+ # HTTP side of the web tools ({Tool::WEB_SCRAPE} and {Tool::FETCH}): GET the
10
+ # URL with a real-browser User-Agent, follow redirects, and hand the body to
11
+ # {Pikuri::Extractor.extract} with the response +Content-Type+ as the hint.
12
+ # HTML/XHTML renders via {Extractor::HTML}, other +text/*+ passes through
13
+ # verbatim, plug-in extractors extend the set (with pikuri-pdf,
14
+ # +application/pdf+ extracts — by header or +%PDF-+ magic, so a lying header
15
+ # still works); remaining types raise {FetchError} so the LLM observes the
16
+ # failure rather than an empty rendering.
19
17
  #
20
- # Split into a thin HTTP fetch ({.fetch}) and the extraction
21
- # wrapper ({.visit}) so tests can drive each piece in isolation and
22
- # {Tool::Fetch} can reuse the HTTP half without the extraction
23
- # pass. Nothing here knows about the LLM; the tools that wrap this
24
- # module own caching and truncation and turn rendered Markdown (or
25
- # {FetchError}) into the next observation.
18
+ # Split into a thin HTTP fetch ({.fetch}) and the extraction wrapper
19
+ # ({.visit}) so tests drive each in isolation and {Tool::Fetch} can reuse the
20
+ # HTTP half. Nothing here knows about the LLM — the wrapping tools own
21
+ # caching, truncation, and turning the result into an observation.
26
22
  module Scraper
27
- # Raised when a URL cannot be rendered into Markdown text — HTTP
28
- # non-2xx, network failure, redirect-loop, missing +Location+,
29
- # unsupported content-type, or a parse failure that reads as "try
30
- # a different URL" to the LLM. Catching this in
31
- # {Tool::WEB_SCRAPE} / {Tool::FETCH} turns the failure into an
32
- # +"Error: ..."+ observation; anything else bubbles up so genuine
33
- # bugs stay visible.
23
+ # Raised when a URL can't be rendered to Markdown — HTTP non-2xx, network
24
+ # failure, redirect loop, missing +Location+, unsupported content-type, or
25
+ # a parse failure. The web tools rescue this into an +"Error: ..."+
26
+ # observation; anything else bubbles up so genuine bugs stay visible.
34
27
  class FetchError < StandardError; end
35
28
 
36
- # @return [String] User-Agent sent with each request; many sites
37
- # reject requests with no UA or an obvious bot UA
29
+ # @return [String] User-Agent sent with each request; many sites reject a
30
+ # missing or obviously-bot UA.
38
31
  USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
39
32
  '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
40
- # @return [String] +Accept+ header sent with each request, so
41
- # servers that content-negotiate hand back something we can use:
42
- # rendered HTML first, +application/pdf+ for hosts with a PDF
43
- # extractor registered, then any +text/*+ for the verbatim
44
- # pass-through arm.
33
+ # @return [String] +Accept+ header, so content-negotiating servers hand
34
+ # back something usable: HTML first, then +application/pdf+, then +text/*+.
45
35
  ACCEPT = 'text/html,application/xhtml+xml,application/pdf,text/*;q=0.8'
46
- # @return [Integer] maximum number of HTTP redirects to follow
47
- # before giving up
36
+ # @return [Integer] maximum HTTP redirects to follow before giving up.
48
37
  MAX_REDIRECTS = 5
49
- # @return [Integer] connect timeout in seconds for the underlying
50
- # Faraday request
38
+ # @return [Integer] connect timeout (s) for the Faraday request.
51
39
  OPEN_TIMEOUT = 10
52
- # @return [Integer] read timeout in seconds for the underlying
53
- # Faraday request
40
+ # @return [Integer] read timeout (s) for the Faraday request.
54
41
  READ_TIMEOUT = 20
55
42
 
56
- # @return [Integer] maximum number of characters of an error
57
- # response body to include in a {FetchError} message. The body is
58
- # often a multi-kilobyte HTML challenge page (Cloudflare, WAF
59
- # interstitial, etc.); a short excerpt tells the LLM what kind of
60
- # page came back without flooding the next observation.
43
+ # @return [Integer] max chars of an error response body in a {FetchError}
44
+ # message — enough to show what came back (a Cloudflare/WAF page) without
45
+ # flooding the observation.
61
46
  ERROR_BODY_EXCERPT = 200
62
47
 
63
48
  # Result of a successful {Scraper.fetch}: the response body, the
@@ -65,46 +50,30 @@ module Pikuri
65
50
  # parameters stripped), and the final URL after redirects.
66
51
  Fetched = Data.define(:body, :content_type, :url)
67
52
 
68
- # Fetch +url+ and render its main content as Markdown.
53
+ # Fetch +url+ and render its main content as Markdown. No caching — every
54
+ # call hits the network (wrap it to memoize; {Tool::WebScrape.visit}
55
+ # does). The output is +strip+'d (leading/trailing blank lines are common
56
+ # from PDF page-feeds and trailing newlines) but interior whitespace is
57
+ # preserved (paragraph breaks and code indentation are load-bearing).
69
58
  #
70
- # No caching here — every call hits the network. Callers that want
71
- # to memoize results should wrap this method themselves (see
72
- # {Tool::WebScrape.visit}, which does exactly that).
73
- #
74
- # The extracted output is +String#strip+'d so the LLM never sees
75
- # a body that opens or closes with blank lines — common with
76
- # extracted PDFs' page-feed whitespace and with text bodies that
77
- # carry a trailing newline. Interior whitespace is preserved
78
- # because Markdown paragraph breaks and source-code indentation
79
- # are load-bearing.
80
- #
81
- # @param url [String] absolute HTTP(S) URL of the page to download
82
- # @return [String] full Markdown representation of the page with
83
- # leading/trailing whitespace trimmed, uncapped otherwise —
84
- # caller is responsible for any size limiting before feeding
85
- # the result back to the LLM
86
- # @raise [FetchError] on HTTP non-2xx, network failure, redirect
87
- # loop, a 3xx without a +Location+ header, a response no
88
- # extractor recognizes, or an extraction failure (malformed
89
- # PDF, ...)
59
+ # @param url [String] absolute HTTP(S) URL
60
+ # @return [String] Markdown, whitespace-trimmed, otherwise uncapped —
61
+ # caller size-limits before feeding the LLM
62
+ # @raise [FetchError] on HTTP non-2xx, network failure, redirect loop, a
63
+ # 3xx without +Location+, an unrecognized response, or extraction failure
90
64
  def self.visit(url)
91
65
  extract(fetch(url)).strip
92
66
  end
93
67
 
94
- # Render a {Fetched} response as Markdown via
95
- # {Pikuri::Extractor.extract}, re-raising both extraction failure
96
- # modes as {FetchError} — the single exception type the web tools
97
- # rescue. The content-type is passed verbatim (including the +""+
98
- # of a missing header, which matches no text arm — a body without
99
- # transport metadata is refused, not sniffed; only a strong magic
100
- # sniff like pikuri-pdf's +%PDF-+ overrides a wrong or missing
101
- # header, because such a sniff never misfires on text).
68
+ # Render a {Fetched} response as Markdown via {Pikuri::Extractor.extract},
69
+ # re-raising both extraction failure modes as {FetchError}. The
70
+ # content-type is passed verbatim (including +""+ for a missing header,
71
+ # which matches no text arm — a body without transport metadata is
72
+ # refused, not sniffed; only a strong magic sniff like +%PDF-+ overrides).
102
73
  #
103
74
  # @param fetched [Fetched]
104
- # @return [String] Markdown representation produced by the
105
- # matched extractor
106
- # @raise [FetchError] when no extractor matches the response's
107
- # content-type, or when extraction fails
75
+ # @return [String] Markdown from the matched extractor
76
+ # @raise [FetchError] when no extractor matches, or extraction fails
108
77
  def self.extract(fetched)
109
78
  Pikuri::Extractor.extract(StringIO.new(fetched.body), content_type: fetched.content_type)
110
79
  rescue Pikuri::Extractor::Unsupported
@@ -113,26 +82,17 @@ module Pikuri
113
82
  raise FetchError, e.message
114
83
  end
115
84
 
116
- # Download the body of +url+, manually following up to
117
- # {MAX_REDIRECTS} redirects. Faraday is configured with no
118
- # middleware so behavior here mirrors the rest of the codebase
119
- # (see +Tool::Search::DuckDuckGo.search+).
120
- #
121
- # All recoverable failures — HTTP 4xx/5xx, +Faraday::Error+ network
122
- # blips, exhausted redirect budget, 3xx without a +Location+ —
123
- # surface as {FetchError} so the caller has a single exception type
124
- # to rescue. Error bodies are trimmed to {ERROR_BODY_EXCERPT}
125
- # characters with whitespace collapsed, so a Cloudflare-challenge
126
- # response doesn't dump kilobytes of inline HTML into the next LLM
127
- # observation.
85
+ # Download +url+, manually following up to {MAX_REDIRECTS} redirects. All
86
+ # recoverable failures (HTTP 4xx/5xx, +Faraday::Error+, exhausted redirect
87
+ # budget, 3xx without +Location+) surface as {FetchError}. Error bodies
88
+ # are trimmed to {ERROR_BODY_EXCERPT} so a challenge page doesn't dump
89
+ # kilobytes into the observation.
128
90
  #
129
- # @param url [String] absolute HTTP(S) URL to fetch
130
- # @param limit [Integer] redirects remaining; recurses with
131
- # +limit - 1+ on each 3xx
132
- # @return [Fetched] body, normalized content-type, and final URL
133
- # after redirects
134
- # @raise [FetchError] on non-2xx/3xx responses, network errors,
135
- # redirect-loop exhaustion, or 3xx without a +Location+ header
91
+ # @param url [String] absolute HTTP(S) URL
92
+ # @param limit [Integer] redirects remaining; recurses with +limit - 1+ on 3xx
93
+ # @return [Fetched] body, normalized content-type, final URL
94
+ # @raise [FetchError] on non-2xx/3xx, network error, redirect-loop
95
+ # exhaustion, or 3xx without +Location+
136
96
  def self.fetch(url, limit: MAX_REDIRECTS)
137
97
  raise FetchError, "too many redirects fetching #{url}" if limit.zero?
138
98
 
@@ -169,10 +129,8 @@ module Pikuri
169
129
  end
170
130
  private_class_method :normalize_content_type
171
131
 
172
- # Whitespace-collapse +body+ and clip to {ERROR_BODY_EXCERPT}
173
- # characters, so the {FetchError} message stays a single readable
174
- # line even when the server returned a multi-KB HTML challenge
175
- # page.
132
+ # Whitespace-collapse +body+ and clip to {ERROR_BODY_EXCERPT}, keeping the
133
+ # {FetchError} message a single readable line.
176
134
  #
177
135
  # @param body [String, nil]
178
136
  # @return [String]
@@ -7,44 +7,33 @@ require 'nokogiri'
7
7
  module Pikuri
8
8
  class Tool
9
9
  module Search
10
- # Performs a Brave Search via the official Web Search API and returns
11
- # the hits as a list of {Result} rows. Split into a thin HTTP fetch
12
- # (#search) and a pure parser (.parse) so tests can exercise the
13
- # parser against fixture JSON without hitting the network. The
14
- # cascade in {Engines#search} owns the final Markdown rendering.
10
+ # Performs a Brave Search via the official Web Search API, returning hits
11
+ # as {Result} rows. Split into a thin HTTP fetch (#search) and a pure
12
+ # parser (.parse) so tests exercise the parser against fixture JSON.
13
+ # {Engines#search} owns the final Markdown rendering.
15
14
  #
16
- # A class constructed with the API key it should use
17
- # (+Brave.new(api_key:)+); {Engines} builds one only when a Brave key
18
- # was configured and then drives it through the same +#search+ /
19
- # +#label+ interface as every other provider. pikuri reads no key
20
- # from the environment (see CLAUDE.md "Environment is not a secret
21
- # store"). Get a key at https://api-dashboard.search.brave.com — the
22
- # free "Data for Search" tier allows 1 query/sec and ~2k queries/month.
15
+ # Constructed with its API key (+Brave.new(api_key:)+); {Engines} builds
16
+ # one only when a Brave key was configured. pikuri reads no key from the
17
+ # environment (CLAUDE.md "Environment is not a secret store"). Key at
18
+ # https://api-dashboard.search.brave.com — the free "Data for Search" tier
19
+ # allows 1 query/sec, ~2k/month.
23
20
  #
24
21
  # == Privacy posture
25
22
  #
26
- # Brave's API Privacy Notice retains Search Query Logs for 90 days
27
- # (billing / troubleshooting) and states +Brave does not collect any
28
- # identifiers that can link a search query to an individual or their
29
- # devices+. Brave publicly commits that the Search API does not use
30
- # query data to train its own models, and offers Zero Data Retention
31
- # — but only on the Enterprise plan, not on the free "Data for
32
- # Search" tier pikuri defaults to.
33
- #
34
- # Bottom line: of pikuri's three providers Brave has the cleanest
35
- # API-level posture — no training-on-queries, no IP linkage, capped
36
- # 90-day retention by default, real ZDR if you pay for it. Still a
37
- # logged 90-day window on the cheap tier, so not a substitute for
38
- # ZDR for genuinely sensitive queries.
23
+ # Brave's API notice retains Search Query Logs 90 days and collects no
24
+ # identifiers linking a query to a person; it publicly commits not to train
25
+ # on query data, and offers Zero Data Retention — but only on Enterprise,
26
+ # not the free tier pikuri defaults to. Bottom line: the cleanest
27
+ # API-level posture of pikuri's three providers (no training, no IP
28
+ # linkage, capped 90-day retention), but a logged 90-day window on the
29
+ # cheap tier, so not a ZDR substitute for genuinely sensitive queries.
39
30
  class Brave
40
31
  # @return [String] Web Search endpoint
41
32
  ENDPOINT = 'https://api.search.brave.com/res/v1/web/search'
42
- # @return [Integer] default number of results returned, matching
43
- # {DuckDuckGo::DEFAULT_MAX_RESULTS}
33
+ # @return [Integer] default number of results returned.
44
34
  DEFAULT_MAX_RESULTS = 10
45
- # @return [RateLimiter] free-tier Brave caps at 1 req/sec; the
46
- # 5-minute cooldown protects the limited monthly quota from
47
- # being burned on doomed retries when a 429 hits.
35
+ # @return [RateLimiter] free-tier Brave caps at 1 req/sec; the 5-minute
36
+ # cooldown protects the monthly quota from doomed retries on a 429.
48
37
  LIMITER = RateLimiter.new(min_interval: 1.0, cooldown: 300.0)
49
38
 
50
39
  # @param api_key [String] Brave Search subscription token. Required
@@ -58,33 +47,28 @@ module Pikuri
58
47
  @api_key = api_key
59
48
  end
60
49
 
61
- # @return [String] short provider label for {Engines} logging /
62
- # fallback messages.
50
+ # @return [String] short provider label for {Engines}.
63
51
  def label
64
52
  'Brave'
65
53
  end
66
54
 
67
- # Fetch results for +query+ and return them as an +Array<Result>+.
68
- # Calls are throttled to one per second and circuit-broken for 5
69
- # minutes on rate-limit / quota-exhausted responses; see {LIMITER}.
70
- # The caller (typically {Engines#search}) is expected to have
71
- # already normalized the query and to wrap this in a result cache.
55
+ # Fetch results for +query+ as an +Array<Result>+. Throttled to 1/sec and
56
+ # circuit-broken 5 min on rate-limit/quota (see {LIMITER}). The caller
57
+ # ({Engines#search}) normalizes the query and wraps this in a cache.
72
58
  #
73
59
  # @param query [String] search query (already normalized)
74
- # @param max_results [Integer] maximum number of result entries;
75
- # passed through as Brave's +count+ (1..20)
76
- # @return [Array<Result>] hits, possibly empty when Brave ran the
77
- # query and matched nothing
78
- # @raise [Engines::Unavailable] when Brave returns HTTP 429
79
- # (rate limit / quota exhausted) or 5xx — "try again later"
80
- # responses the cascade in {Engines#search} can fall back
81
- # from. Also raised immediately if {LIMITER} is in cooldown.
82
- # Other non-2xx (e.g. 401/403 from a bad API key) bubble up as
83
- # +RuntimeError+ so config problems stay visible.
84
- # @raise [RuntimeError] for non-rate-limit HTTP failures or when the
85
- # response shape contains no results.
86
- def search(query, max_results: DEFAULT_MAX_RESULTS)
87
- LIMITER.call do
60
+ # @param max_results [Integer] max entries; Brave's +count+ (1..20)
61
+ # @param cancellable [Pikuri::Agent::Control::Cancellable] makes
62
+ # {LIMITER}'s pacing wait interruptible; see {RateLimiter#call}.
63
+ # @return [Array<Result>] hits, possibly empty when Brave matched nothing
64
+ # @raise [Engines::Unavailable] on HTTP 429 or 5xx (the cascade falls
65
+ # back), or if {LIMITER} is in cooldown. Other non-2xx (401/403 bad
66
+ # key) bubble up as +RuntimeError+.
67
+ # @raise [RuntimeError] for non-rate-limit HTTP failures, or a
68
+ # zero-result response that isn't a recognized empty-results payload.
69
+ def search(query, max_results: DEFAULT_MAX_RESULTS,
70
+ cancellable: Pikuri::Agent::Control::Cancellable::NEVER)
71
+ LIMITER.call(cancellable: cancellable) do
88
72
  response = Faraday.get(
89
73
  ENDPOINT,
90
74
  { q: query, count: max_results },
@@ -102,24 +86,16 @@ module Pikuri
102
86
  end
103
87
  end
104
88
 
105
- # Parse a Brave Web Search JSON response into a list of {Result} rows.
106
- # HTML highlight tags (+<strong>+) inside +title+ and +description+
107
- # are stripped via Nokogiri so the output is plain text.
108
- #
109
- # When the response yields zero result nodes, two cases are
110
- # distinguished: a genuine "no results" payload (recognized search
111
- # shape with empty +mixed.main+/+top+/+side+ — typically a too-narrow
112
- # query Brave couldn't match) returns an empty array instead of
113
- # raising, so {Engines.search} can render its standard no-results
114
- # stub. Anything else (unknown layout, structured error) raises
115
- # with a diagnostic so the failure surfaces.
89
+ # Parse a Brave JSON response into {Result} rows (+<strong>+ highlights
90
+ # stripped). A genuine "no results" payload (recognized search shape,
91
+ # empty +mixed.main/top/side+) returns +[]+ so {Engines.search} renders
92
+ # its stub; any other zero-result shape raises.
116
93
  #
117
94
  # @param json [String] response body from {ENDPOINT}
118
- # @param max_results [Integer] maximum number of result entries
95
+ # @param max_results [Integer] max entries
119
96
  # @return [Array<Result>] hits, possibly empty on a recognized
120
97
  # empty-results payload
121
- # @raise [RuntimeError] when the response yields no result entries and
122
- # is not recognized as a genuine empty-results payload
98
+ # @raise [RuntimeError] on an unrecognized zero-result response
123
99
  def self.parse(json, max_results: DEFAULT_MAX_RESULTS)
124
100
  data = JSON.parse(json)
125
101
  results = Array(data.dig('web', 'results')).take(max_results).filter_map do |r|
@@ -142,11 +118,11 @@ module Pikuri
142
118
  results
143
119
  end
144
120
 
145
- # Strip HTML markup (notably +<strong>+ highlights Brave wraps around
146
- # query terms in titles and descriptions) and collapse whitespace.
121
+ # Strip HTML markup (notably the +<strong>+ highlights Brave wraps around
122
+ # query terms) and collapse whitespace.
147
123
  #
148
- # @param html [String, nil] raw text from a Brave result field
149
- # @return [String] plain text with tags removed; empty string for nil
124
+ # @param html [String, nil]
125
+ # @return [String] plain text; empty string for nil
150
126
  def self.strip_html(html)
151
127
  return '' if html.nil?
152
128
 
@@ -154,13 +130,10 @@ module Pikuri
154
130
  end
155
131
  private_class_method :strip_html
156
132
 
157
- # True when a parsed response with zero +web.results+ entries looks
158
- # like Brave's own "search ran, nothing matched" payload (typically a
159
- # too-narrow query) rather than a malformed or error response. The
160
- # markers are the recognized +type: "search"+ envelope and an empty
161
- # +mixed+ block — Brave populates +mixed.main/top/side+ with whichever
162
- # verticals matched, so all three being empty arrays is the canonical
163
- # signal that the search itself succeeded but found nothing.
133
+ # True when a zero-+web.results+ response is Brave's own "ran, matched
134
+ # nothing" payload, not an error. Markers: the +type: "search"+ envelope
135
+ # and an empty +mixed+ block (Brave fills +mixed.main/top/side+ with
136
+ # matched verticals, so all three empty = succeeded-but-found-nothing).
164
137
  #
165
138
  # @param data [Hash, Object] parsed response
166
139
  # @return [Boolean]
@@ -174,13 +147,12 @@ module Pikuri
174
147
  end
175
148
  private_class_method :genuine_no_results?
176
149
 
177
- # Build an error message for a parsed response that yielded zero
178
- # results. Quotes Brave's +error.detail+ if present, otherwise
179
- # truncates the raw body so the caller can see the actual payload.
150
+ # Build an error message for a zero-result response: quote Brave's
151
+ # +error.detail+ if present, else a truncated raw body.
180
152
  #
181
153
  # @param data [Hash, Object] parsed response
182
154
  # @param raw [String] raw response body
183
- # @return [String] human-readable diagnostic to feed to +raise+
155
+ # @return [String] diagnostic to feed to +raise+
184
156
  def self.diagnose_empty(data, raw)
185
157
  if data.is_a?(Hash) && data['error'].is_a?(Hash)
186
158
  err = data['error']
@@ -7,73 +7,56 @@ require 'uri'
7
7
  module Pikuri
8
8
  class Tool
9
9
  module Search
10
- # Performs a DuckDuckGo search by scraping +html.duckduckgo.com+ and
11
- # returns the hits as a list of {Result} rows. Split into a thin HTTP
12
- # fetch (#search) and a pure parser (.parse) so tests can exercise
13
- # the parser against fixture HTML without hitting the network. The
14
- # cascade in {Engines#search} owns the final Markdown rendering.
15
- #
16
- # A class (constructed with no arguments) so it shares the uniform
17
- # provider shape with the keyed {Brave} / {Exa}: {Engines} holds a
18
- # list of provider *instances* and calls +#search+ / +#label+ on each
19
- # without caring which is which.
10
+ # Performs a DuckDuckGo search by scraping +html.duckduckgo.com+, returning
11
+ # hits as {Result} rows. Split into a thin HTTP fetch (#search) and a pure
12
+ # parser (.parse) so tests exercise the parser against fixture HTML.
13
+ # {Engines#search} owns the final Markdown rendering. Constructed with no
14
+ # arguments, sharing the uniform +#search+/+#label+ provider shape with the
15
+ # keyed {Brave} / {Exa}.
20
16
  #
21
17
  # == Privacy posture
22
18
  #
23
- # DuckDuckGo's privacy policy states +We don't save your IP address
24
- # or any unique identifiers alongside your searches+ and +We have
25
- # never sold any personal information+, and they proxy requests on
26
- # the user's behalf so downstream content providers can't build a
27
- # per-user search history. That part is real — but DDG is mainly a
28
- # relay over Bing for web results, so the *query content* still
29
- # reaches Microsoft for fulfillment even though DDG strips
30
- # identifying info on the way out.
31
- #
32
- # Bottom line: DDG is a genuine privacy improvement over hitting
33
- # Bing directly (your IP isn't tied to the query, no per-user
34
- # profile is built on DDG's side), but query content still lands at
35
- # Microsoft, who has no comparable no-training pledge. Better than
36
- # Exa for sensitive queries, worse than Brave; for anything
37
- # genuinely embarrassing, don't search the web at all.
19
+ # DDG doesn't save your IP or tie a per-user profile to queries, and
20
+ # proxies requests so downstream providers can't build a search history —
21
+ # real, but DDG relays web results over Bing, so *query content* still
22
+ # reaches Microsoft (who has no no-training pledge). Better than Exa for
23
+ # sensitive queries, worse than Brave; for anything genuinely embarrassing,
24
+ # don't search the web at all.
38
25
  class DuckDuckGo
39
26
  # @return [String] HTML search endpoint
40
27
  ENDPOINT = 'https://html.duckduckgo.com/html/'
41
28
  # @return [String] User-Agent sent with each request; DDG often rejects
42
- # requests with no UA or an obvious bot UA
29
+ # a missing or obviously-bot UA.
43
30
  USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
44
31
  '(KHTML, like Gecko) Chrome/120.0 Safari/537.36'
45
- # @return [Integer] default number of results returned, matching smolagents
32
+ # @return [Integer] default number of results returned.
46
33
  DEFAULT_MAX_RESULTS = 10
47
34
  # @return [RateLimiter] paces calls (DDG bans IPs that hammer the HTML
48
- # endpoint) and circuit-breaks on {Engines::Unavailable} so a
49
- # soft-block response doesn't get retried for the next 5 minutes
35
+ # endpoint) and circuit-breaks 5 min on {Engines::Unavailable}.
50
36
  LIMITER = RateLimiter.new(min_interval: 2.0, cooldown: 300.0)
51
37
 
52
- # @return [String] short provider label for {Engines} logging /
53
- # fallback messages. Uniform across providers (see {Brave#label}).
38
+ # @return [String] short provider label for {Engines}.
54
39
  def label
55
40
  'DuckDuckGo'
56
41
  end
57
42
 
58
- # Fetch results for +query+ and return them as an +Array<Result>+.
59
- # Calls are throttled to one every 2s and circuit-broken for 5 minutes
60
- # after a soft-block; see {LIMITER}. The caller (typically
61
- # {Engines#search}) is expected to have already normalized the
62
- # query and to wrap this in a result cache.
43
+ # Fetch results for +query+ as an +Array<Result>+. Throttled to one every
44
+ # 2s and circuit-broken 5 min after a soft-block (see {LIMITER}). The
45
+ # caller ({Engines#search}) normalizes the query and wraps this in a cache.
63
46
  #
64
47
  # @param query [String] search query (already normalized)
65
- # @param max_results [Integer] maximum number of result entries
66
- # @return [Array<Result>] hits, possibly empty when DDG ran the
67
- # query and matched nothing
68
- # @raise [Engines::Unavailable] when DDG soft-blocks the IP
69
- # (anomaly/CAPTCHA page) or returns HTTP 429/5xx — i.e. "try again
70
- # later" responses the cascade in {Engines#search} can fall
71
- # back from. Also raised immediately if {LIMITER} is in cooldown.
72
- # @raise [RuntimeError] if the HTTP call fails for other reasons or
73
- # the empty-results page is in an unrecognized layout. A genuine
74
- # empty-results page is *not* an error; see {.parse}.
75
- def search(query, max_results: DEFAULT_MAX_RESULTS)
76
- LIMITER.call do
48
+ # @param max_results [Integer] max result entries
49
+ # @param cancellable [Pikuri::Agent::Control::Cancellable] makes
50
+ # {LIMITER}'s pacing wait interruptible; see {RateLimiter#call}.
51
+ # @return [Array<Result>] hits, possibly empty when DDG matched nothing
52
+ # @raise [Engines::Unavailable] on a soft-block (anomaly/CAPTCHA page) or
53
+ # HTTP 429/5xx (the cascade falls back), or if {LIMITER} is in cooldown.
54
+ # @raise [RuntimeError] on other HTTP failures or an unrecognized
55
+ # empty-results layout. A genuine empty-results page is not an error;
56
+ # see {.parse}.
57
+ def search(query, max_results: DEFAULT_MAX_RESULTS,
58
+ cancellable: Pikuri::Agent::Control::Cancellable::NEVER)
59
+ LIMITER.call(cancellable: cancellable) do
77
60
  response = Faraday.get(ENDPOINT, { q: query }, { 'User-Agent' => USER_AGENT })
78
61
  unless response.success?
79
62
  if response.status == 429 || response.status >= 500
@@ -87,28 +70,18 @@ module Pikuri
87
70
  end
88
71
  end
89
72
 
90
- # Parse a +html.duckduckgo.com+ result page into a list of {Result}
91
- # rows. +<b>+ highlights inside snippets are stripped.
92
- #
93
- # When the page has zero result nodes, two cases are distinguished:
94
- # a genuine "no results" page (narrow query, DDG's own "No results
95
- # found" indicator) returns an empty array instead of raising, so
96
- # {Engines.search} can render its standard no-results stub.
97
- # Anything else (anomaly modal, CAPTCHA, service-unavailable page,
98
- # unknown layout) raises with the diagnostic text extracted from
99
- # the body, so an IP soft-block is surfaced rather than silently
73
+ # Parse a +html.duckduckgo.com+ result page into {Result} rows (+<b>+
74
+ # highlights stripped). On zero result nodes: a genuine "no results" page
75
+ # returns +[]+ (so {Engines.search} renders its stub); the anomaly/CAPTCHA
76
+ # modal raises {Engines::Unavailable}; anything else raises {RuntimeError}
77
+ # (likely a layout change) — so an IP soft-block surfaces rather than
100
78
  # masquerading as an empty search.
101
79
  #
102
- # @param html [String] HTML document body from html.duckduckgo.com
103
- # @param max_results [Integer] maximum number of result entries
104
- # @return [Array<Result>] hits, possibly empty on a genuine
105
- # no-results page
106
- # @raise [Engines::Unavailable] when the page is the DDG
107
- # anomaly/CAPTCHA modal (IP soft-block) — a "try again later" the
108
- # cascade can fall back from.
109
- # @raise [RuntimeError] when the page contains no result nodes and is
110
- # not recognized as either a genuine no-results page or the
111
- # anomaly modal (likely a layout change worth surfacing loudly).
80
+ # @param html [String] HTML from html.duckduckgo.com
81
+ # @param max_results [Integer] max result entries
82
+ # @return [Array<Result>] hits, possibly empty on a genuine no-results page
83
+ # @raise [Engines::Unavailable] on the anomaly/CAPTCHA modal (IP soft-block)
84
+ # @raise [RuntimeError] on zero results with an unrecognized layout
112
85
  def self.parse(html, max_results: DEFAULT_MAX_RESULTS)
113
86
  doc = Nokogiri::HTML(html)
114
87
  results = doc.css('div.result.web-result').take(max_results).filter_map do |node|
@@ -133,25 +106,22 @@ module Pikuri
133
106
  results
134
107
  end
135
108
 
136
- # True when the page contains DDG's anomaly/CAPTCHA modal — i.e. the
137
- # IP has been soft-blocked. Used by {.parse} to pick between
138
- # {Engines::Unavailable} (recoverable, fall back to another
139
- # provider) and {RuntimeError} (unknown layout, surface loudly).
109
+ # True when the page is DDG's anomaly/CAPTCHA modal (IP soft-blocked).
110
+ # {.parse} uses it to pick {Engines::Unavailable} (recoverable) over
111
+ # {RuntimeError} (unknown layout).
140
112
  #
141
- # @param doc [Nokogiri::HTML::Document] parsed result page
113
+ # @param doc [Nokogiri::HTML::Document]
142
114
  # @return [Boolean]
143
115
  def self.anomaly_modal?(doc)
144
116
  !!(doc.at_css('.anomaly-modal__title') || doc.at_css('.anomaly-modal__description'))
145
117
  end
146
118
  private_class_method :anomaly_modal?
147
119
 
148
- # True when a results page with zero result nodes looks like DDG's own
149
- # "no results found" page (narrow query) rather than an anomaly/CAPTCHA
150
- # or other non-results layout. Anomaly modal wins: if the modal divs
151
- # are present we never treat the page as a genuine empty result, even
152
- # if the surrounding text happens to mention "No results".
120
+ # True when a zero-node page is DDG's own "no results found" page, not an
121
+ # anomaly/CAPTCHA. Anomaly wins: if the modal divs are present it's never
122
+ # a genuine empty result, even if the text mentions "No results".
153
123
  #
154
- # @param doc [Nokogiri::HTML::Document] parsed result page
124
+ # @param doc [Nokogiri::HTML::Document]
155
125
  # @return [Boolean]
156
126
  def self.genuine_no_results?(doc)
157
127
  return false if doc.at_css('.anomaly-modal__title') || doc.at_css('.anomaly-modal__description')
@@ -161,14 +131,12 @@ module Pikuri
161
131
  end
162
132
  private_class_method :genuine_no_results?
163
133
 
164
- # Build an error message for a results page that yielded zero matches.
165
- # Recognizes the DDG anomaly modal explicitly and quotes its title and
166
- # description; otherwise extracts visible text from the body (with
167
- # +<script>+/+<style>+/+<noscript>+ stripped and whitespace collapsed)
168
- # and includes a truncated copy so the caller can see what came back.
134
+ # Build an error message for a zero-match page: quote the DDG anomaly
135
+ # modal's title/description if present, else a truncated copy of the
136
+ # body's visible text (+<script>/<style>/<noscript>+ stripped).
169
137
  #
170
- # @param doc [Nokogiri::HTML::Document] parsed result page
171
- # @return [String] human-readable diagnostic to feed to +raise+
138
+ # @param doc [Nokogiri::HTML::Document]
139
+ # @return [String] diagnostic to feed to +raise+
172
140
  def self.diagnose_empty(doc)
173
141
  title = doc.at_css('.anomaly-modal__title')&.text&.strip
174
142
  desc = doc.at_css('.anomaly-modal__description')&.text&.strip
@@ -184,12 +152,12 @@ module Pikuri
184
152
  end
185
153
  private_class_method :diagnose_empty
186
154
 
187
- # Decode DuckDuckGo's +//duckduckgo.com/l/?uddg=<encoded>+ redirect wrapper
188
- # back to the real target URL.
155
+ # Decode DDG's +//duckduckgo.com/l/?uddg=<encoded>+ redirect wrapper to
156
+ # the real target URL.
189
157
  #
190
- # @param href [String, nil] href as found on the search-result page
191
- # @return [String] the decoded target URL, or +href+ unchanged when it is
192
- # not a recognized DDG redirect or cannot be parsed
158
+ # @param href [String, nil] href from the result page
159
+ # @return [String] the decoded target, or +href+ unchanged when not a
160
+ # recognized DDG redirect or unparseable
193
161
  def self.extract_url(href)
194
162
  return href if href.nil? || href.empty?
195
163