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
data/lib/pikuri-core.rb CHANGED
@@ -3,55 +3,34 @@
3
3
  require 'logger'
4
4
  require 'zeitwerk'
5
5
 
6
- # +Pikuri::VERSION+ lives in its own tiny file so the gemspec can read
7
- # the version constant via +require_relative+ at build time without
8
- # loading Zeitwerk and every runtime dep. Requiring it here makes the
9
- # constant available to ordinary +require 'pikuri-core'+ callers too —
10
- # without this line, Zeitwerk's ignore-rule below would leave
11
- # +Pikuri::VERSION+ undefined for anyone using the installed gem.
6
+ # +Pikuri::VERSION+ lives in its own tiny file so the gemspec can +require_relative+
7
+ # it at build time without loading Zeitwerk and every runtime dep. Requiring it
8
+ # here also defines the constant for ordinary +require 'pikuri-core'+ callers —
9
+ # without this line the Zeitwerk ignore-rule below would leave it undefined.
12
10
  require_relative 'pikuri/version'
13
11
 
14
- # Boot file: configures the Zeitwerk autoloader for every file under
15
- # +pikuri-core/lib/pikuri/+ and eager-loads them all. After
16
- # +require 'pikuri-core'+, every constant pikuri-core ships
17
- # (+Pikuri::Agent+, +Pikuri::Tool+, +Pikuri::Tool::Calculator+, ...)
18
- # is defined and resolvable. Sibling gems (+pikuri-skills+,
19
- # +pikuri-mcp+, ...) set up their own Zeitwerk loaders rooted at
20
- # their own +lib/+ and contribute to the same +Pikuri::+ namespace.
12
+ # Boot file: configures the Zeitwerk autoloader for +pikuri-core/lib/pikuri/+
13
+ # and eager-loads it, so after +require 'pikuri-core'+ every constant
14
+ # pikuri-core ships is defined. Sibling gems set up their own loaders into the
15
+ # same +Pikuri::+ namespace.
21
16
  #
22
- # Beyond loading, the +Pikuri+ module owns the logging surface — see
23
- # {.logger_for}, {.log_io=}, and the +PIKURI_LOG+ /
24
- # +PIKURI_LOG_<NAME>+ env vars. Each subsystem holds its own
25
- # memoized +Logger+, all writing through a shared IO that
26
- # {.log_io=} can swap in one shot (handy in tests, daemons, or
27
- # anywhere stderr isn't the right sink).
17
+ # The +Pikuri+ module also owns the logging surface ({.logger_for}, {.log_io=},
18
+ # the +PIKURI_LOG+ / +PIKURI_LOG_<NAME>+ env vars): each subsystem holds a
19
+ # memoized +Logger+ writing through a shared IO that {.log_io=} swaps in one shot.
28
20
  #
29
21
  # == Why eager-load
30
22
  #
31
- # The stateless bundled tools (+Pikuri::Tool::CALCULATOR+,
32
- # +Pikuri::Tool::WEB_SCRAPE+, +Pikuri::Tool::FETCH+) are +ALL_CAPS+ value
33
- # constants rather than classes/modules, and Zeitwerk only auto-loads
34
- # constants that match its filename-↔-CamelCase convention. Eager-loading
35
- # at boot guarantees the files defining those values run, so the bin
36
- # script can drop them straight into the +Agent.new+ block via
37
- # +c.add_tool+ without per-file +require+ ceremony. (web_search is instead
38
- # host-configured — built per-wiring via {Pikuri::Tool::WebSearch.build}
39
- # with the host's provider keys — so it is not a value constant.) The cost
40
- # is a few milliseconds of startup — negligible compared to a single LLM
41
- # round-trip.
23
+ # The stateless bundled tools (+Tool::CALCULATOR+, +Tool::WEB_SCRAPE+,
24
+ # +Tool::FETCH+) are +ALL_CAPS+ value constants, and Zeitwerk only auto-loads
25
+ # constants matching its filename↔CamelCase convention. Eager-loading runs the
26
+ # files defining those values so a bin script can +c.add_tool+ them without
27
+ # per-file +require+. (web_search is host-configured via
28
+ # {Tool::WebSearch.build}, so it's not a value constant.) Cost: a few ms.
42
29
  module Pikuri
43
- # Search path for bundled system prompts. Mutable list: each pikuri
44
- # gem appends its own +prompts/+ directory when it boots, so a
45
- # +Pikuri.prompt(name)+ call from a host that requires +pikuri-code+
46
- # (for example) finds +coding-system-prompt.txt+ in
47
- # +pikuri-code/prompts/+. The core gem registers its own
48
- # +pikuri-core/prompts/+ below; sibling gems do the equivalent in
49
- # their own entry files.
50
- #
51
- # Exposed publicly so a downstream library user can read pikuri's
52
- # prompts as a starting point for their own system prompt (or
53
- # use them verbatim). Prefer {.prompt} for the common case of
54
- # loading one by name.
30
+ # Search path for bundled system prompts. Mutable: each pikuri gem appends its
31
+ # own +prompts/+ at boot, so +Pikuri.prompt(name)+ from a host requiring
32
+ # +pikuri-code+ finds +coding-system-prompt.txt+ there. Public so a downstream
33
+ # user can read pikuri's prompts as a starting point; prefer {.prompt}.
55
34
  #
56
35
  # @return [Array<String>]
57
36
  PROMPT_DIRS = [File.expand_path('../prompts', __dir__)]
@@ -76,19 +55,15 @@ module Pikuri
76
55
  # @return [IO] shared sink every {.logger_for} writes through
77
56
  attr_reader :log_io
78
57
 
79
- # Swap the shared sink (e.g. a +StringIO+ in tests, a file in a
80
- # daemon). Re-points every logger already handed out by
81
- # {.logger_for} so callers don't have to re-fetch them.
58
+ # Swap the shared sink (a +StringIO+ in tests, a file in a daemon),
59
+ # re-pointing every logger already handed out by {.logger_for}.
82
60
  #
83
- # Why we don't use +Logger#reopen+: that method closes the previous
84
- # sink before installing the new one, which makes "swap to a
85
- # capture +StringIO+, then swap back to the original" impossible —
86
- # the original handle would be dead the second time around. Instead
87
- # we swap each logger's +@logdev+ to a fresh +Logger::LogDevice+
88
- # over the new +io+, leaving the previous device untouched so the
89
- # caller can hand the same +IO+ back in later. This uses a documented
90
- # but internal Logger ivar; if a future Ruby renames it the
91
- # +Engines+ logging specs will fail loudly.
61
+ # Not +Logger#reopen+: it closes the previous sink before installing the new
62
+ # one, which makes "swap to a capture StringIO, then swap back" impossible —
63
+ # the original handle would be dead. Instead we swap each logger's +@logdev+
64
+ # to a fresh +Logger::LogDevice+ over +io+, leaving the previous device
65
+ # open. Uses an internal Logger ivar; if a future Ruby renames it the
66
+ # +Engines+ logging specs fail loudly.
92
67
  #
93
68
  # @param io [IO] new sink for every Pikuri logger
94
69
  # @return [IO]
@@ -100,16 +75,11 @@ module Pikuri
100
75
  io
101
76
  end
102
77
 
103
- # Memoized {Logger} tagged with +name+ in its +progname+, so each
104
- # subsystem's lines stand out in the shared sink. Level resolves
105
- # in this order: +PIKURI_LOG_<NAME>+ (e.g. +PIKURI_LOG_ENGINES=debug+),
106
- # then +PIKURI_LOG+, then +INFO+.
107
- #
108
- # Repeated calls with the same +name+ return the same instance so
109
- # there is one logger per subsystem and {.log_io=} can rewire them
110
- # all in one shot.
78
+ # Memoized {Logger} tagged with +name+ as its +progname+. Level resolves
79
+ # +PIKURI_LOG_<NAME>+ → +PIKURI_LOG+ → +INFO+. Repeated calls with the same
80
+ # +name+ return the same instance, so {.log_io=} can rewire them all.
111
81
  #
112
- # @param name [String] subsystem tag (rendered as +progname+)
82
+ # @param name [String] subsystem tag (the +progname+)
113
83
  # @return [Logger]
114
84
  def logger_for(name)
115
85
  @log_loggers[name] ||= begin
@@ -120,25 +90,16 @@ module Pikuri
120
90
  end
121
91
  end
122
92
 
123
- # Read a bundled prompt by basename. Searches every directory in
124
- # {PROMPT_DIRS} in order, returning the first match. +.txt+ is
125
- # auto-appended if absent. Symbols are accepted as a convenience
126
- # (+:pikuri-chat+ / +'pikuri-chat'+ / +'pikuri-chat.txt'+ all
127
- # resolve to the same file).
128
- #
129
- # Intended for downstream library users who want to bootstrap their
130
- # own +Pikuri::Agent+ wiring from pikuri's defaults — read the
131
- # prompt, customize the bits they care about, hand the result to
132
- # +Agent.new(system_prompt: ...)+.
93
+ # Read a bundled prompt by basename, searching {PROMPT_DIRS} in order
94
+ # (+.txt+ auto-appended; Symbols accepted). For downstream users
95
+ # bootstrapping their own +Agent+ wiring from pikuri's defaults.
133
96
  #
134
97
  # @example
135
- # chat_prompt = Pikuri.prompt(:'pikuri-chat')
136
- # agent = Pikuri::Agent.new(system_prompt: chat_prompt, ...)
98
+ # agent = Pikuri::Agent.new(system_prompt: Pikuri.prompt(:'pikuri-chat'), ...)
137
99
  #
138
100
  # @param name [String, Symbol] basename of the prompt file
139
101
  # @return [String] file contents
140
- # @raise [ArgumentError] if no matching file exists in any
141
- # directory in {PROMPT_DIRS}
102
+ # @raise [ArgumentError] if no matching file exists in any {PROMPT_DIRS}
142
103
  def prompt(name)
143
104
  basename = name.to_s
144
105
  basename += '.txt' unless basename.end_with?('.txt')
@@ -149,30 +110,53 @@ module Pikuri
149
110
  available = PROMPT_DIRS.flat_map { |dir| Dir.exist?(dir) ? Dir.children(dir) : [] }.sort.uniq
150
111
  raise ArgumentError, "Unknown pikuri prompt #{name.inspect}; available: #{available.join(', ')}"
151
112
  end
113
+
114
+ # Read several bundled prompts and join them into one system prompt —
115
+ # the way a binary assembles a base prompt plus shared fragments (e.g.
116
+ # the tool-loop hygiene in +agent-loop.txt+).
117
+ #
118
+ # system_prompt = Pikuri.prompts(:'pikuri-chat', :'agent-loop')
119
+ # system_prompt += "\n\nCurrent date: #{...}" # dynamic tail, still one blank line
120
+ #
121
+ # Each fragment is right-stripped before joining with a single blank
122
+ # line, and the result carries no trailing newline — so neither the
123
+ # fragment seams nor a later +"\n\n"+-prefixed append double-blanks,
124
+ # regardless of the +.txt+ files' own trailing newlines.
125
+ #
126
+ # @param names [Array<String, Symbol>] prompt basenames, in order
127
+ # @return [String] the fragments joined by a blank line
128
+ # @raise [ArgumentError] if any name has no matching file
129
+ def prompts(*names)
130
+ names.map { |name| prompt(name).rstrip }.join("\n\n")
131
+ end
152
132
  end
153
133
 
154
- # Zeitwerk loader managing every constant under
155
- # +pikuri-core/lib/pikuri/+. Exposed as a constant (rather than scoped
156
- # to the boot block) so a downstream host that wants to add ignore
157
- # rules can reach it without monkey-patching. Sibling gems
158
- # (+pikuri-skills+, +pikuri-mcp+, ...) set up their own loaders
159
- # rooted at their own +lib/+ dirs — see each gem's entry file.
134
+ # Zeitwerk loader for every constant under +pikuri-core/lib/pikuri/+. A
135
+ # constant (not block-scoped) so a downstream host can add ignore rules
136
+ # without monkey-patching. Sibling gems set up their own loaders.
160
137
  #
161
138
  # @return [Zeitwerk::Loader]
162
139
  Loader = Zeitwerk::Loader.new
163
140
  Loader.tag = 'pikuri-core'
164
141
  Loader.push_dir(File.expand_path('.', __dir__))
165
142
  Loader.ignore(__FILE__)
166
- # +pikuri/version.rb+ defines +Pikuri::VERSION+ (an ALL_CAPS value
167
- # constant), not +Pikuri::Version+ as Zeitwerk would expect from the
168
- # filename — so tell the loader to skip it. The file is +require_relative+'d
169
- # at the top of this file (and also by the gemspec at build time), so
170
- # the constant is defined before Zeitwerk runs.
143
+ # +pikuri/version.rb+ defines the ALL_CAPS +Pikuri::VERSION+, not
144
+ # +Pikuri::Version+ as Zeitwerk expects — so skip it (it's +require_relative+'d
145
+ # above and by the gemspec, so the constant is already defined).
171
146
  Loader.ignore(File.expand_path('pikuri/version.rb', __dir__))
147
+ # +pikuri/testing.rb+ is opt-in test scaffolding (+require 'pikuri/testing'+
148
+ # from a spec), kept off the production load path — so don't autoload or
149
+ # eager-load it. See {Pikuri::Testing}.
150
+ Loader.ignore(File.expand_path('pikuri/testing.rb', __dir__))
172
151
  Loader.inflector.inflect(
173
152
  'html' => 'HTML',
174
153
  'duckduckgo' => 'DuckDuckGo'
175
154
  )
176
155
  Loader.setup
177
156
  Loader.eager_load
157
+
158
+ # Reap +~/.cache/pikuri/temp-*+ scratch left by processes killed before their
159
+ # {Pikuri::Finalizers} reap could fire. Best-effort, once per process at the
160
+ # earliest load point (every gem requires pikuri-core). See {Paths.new_temp}.
161
+ Paths.sweep_stale_temps!
178
162
  end
@@ -0,0 +1,5 @@
1
+ Working with tools:
2
+ - If several next steps are independent (e.g. two unrelated lookups), emit them as parallel tool calls in one turn rather than one at a time.
3
+ - Don't repeat a tool call with identical arguments — re-read the previous observation instead.
4
+ - Treat a tool error (an observation starting with `Error:`) as feedback, not a dead-end: it often tells you how to proceed — retry with corrected or narrowed arguments. Only when the error genuinely leaves no way forward, use what you already have, or tell the user plainly what failed and why (a timeout, an unreachable resource, a rate limit, a missing tool). Don't retry the identical call.
5
+ - When you're done, reply in plain text with no tool call — that is how you end the turn.
@@ -1,15 +1,6 @@
1
1
  You are an expert assistant who solves tasks by calling tools when needed.
2
2
 
3
- You have access to tools described in the API's tool list. To call one, use the standard tool-call mechanism — do not write tool calls as text.
4
-
5
- If several next steps are independent (e.g. two unrelated lookups), emit them as parallel tool calls in a single turn rather than one at a time.
6
-
7
- Choosing a tool:
8
- - Default to `web_search` whenever a question asks for a specific fact about a named entity — company HQs and headcounts, product versions and release dates, prices, people's bios and roles, anything tying a proper noun to a specific attribute — and for anything time-sensitive or obscure. Your training will *feel* certain on these and is often wrong; check rather than guess. One well-chosen search beats three exploratory ones.
9
- - Use `calculator` for any arithmetic beyond simple mental math. Don't search the web for "what is X * Y".
3
+ How to work:
4
+ - When a question asks for a specific fact about a named entity — company HQs and headcounts, product versions and release dates, prices, people's bios and roles, anything tying a proper noun to a specific attribute — or for anything time-sensitive or obscure, search the web rather than answering from memory. Your training will *feel* certain on these and is often wrong; check rather than guess. One well-chosen search beats three exploratory ones.
5
+ - For any arithmetic beyond simple mental math, compute it with a tool rather than in your head — and don't search the web for "what is X * Y".
10
6
  - Reply directly, without any tool, for language and translation, definitions of common terms, and widely-known general history.
11
-
12
- Other guidelines:
13
- - Don't repeat a tool call with identical arguments — re-read the previous observation instead.
14
- - On a tool error (observation starting with `Error:`): use the data you already have to answer if you can. If you can't, reply to the user that you weren't able to find the answer and briefly say why (e.g. "web search hit a rate limit", "the page wasn't reachable"). Do not retry the same call hoping for a different result, and do not loop on rephrased variants of the same failing call.
15
- - When you have the answer, reply in plain text with no tool call. That is how you finish.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pikuri-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Vysny
@@ -85,14 +85,14 @@ dependencies:
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: '1.15'
88
+ version: '1.16'
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: '1.15'
95
+ version: '1.16'
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: zeitwerk
98
98
  requirement: !ruby/object:Gem::Requirement
@@ -136,6 +136,7 @@ files:
136
136
  - lib/pikuri/agent/event.rb
137
137
  - lib/pikuri/agent/extension.rb
138
138
  - lib/pikuri/agent/extension_context.rb
139
+ - lib/pikuri/agent/history.rb
139
140
  - lib/pikuri/agent/listener.rb
140
141
  - lib/pikuri/agent/listener/in_memory_event_list.rb
141
142
  - lib/pikuri/agent/listener/rate_limited.rb
@@ -143,16 +144,20 @@ files:
143
144
  - lib/pikuri/agent/listener/token_log.rb
144
145
  - lib/pikuri/agent/listener_list.rb
145
146
  - lib/pikuri/agent/synthesizer.rb
147
+ - lib/pikuri/bundler_env.rb
146
148
  - lib/pikuri/extractor.rb
147
149
  - lib/pikuri/extractor/html.rb
148
150
  - lib/pikuri/extractor/passthrough.rb
149
151
  - lib/pikuri/file_type.rb
150
152
  - lib/pikuri/finalizers.rb
151
153
  - lib/pikuri/paths.rb
154
+ - lib/pikuri/ruby_llm_patches.rb
152
155
  - lib/pikuri/sanitizer.rb
153
156
  - lib/pikuri/subprocess.rb
157
+ - lib/pikuri/testing.rb
154
158
  - lib/pikuri/tool.rb
155
159
  - lib/pikuri/tool/calculator.rb
160
+ - lib/pikuri/tool/execute_context.rb
156
161
  - lib/pikuri/tool/fetch.rb
157
162
  - lib/pikuri/tool/parameters.rb
158
163
  - lib/pikuri/tool/scraper.rb
@@ -162,10 +167,16 @@ files:
162
167
  - lib/pikuri/tool/search/exa.rb
163
168
  - lib/pikuri/tool/search/rate_limiter.rb
164
169
  - lib/pikuri/tool/search/result.rb
170
+ - lib/pikuri/tool/trifecta_legs.rb
165
171
  - lib/pikuri/tool/web_scrape.rb
166
172
  - lib/pikuri/tool/web_search.rb
173
+ - lib/pikuri/trifecta.rb
174
+ - lib/pikuri/trifecta/contribution.rb
175
+ - lib/pikuri/trifecta/node.rb
176
+ - lib/pikuri/trifecta/report.rb
167
177
  - lib/pikuri/url_cache.rb
168
178
  - lib/pikuri/version.rb
179
+ - prompts/agent-loop.txt
169
180
  - prompts/pikuri-chat.txt
170
181
  homepage: https://codeberg.org/mvysny/pikuri
171
182
  licenses: