pikuri-core 0.0.3

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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +67 -0
  3. data/lib/pikuri/agent/chat_transport.rb +41 -0
  4. data/lib/pikuri/agent/configurator.rb +270 -0
  5. data/lib/pikuri/agent/context_window_detector.rb +111 -0
  6. data/lib/pikuri/agent/control/cancellable.rb +128 -0
  7. data/lib/pikuri/agent/control/interloper.rb +167 -0
  8. data/lib/pikuri/agent/control/step_limit.rb +93 -0
  9. data/lib/pikuri/agent/control.rb +45 -0
  10. data/lib/pikuri/agent/event.rb +190 -0
  11. data/lib/pikuri/agent/extension.rb +82 -0
  12. data/lib/pikuri/agent/listener/in_memory_event_list.rb +34 -0
  13. data/lib/pikuri/agent/listener/rate_limited.rb +172 -0
  14. data/lib/pikuri/agent/listener/terminal.rb +264 -0
  15. data/lib/pikuri/agent/listener/token_log.rb +216 -0
  16. data/lib/pikuri/agent/listener.rb +54 -0
  17. data/lib/pikuri/agent/listener_list.rb +102 -0
  18. data/lib/pikuri/agent/synthesizer.rb +145 -0
  19. data/lib/pikuri/agent.rb +731 -0
  20. data/lib/pikuri/subprocess.rb +166 -0
  21. data/lib/pikuri/tool/calculator.rb +82 -0
  22. data/lib/pikuri/tool/fetch.rb +171 -0
  23. data/lib/pikuri/tool/parameters.rb +314 -0
  24. data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
  25. data/lib/pikuri/tool/scraper/html.rb +285 -0
  26. data/lib/pikuri/tool/scraper/pdf.rb +54 -0
  27. data/lib/pikuri/tool/scraper/simple.rb +183 -0
  28. data/lib/pikuri/tool/search/brave.rb +184 -0
  29. data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
  30. data/lib/pikuri/tool/search/engines.rb +163 -0
  31. data/lib/pikuri/tool/search/exa.rb +217 -0
  32. data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
  33. data/lib/pikuri/tool/search/result.rb +29 -0
  34. data/lib/pikuri/tool/sub_agent.rb +150 -0
  35. data/lib/pikuri/tool/web_scrape.rb +121 -0
  36. data/lib/pikuri/tool/web_search.rb +38 -0
  37. data/lib/pikuri/tool.rb +118 -0
  38. data/lib/pikuri/url_cache.rb +112 -0
  39. data/lib/pikuri/version.rb +10 -0
  40. data/lib/pikuri-core.rb +177 -0
  41. data/prompts/pikuri-chat.txt +15 -0
  42. metadata +251 -0
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'set'
5
+
6
+ module Pikuri
7
+ # Chokepoint for *all* subprocess spawning in pikuri. Forces a new
8
+ # process group for each invocation, tracks pgids so descendants of
9
+ # the direct child (commands backgrounded with +&+) can be cleaned
10
+ # up at process exit, and captures combined stdout+stderr through a
11
+ # single pipe.
12
+ #
13
+ # == Seam discipline
14
+ #
15
+ # All subprocess spawning in +lib/+ goes through {.spawn}. Direct
16
+ # +Process.spawn+ / +Open3.*+ / +system+ / backticks anywhere in
17
+ # +lib/+ are bugs. The convention is grep-enforceable:
18
+ # +grep -rn 'Process\.spawn\|Open3\|system\|backtick' lib/+ should
19
+ # only hit this file.
20
+ #
21
+ # == Timeouts are the caller's job
22
+ #
23
+ # {.spawn} does not implement a timeout — Ruby's +Timeout.timeout+
24
+ # cannot kill subprocesses cleanly. Callers that need a timeout
25
+ # wrap their argv with coreutils' +timeout+ binary:
26
+ #
27
+ # Pikuri::Subprocess.spawn(
28
+ # 'timeout', '--signal=TERM', '--kill-after=5s', '120s',
29
+ # 'bash', '-c', command,
30
+ # chdir: workspace.cwd.to_s
31
+ # )
32
+ #
33
+ # When +timeout+ and its FD-inheriting children die, the combined
34
+ # output pipe closes and {#wait}'s +io.read+ returns. No Ruby-side
35
+ # timeout machinery; the +timeout+ binary handles SIGTERM-then-
36
+ # SIGKILL race-free.
37
+ #
38
+ # == Backgrounded subprocesses
39
+ #
40
+ # When a shell command backgrounds work with +&+, the resulting
41
+ # process stays in our pgroup. {#wait} returns as soon as the
42
+ # direct child exits, but {.active} keeps the pgid in the tracked
43
+ # set as long as any process in the group is alive (probed with
44
+ # +kill(0, -pgid)+). On pikuri exit, {.cleanup!} sends SIGTERM to
45
+ # every tracked group. The model can opt out via +nohup cmd &+ or
46
+ # +setsid cmd &+ — both detach from our group.
47
+ #
48
+ # == State is process-global
49
+ #
50
+ # One +@active+ Set and one +at_exit+ for the whole process. A
51
+ # +Mutex+ guards register/prune/cleanup; v1 is single-threaded, so
52
+ # this is more for the +at_exit+/register race than for current
53
+ # callers.
54
+ #
55
+ # == Why +Pikuri::Subprocess+, not top-level
56
+ #
57
+ # First class actually under the +Pikuri::+ namespace. Domain
58
+ # classes (+Tool+, +Agent+, +URLCache+) are top-level as a legacy
59
+ # convention — they predate the namespacing decision and an
60
+ # eventual refactor moves them too. For now: library-level
61
+ # infrastructure under +Pikuri::+; domain objects flat. See
62
+ # +CLAUDE.md+ for the convention.
63
+ class Subprocess
64
+ # Combined output + exit status, returned from {#wait}.
65
+ Result = Data.define(:output, :status)
66
+
67
+ # Spawn +argv+ in a new process group, redirecting stderr onto
68
+ # stdout. Tracked for cleanup.
69
+ #
70
+ # @param argv [Array<String>] command and arguments. Caller does
71
+ # any shell wrapping (e.g. +'bash', '-c', cmd+) when shell
72
+ # interpretation is wanted; +argv+ is passed to +exec+
73
+ # directly, so no implicit shell expansion happens here.
74
+ # @param chdir [String, Pathname] working directory
75
+ # @return [Subprocess] handle — call {#wait} to block for the
76
+ # direct child to exit and read the captured output
77
+ def self.spawn(*argv, chdir:)
78
+ stdin, io, wait_thr = Open3.popen2e(*argv, chdir: chdir.to_s, pgroup: true)
79
+ stdin.close
80
+ register(wait_thr.pid)
81
+ new(io: io, wait_thr: wait_thr)
82
+ end
83
+
84
+ # @return [Integer] direct child's pid
85
+ attr_reader :pid
86
+
87
+ # @return [Integer] process group id. Equal to {#pid} since the
88
+ # child was spawned with +pgroup: true+ (it's the group leader).
89
+ attr_reader :pgid
90
+
91
+ # @return [IO] read end of the combined stdout+stderr pipe.
92
+ # Exposed for future live-streaming consumers; v1 callers go
93
+ # straight to {#wait}, which drains it.
94
+ attr_reader :io
95
+
96
+ # @api private — call {.spawn}, not the constructor.
97
+ def initialize(io:, wait_thr:)
98
+ @io = io
99
+ @wait_thr = wait_thr
100
+ @pid = wait_thr.pid
101
+ @pgid = wait_thr.pid # pgroup:true → pgid == pid
102
+ end
103
+
104
+ # Block until the direct child exits, read whatever remains on
105
+ # the combined-output pipe, return a {Result}. The pgid stays
106
+ # tracked if the group still has live members (backgrounded
107
+ # children); pruned if everything's gone.
108
+ #
109
+ # @return [Result]
110
+ def wait
111
+ output = @io.read
112
+ @io.close
113
+ Result.new(output: output, status: @wait_thr.value)
114
+ ensure
115
+ self.class.send(:prune, @pgid)
116
+ end
117
+
118
+ class << self
119
+ # Currently-tracked process groups, with dead ones pruned as a
120
+ # side effect. Useful for a future +/bg+ REPL command or a
121
+ # between-turn status line.
122
+ #
123
+ # @return [Array<Integer>]
124
+ def active
125
+ @mutex.synchronize do
126
+ @active.delete_if { |g| !alive?(g) }
127
+ @active.to_a
128
+ end
129
+ end
130
+
131
+ # SIGTERM every tracked process group. Used by +at_exit+
132
+ # (production) and +after+ blocks (specs). Best-effort —
133
+ # ignores errors from already-dead groups.
134
+ #
135
+ # @return [void]
136
+ def cleanup!
137
+ @mutex.synchronize do
138
+ @active.each { |g| Process.kill('-TERM', g) rescue nil }
139
+ @active.clear
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def register(pgid)
146
+ @mutex.synchronize { @active << pgid }
147
+ end
148
+
149
+ def prune(pgid)
150
+ @mutex.synchronize { @active.delete(pgid) unless alive?(pgid) }
151
+ end
152
+
153
+ def alive?(pgid)
154
+ Process.kill(0, -pgid)
155
+ true
156
+ rescue Errno::ESRCH
157
+ false
158
+ end
159
+ end
160
+
161
+ @active = Set.new
162
+ @mutex = Mutex.new
163
+ end
164
+ end
165
+
166
+ at_exit { Pikuri::Subprocess.cleanup! }
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dentaku'
4
+
5
+ module Pikuri
6
+ class Tool
7
+ # Evaluates a basic arithmetic expression using Dentaku, with light
8
+ # preprocessing so the LLM can emit Python-flavored syntax (notably
9
+ # +**+ for exponentiation) instead of learning Dentaku's dialect.
10
+ #
11
+ # Scope is intentionally narrow: operators (+, -, *, /, **, %),
12
+ # parentheses, and decimal numbers. No variables, functions, or
13
+ # booleans — those would mean teaching the model a dialect, which we
14
+ # specifically want to avoid for this tool.
15
+ module Calculator
16
+ # Translate the operator differences between Python and Dentaku. In
17
+ # practice that is only +**+ → +^+; everything else in the supported
18
+ # subset is byte-identical.
19
+ #
20
+ # @param expression [String] raw expression as the model wrote it
21
+ # @return [String] expression with Python-style operators rewritten
22
+ def self.normalize(expression)
23
+ expression.gsub('**', '^')
24
+ end
25
+
26
+ # Evaluate +expression+ and return the result formatted as a String.
27
+ # Parse, unbound-variable, and division-by-zero failures are caught
28
+ # and returned as +"Error: ..."+ strings so the model can read the
29
+ # failure as the next observation and self-correct rather than
30
+ # crashing the agent loop.
31
+ #
32
+ # @param expression [String]
33
+ # @return [String] numeric result, or +"Error: ..."+ on failure
34
+ def self.calculate(expression)
35
+ result = Dentaku::Calculator.new.evaluate!(normalize(expression))
36
+ format_result(result)
37
+ rescue Dentaku::ZeroDivisionError, ZeroDivisionError
38
+ 'Error: division by zero'
39
+ rescue Dentaku::Error => e
40
+ "Error: #{e.message}"
41
+ end
42
+
43
+ # Dentaku returns BigDecimal for any expression that touches division
44
+ # or a decimal literal, with full BigDecimal precision (47-digit tails
45
+ # for the leopard expression). Round to 3 places and strip the
46
+ # default scientific-notation formatting so the model sees a short
47
+ # readable number; integer/other results pass through unchanged.
48
+ def self.format_result(result)
49
+ case result
50
+ when BigDecimal then result.round(3).to_s('F')
51
+ else result.to_s
52
+ end
53
+ end
54
+ private_class_method :format_result
55
+ end
56
+
57
+ # Arithmetic-evaluation tool backed by {Tool::Calculator.calculate}.
58
+ # Accepts Python-flavored operator syntax (+, -, *, /, ** for
59
+ # exponentiation, %, parentheses, decimals) so the model can emit the
60
+ # syntax it already knows.
61
+ #
62
+ # @return [Tool]
63
+ CALCULATOR = new(
64
+ name: 'calculator',
65
+ description: <<~DESC,
66
+ Evaluates a basic arithmetic expression and returns the numeric result.
67
+
68
+ Usage:
69
+ - Use this for any arithmetic beyond simple mental math — do not eyeball multi-digit work.
70
+ - Operators supported: +, -, *, /, ** (exponentiation), %, parentheses, decimal numbers.
71
+ - Decimal results are rounded to 3 places; integer results are exact.
72
+ - Failures (parse error, division by zero) come back as "Error: ..." — read the message and re-call with a corrected expression.
73
+ DESC
74
+ parameters: Parameters.build { |p|
75
+ p.required_string :expression,
76
+ 'Arithmetic expression to evaluate, e.g. ' \
77
+ '"155 / (58 * 1000.0 / 3600)" or "2**10".'
78
+ },
79
+ execute: ->(expression:) { Calculator.calculate(expression) }
80
+ )
81
+ end
82
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Tool
5
+ # Truncation policy and Tool spec for the +fetch+ tool. The HTTP work
6
+ # lives in {Tool::Scraper::Simple.fetch}; this module is a thin
7
+ # wrapper that accepts only textual content-types, applies a character
8
+ # cap so the LLM doesn't drown in long-form bodies, and exposes the
9
+ # result to the agent loop in OpenAI tool-call shape.
10
+ #
11
+ # Sister of {Tool::WebScrape}, but without HTML→Markdown or PDF→text
12
+ # extraction: bodies are returned verbatim. Useful for raw textual
13
+ # data — JSON APIs, CSV files, +robots.txt+, sitemaps, source files —
14
+ # where any rendering pass would corrupt the payload.
15
+ module Fetch
16
+ # @return [Integer] default character cap on the body returned by
17
+ # {.fetch}. Smaller than {Tool::WebScrape::DEFAULT_MAX_CHARS}
18
+ # because fetch's content profile is bimodal — most JSON/XML/CSV
19
+ # responses are tiny, and the long-tail (large data dumps) is
20
+ # better re-requested deliberately than padded into every default.
21
+ DEFAULT_MAX_CHARS = 5_000
22
+
23
+ # @return [Integer] hard ceiling on the +max_chars+ argument to
24
+ # {.fetch}. Matches {Tool::WebScrape::MAX_MAX_CHARS}.
25
+ MAX_MAX_CHARS = 100_000
26
+
27
+ # Application content-types that are textual in practice and so
28
+ # safe to return verbatim to the LLM, despite their +application/+
29
+ # prefix making them fail the +text/*+ check. Anything outside
30
+ # +text/*+ and this allowlist is refused.
31
+ # @return [Array<String>]
32
+ TEXTUAL_APPLICATION_TYPES = %w[
33
+ application/json
34
+ application/xml
35
+ application/javascript
36
+ application/xhtml+xml
37
+ application/rss+xml
38
+ application/atom+xml
39
+ ].freeze
40
+
41
+ # On-disk cache used by {.fetch} to memoize downloads. Defined as a
42
+ # method so specs can swap it for an isolated cache or
43
+ # {UrlCache::NULL} without touching the shared instance. Lives in
44
+ # its own subdir under {UrlCache::ROOT_DIR} so a +fetch+ on a URL
45
+ # and a +web_scrape+ on the same URL cannot collide on the same
46
+ # cache file (one returns the raw body, the other returns extracted
47
+ # Markdown).
48
+ #
49
+ # @return [UrlCache, #fetch]
50
+ CACHE = UrlCache.new(ttl: UrlCache::DEFAULT_TTL, dir: "#{UrlCache::ROOT_DIR}/fetch")
51
+ # Accessor for {CACHE}; specs override this to swap in
52
+ # {UrlCache::NULL} or an isolated cache.
53
+ #
54
+ # @return [UrlCache, #fetch]
55
+ def self.cache
56
+ CACHE
57
+ end
58
+
59
+ # Download +url+ via {Tool::Scraper::Simple.fetch} and return the
60
+ # response body verbatim, provided the content-type is one we deem
61
+ # textual (any +text/*+, plus the formats listed in
62
+ # {TEXTUAL_APPLICATION_TYPES}). Anything else — PDFs, images, other
63
+ # binaries — produces an +"Error: ..."+ string in the calculator-
64
+ # style convention so the agent loop feeds the failure back to the
65
+ # model as the next observation.
66
+ #
67
+ # The body is cached on disk via {.cache}, keyed by URL, so repeat
68
+ # fetches within the cache TTL skip the network. +max_chars+ is not
69
+ # part of the cache key — different values for the same URL share
70
+ # one entry, and truncation runs after the cache lookup. The cache
71
+ # is only populated on success: {Scraper::FetchError} (HTTP non-2xx,
72
+ # network failure, redirect-loop exhaustion, refused content-type)
73
+ # is caught outside the +cache.fetch+ block, so failure strings are
74
+ # never persisted and a retry on the next call hits the network
75
+ # again. Other exceptions (parser bugs in our own code) bubble up
76
+ # unchanged.
77
+ #
78
+ # @param url [String] absolute HTTP(S) URL to download
79
+ # @param max_chars [Integer] character cap on the returned body.
80
+ # Clamped to +[1, {MAX_MAX_CHARS}]+; defaults to
81
+ # {DEFAULT_MAX_CHARS}. When the body exceeds the cap, output is
82
+ # cut and a marker noting the original length is appended.
83
+ # @return [String] response body, possibly truncated, or
84
+ # +"Error: ..."+ on a recoverable failure
85
+ def self.fetch(url, max_chars: DEFAULT_MAX_CHARS)
86
+ max_chars = max_chars.clamp(1, MAX_MAX_CHARS)
87
+ body = cache.fetch(url) { download(url) }
88
+ truncate(body, max_chars)
89
+ rescue Scraper::FetchError => e
90
+ "Error: #{e.message}"
91
+ end
92
+
93
+ # GET +url+ and verify the response's content-type is textual.
94
+ # Caller is responsible for caching and truncation; this method
95
+ # always hits the network.
96
+ #
97
+ # @param url [String]
98
+ # @return [String] response body
99
+ # @raise [Scraper::FetchError] on HTTP non-2xx, network failure,
100
+ # redirect-loop exhaustion, missing +Location+ on a 3xx, or a
101
+ # non-textual content-type
102
+ def self.download(url)
103
+ fetched = Scraper::Simple.fetch(url)
104
+ return fetched.body if textual?(fetched.content_type)
105
+
106
+ raise Scraper::FetchError,
107
+ "refused to fetch #{url}: content-type #{fetched.content_type.inspect} " \
108
+ 'is not textual (use web_scrape for PDFs or rendered pages)'
109
+ end
110
+
111
+ # @param content_type [String] normalized content-type (no +charset+
112
+ # parameter, lowercased) as produced by {Scraper::Simple.fetch}
113
+ # @return [Boolean] true when the content-type is +text/*+ or one
114
+ # of {TEXTUAL_APPLICATION_TYPES}
115
+ def self.textual?(content_type)
116
+ content_type.start_with?('text/') ||
117
+ TEXTUAL_APPLICATION_TYPES.include?(content_type)
118
+ end
119
+
120
+ # Cut +body+ to at most +max_chars+ characters, appending a marker
121
+ # describing the original length when truncation actually happens.
122
+ # Returns +body+ unchanged if it already fits. Same shape as
123
+ # {Tool::WebScrape.truncate} so the LLM sees a consistent
124
+ # truncation marker across both tools.
125
+ #
126
+ # @param body [String] full response body
127
+ # @param max_chars [Integer] character cap; assumed already clamped
128
+ # @return [String]
129
+ def self.truncate(body, max_chars)
130
+ return body if body.length <= max_chars
131
+
132
+ "#{body[0, max_chars]}\n\n" \
133
+ "... [truncated at #{max_chars} of #{body.length} chars; " \
134
+ 'call again with a larger `max_chars` to see more]'
135
+ end
136
+ end
137
+
138
+ # Verbatim URL download tool. Thin wrapper over {Tool::Fetch.fetch}
139
+ # that exposes it to the agent loop in OpenAI tool-call shape. Use for
140
+ # raw textual payloads (JSON APIs, CSV files, +robots.txt+, source
141
+ # files); use {Tool::WEB_SCRAPE} for rendered web pages or PDFs where
142
+ # readability extraction makes the result usable.
143
+ #
144
+ # @return [Tool]
145
+ FETCH = new(
146
+ name: 'fetch',
147
+ description: <<~DESC,
148
+ Downloads the given URL and returns its body verbatim.
149
+
150
+ Usage:
151
+ - Use for raw textual payloads: JSON APIs, CSV files, robots.txt, sitemaps, source files — anywhere a rendering pass would corrupt the data.
152
+ - For rendered HTML pages or PDFs, use web_scrape — it extracts readable content; fetch returns the raw HTML/PDF bytes unchanged.
153
+ - Accepts text/* and common textual application/* types (JSON, XML, JS, XHTML, RSS, Atom). Refuses PDFs, images, and other binaries.
154
+ DESC
155
+ parameters: Parameters.build { |p|
156
+ p.required_string :url,
157
+ 'Absolute URL to download, including the scheme, ' \
158
+ 'e.g. "https://example.com/data.json".'
159
+ p.optional_integer :max_chars,
160
+ 'Maximum number of characters of the body to ' \
161
+ 'return. Defaults to 5000; hard-capped at ' \
162
+ '100000. When the body is longer than this, ' \
163
+ 'output is cut and a marker reports the full ' \
164
+ 'length.'
165
+ },
166
+ execute: ->(url:, max_chars: Fetch::DEFAULT_MAX_CHARS) {
167
+ Fetch.fetch(url, max_chars: max_chars)
168
+ }
169
+ )
170
+ end
171
+ end