pikuri 0.0.1 → 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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -179
  3. data/lib/pikuri.rb +12 -162
  4. metadata +45 -159
  5. data/CHANGELOG.md +0 -62
  6. data/GETTING_STARTED.md +0 -223
  7. data/LICENSE +0 -21
  8. data/lib/pikuri/agent/chat_transport.rb +0 -41
  9. data/lib/pikuri/agent/context_window_detector.rb +0 -101
  10. data/lib/pikuri/agent/listener/in_memory_message_list.rb +0 -33
  11. data/lib/pikuri/agent/listener/message_listener.rb +0 -93
  12. data/lib/pikuri/agent/listener/step_limit.rb +0 -97
  13. data/lib/pikuri/agent/listener/terminal.rb +0 -137
  14. data/lib/pikuri/agent/listener/token_log.rb +0 -166
  15. data/lib/pikuri/agent/listener_list.rb +0 -113
  16. data/lib/pikuri/agent/message.rb +0 -61
  17. data/lib/pikuri/agent/synthesizer.rb +0 -120
  18. data/lib/pikuri/agent/tokens.rb +0 -56
  19. data/lib/pikuri/agent.rb +0 -286
  20. data/lib/pikuri/subprocess.rb +0 -166
  21. data/lib/pikuri/tool/bash.rb +0 -272
  22. data/lib/pikuri/tool/calculator.rb +0 -82
  23. data/lib/pikuri/tool/confirmer.rb +0 -96
  24. data/lib/pikuri/tool/edit.rb +0 -196
  25. data/lib/pikuri/tool/fetch.rb +0 -167
  26. data/lib/pikuri/tool/glob.rb +0 -310
  27. data/lib/pikuri/tool/grep.rb +0 -338
  28. data/lib/pikuri/tool/parameters.rb +0 -314
  29. data/lib/pikuri/tool/read.rb +0 -254
  30. data/lib/pikuri/tool/scraper/fetch_error.rb +0 -16
  31. data/lib/pikuri/tool/scraper/html.rb +0 -285
  32. data/lib/pikuri/tool/scraper/pdf.rb +0 -54
  33. data/lib/pikuri/tool/scraper/simple.rb +0 -177
  34. data/lib/pikuri/tool/search/brave.rb +0 -184
  35. data/lib/pikuri/tool/search/duckduckgo.rb +0 -196
  36. data/lib/pikuri/tool/search/engines.rb +0 -154
  37. data/lib/pikuri/tool/search/exa.rb +0 -217
  38. data/lib/pikuri/tool/search/rate_limiter.rb +0 -92
  39. data/lib/pikuri/tool/search/result.rb +0 -29
  40. data/lib/pikuri/tool/skill.rb +0 -80
  41. data/lib/pikuri/tool/skill_catalog.rb +0 -376
  42. data/lib/pikuri/tool/sub_agent.rb +0 -102
  43. data/lib/pikuri/tool/web_scrape.rb +0 -117
  44. data/lib/pikuri/tool/web_search.rb +0 -38
  45. data/lib/pikuri/tool/workspace.rb +0 -150
  46. data/lib/pikuri/tool/write.rb +0 -170
  47. data/lib/pikuri/tool.rb +0 -118
  48. data/lib/pikuri/url_cache.rb +0 -106
  49. data/lib/pikuri/version.rb +0 -10
  50. data/prompts/coding-system-prompt.txt +0 -28
  51. data/prompts/pikuri-chat.txt +0 -15
@@ -1,166 +0,0 @@
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! }
@@ -1,272 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rainbow'
4
-
5
- module Pikuri
6
- class Tool
7
- # The +bash+ tool — run an arbitrary shell command in the workspace.
8
- # Instantiating +Tool::Bash.new(workspace: ws, confirmer: c)+ produces
9
- # a tool whose {Tool#to_ruby_llm_tool} wiring is identical to any
10
- # bundled tool's. Same shape as {Tool::Write} (workspace + confirmer
11
- # captured by the +execute+ closure at construction).
12
- #
13
- # == Confirmation
14
- #
15
- # Every command requires confirmation. Bash composes the full prompt
16
- # (header line, +$ <command>+ echo, +(y/n)?+ cue) and hands it to the
17
- # confirmer; the confirmer renders + parses the answer. The command
18
- # echo passes through {.visible} which escapes control bytes so the
19
- # model can't smuggle a +\r\033[2K rm -rf ~/+ behind the displayed
20
- # text. Execution uses the raw command; only the *display* is
21
- # sanitized.
22
- #
23
- # == Subprocess wiring
24
- #
25
- # The command runs through {Pikuri::Subprocess.spawn} with argv:
26
- #
27
- # timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
28
- #
29
- # +bash -c+ (no +-l+) — no profile/rc sourcing, no inherited login
30
- # environment beyond what pikuri itself runs in. +timeout(1)+ from
31
- # GNU coreutils handles the SIGTERM-then-SIGKILL race; we never use
32
- # Ruby's +Timeout.timeout+ (can't reliably kill subprocesses). The
33
- # +--kill-after=5s+ gives the command 5 seconds to handle SIGTERM
34
- # cleanly before SIGKILL is sent.
35
- #
36
- # == Timeout detection
37
- #
38
- # GNU coreutils' +timeout+ exits +124+ after a successful SIGTERM,
39
- # +137+ after escalating to SIGKILL. We treat both as "command timed
40
- # out". A third code, +125+, is also accepted: uutils-coreutils 0.2.2
41
- # (the Rust reimplementation shipped on some distros) sends the
42
- # SIGTERM correctly but then mis-reports +125+ ("timeout itself
43
- # failed") instead of +124+ whenever +--kill-after+ is in play. False-
44
- # positive risk on real GNU coreutils is low — our argv is fixed and
45
- # well-formed, so a +125+ there would indicate a misconfigured PATH
46
- # rather than a routine timeout.
47
- #
48
- # Caveat: +137+ is ambiguous — a command killed by the OOM-killer
49
- # also exits +137+. v1 accepts the mis-classification; the
50
- # observation tells the user "sent SIGTERM, then SIGKILL" regardless.
51
- #
52
- # == Output handling
53
- #
54
- # Combined stdout+stderr (popen2e). Head+tail truncation at
55
- # {OUTPUT_HEAD} + {OUTPUT_TAIL} bytes with a marker reporting both
56
- # bytes-omitted and the original total — the model needs the scale
57
- # to decide whether to re-run with +head+/+tail+/+grep+.
58
- #
59
- # == Backgrounded subprocesses
60
- #
61
- # Plain +cmd &+ does NOT detach — the backgrounded child inherits our
62
- # combined-output pipe by default, so {Pikuri::Subprocess#wait} blocks
63
- # on +io.read+ until the backgrounded process exits. The model must
64
- # redirect fds away from our pipe to genuinely background:
65
- # +cmd >/dev/null 2>&1 &+. Such commands stay in our pgroup and get
66
- # SIGTERM on pikuri exit via {Pikuri::Subprocess.cleanup!}; +nohup+ /
67
- # +setsid+ plus redirection opt out of cleanup.
68
- #
69
- # == Refusals
70
- #
71
- # All returned as +"Error: ..."+ observations:
72
- #
73
- # * Empty / whitespace-only +command+ → fast reject before confirming.
74
- # * +timeout+ outside +[1, MAX_TIMEOUT]+ → bounds error.
75
- # * User declined the confirmation → +"Error: user declined ..."+.
76
- # * +timeout+ exit (+124+ / +137+) → timeout error with partial output.
77
- class Bash < Tool
78
- # Pikuri-convention per-module logger; the +Bash+ progname tags the
79
- # construction-time warning below so it's clear in the shared
80
- # +Pikuri.log_io+ stream which tool issued it.
81
- # @return [Logger]
82
- LOGGER = Pikuri.logger_for('Bash')
83
-
84
- # @return [Integer] default value of the +timeout+ parameter (seconds).
85
- DEFAULT_TIMEOUT = 120
86
-
87
- # @return [Integer] hard upper bound on the +timeout+ parameter.
88
- MAX_TIMEOUT = 600
89
-
90
- # @return [String] grace period between SIGTERM and SIGKILL,
91
- # passed to +timeout --kill-after=...+.
92
- KILL_AFTER = '5s'
93
-
94
- # @return [Integer] bytes preserved from the start of the output
95
- # when the combined-output stream exceeds {OUTPUT_HEAD} + {OUTPUT_TAIL}.
96
- OUTPUT_HEAD = 15 * 1024
97
-
98
- # @return [Integer] bytes preserved from the end of the output.
99
- OUTPUT_TAIL = 15 * 1024
100
-
101
- # Description shown to the LLM. opencode-shape: summary + +Usage:+
102
- # bullets. Per-parameter constraints (default, max) live in the
103
- # parameter descriptions.
104
- #
105
- # @return [String]
106
- DESCRIPTION = <<~DESC
107
- Run a bash command in the workspace.
108
-
109
- Usage:
110
- - Use for tasks the dedicated tools can't do: git, tests, package managers, multi-step shell pipelines.
111
- - Prefer `read` / `write` / `edit` / `grep` / `glob` over `cat` / `sed` / `rg` / `find` — they're workspace-resolved.
112
- - Each call starts in the workspace root; there is no pwd persistence between calls. Use `cd /path && cmd` in one command.
113
- - stdin is closed; interactive commands hang until timeout. Use non-interactive flags (`apt -y`, `git commit -m`).
114
- - Plain `cmd &` does NOT detach — the backgrounded process inherits our output pipe and blocks. To genuinely background, redirect fds: `cmd >/dev/null 2>&1 &`. Add `nohup` or `setsid` to survive pikuri exit.
115
- - Combined stdout+stderr is returned. Suppress either via `2>/dev/null` etc.
116
- - Large outputs are head+tail-truncated. Pipe through `head`/`tail`/`grep`/`wc` to control volume.
117
- - Non-zero exit status is a normal observation, not an error.
118
- - The user must confirm every command before it runs; on rejection an Error is returned.
119
- DESC
120
-
121
- # @param workspace [Tool::Workspace] captured for +chdir+; commands
122
- # run in +workspace.cwd+. Bash does NOT path-resolve individual
123
- # arguments — the +command+ string is opaque shell syntax.
124
- # @param confirmer [Tool::Confirmer] consulted before every command.
125
- # @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+;
126
- # fail-loud at construction rather than the first tool call.
127
- # @return [Bash]
128
- def initialize(workspace:, confirmer:)
129
- Bash.send(:check_binaries!)
130
- # Prototype-stage capability advisory. The Bash tool runs commands
131
- # with pikuri's own UID and inherits its filesystem view — there is
132
- # no chroot, container, seccomp, or syscall filter today. Anything
133
- # readable to the user is readable to the LLM via +cat ~/.ssh/id_*+
134
- # / +aws configure list+ / etc. The per-command +Confirmer+ is the
135
- # only line of defense; logged loud so anyone wiring this tool up
136
- # sees the warning at startup, not on the day of an incident.
137
- LOGGER.warn(
138
- 'Tool::Bash is a prototype: commands run unsandboxed under your UID and can read ' \
139
- 'sensitive files (~/.ssh, AWS credentials, browser sessions, ...). Use with care; ' \
140
- 'a future release will gate this behind a sandbox.'
141
- )
142
- super(
143
- name: 'bash',
144
- description: DESCRIPTION,
145
- parameters: Parameters.build { |p|
146
- p.required_string :command,
147
- 'Bash command to execute. Multi-line is fine. ' \
148
- 'Example: "ls -la lib/".'
149
- p.optional_string :description,
150
- 'Short 3-7 word label shown to the user alongside ' \
151
- 'the command, e.g. "Run unit tests".'
152
- p.optional_integer :timeout,
153
- "Timeout in seconds. Defaults to #{DEFAULT_TIMEOUT}, " \
154
- "max #{MAX_TIMEOUT}, e.g. 300."
155
- },
156
- execute: ->(command:, description: nil, timeout: DEFAULT_TIMEOUT) {
157
- Bash.run(workspace: workspace, confirmer: confirmer,
158
- command: command, description: description, timeout: timeout)
159
- }
160
- )
161
- end
162
-
163
- # Bounds-check, confirm, spawn, and render the observation. Returns
164
- # either +"$ ...\n<out>\n\nexit status: N"+ on a normal exit, or
165
- # +"Error: ..."+ on rejection / timeout / bad inputs.
166
- #
167
- # @param workspace [Tool::Workspace]
168
- # @param confirmer [Tool::Confirmer]
169
- # @param command [String] raw command as supplied by the LLM
170
- # @param description [String, nil] optional short label for the user
171
- # @param timeout [Integer] seconds before SIGTERM is sent
172
- # @return [String]
173
- def self.run(workspace:, confirmer:, command:, description:, timeout:)
174
- return 'Error: empty bash command.' if command.strip.empty?
175
- return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1
176
- return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT
177
-
178
- prompt = compose_prompt(command: command, description: description, timeout: timeout)
179
- return 'Error: user declined the bash command.' unless confirmer.confirm?(prompt: prompt)
180
-
181
- result = Pikuri::Subprocess.spawn(
182
- 'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s",
183
- 'bash', '-c', command,
184
- chdir: workspace.cwd.to_s
185
- ).wait
186
-
187
- output = truncate(result.output)
188
- exit_code = result.status.exitstatus
189
-
190
- # 124 (GNU SIGTERM), 137 (GNU SIGKILL), 125 (uutils-coreutils
191
- # 0.2.2 bug when --kill-after is set). See class header.
192
- if exit_code == 124 || exit_code == 137 || exit_code == 125
193
- "Error: command timed out after #{timeout}s (sent SIGTERM, then SIGKILL).\n\n" \
194
- "$ #{visible(command)}\n#{output}"
195
- else
196
- "$ #{visible(command)}\n#{output}\n\nexit status: #{exit_code}"
197
- end
198
- end
199
-
200
- # Compose the multi-line confirmation prompt. Three lines:
201
- #
202
- # 1. +OK to run bash[: <desc>][ \[Timeout: Ns\]]+ (bold)
203
- # 2. +$ <visible(command)>+ (dim)
204
- # 3. +(y/n)?+
205
- #
206
- # Colon after +bash+ is dropped when there's no description, since a
207
- # trailing colon with nothing after it reads as broken. Timeout
208
- # suffix appears only when non-default.
209
- #
210
- # @return [String]
211
- def self.compose_prompt(command:, description:, timeout:)
212
- header = +'OK to run bash'
213
- desc_clean = description&.strip
214
- header << ": #{desc_clean}" if desc_clean && !desc_clean.empty?
215
- header << " [Timeout: #{timeout}s]" if timeout != DEFAULT_TIMEOUT
216
-
217
- [
218
- Rainbow(header).bold,
219
- Rainbow("$ #{visible(command)}").dimgray,
220
- '(y/n)?'
221
- ].join("\n")
222
- end
223
- private_class_method :compose_prompt
224
-
225
- # Escape control bytes for safe display while preserving +\n+
226
- # (multi-line shell commands are normal). Catches +\r+, +\x1b+
227
- # (ESC, the ANSI introducer), +\b+, NUL, DEL, etc. — without this,
228
- # a model could craft +command: "\rrm -rf ~/"+ that visually
229
- # overwrites the echo line after the user has already read it.
230
- #
231
- # @param command [String]
232
- # @return [String]
233
- def self.visible(command)
234
- command.gsub(/[\x00-\x09\x0b-\x1f\x7f]/) { |c| format('\\x%02x', c.ord) }
235
- end
236
- private_class_method :visible
237
-
238
- # Head+tail-truncate +output+ to {OUTPUT_HEAD} + {OUTPUT_TAIL}
239
- # bytes with a marker reporting both bytes-omitted and total.
240
- # No-op when the output already fits.
241
- #
242
- # @param output [String]
243
- # @return [String]
244
- def self.truncate(output)
245
- total = output.bytesize
246
- return output if total <= OUTPUT_HEAD + OUTPUT_TAIL
247
-
248
- omitted = total - (OUTPUT_HEAD + OUTPUT_TAIL)
249
- head = output.byteslice(0, OUTPUT_HEAD)
250
- tail = output.byteslice(total - OUTPUT_TAIL, OUTPUT_TAIL)
251
- "#{head}\n... [#{omitted} bytes omitted; total was #{total} bytes] ...\n#{tail}"
252
- end
253
- private_class_method :truncate
254
-
255
- # Verify +bash+ and GNU +timeout+ are reachable on +PATH+. Routed
256
- # through {Pikuri::Subprocess.spawn} to honor the subprocess seam
257
- # (no direct +system+ / +backtick+ in +lib/+). +bash+ missing
258
- # surfaces as +Errno::ENOENT+ from +popen2e+; +timeout+ missing
259
- # surfaces as a non-zero exit from +command -v+.
260
- #
261
- # @return [void]
262
- # @raise [RuntimeError] if either binary is missing
263
- def self.check_binaries!
264
- result = Pikuri::Subprocess.spawn('bash', '-c', 'command -v timeout >/dev/null', chdir: '/').wait
265
- raise "Tool::Bash requires GNU coreutils 'timeout' on PATH" unless result.status.success?
266
- rescue Errno::ENOENT
267
- raise "Tool::Bash requires 'bash' on PATH"
268
- end
269
- private_class_method :check_binaries!
270
- end
271
- end
272
- end
@@ -1,82 +0,0 @@
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
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pikuri
4
- class Tool
5
- # Port for asking the user to confirm a potentially destructive tool
6
- # operation — currently {Tool::Bash} (every command) and
7
- # {Tool::Write} (overwrite of an existing file with non-identical
8
- # content). Subclass and implement {#confirm?}.
9
- #
10
- # == Why a Boolean return
11
- #
12
- # v1 returns +true+ or +false+. Two paths-not-taken worth recording
13
- # so a future reader knows the design space was considered:
14
- #
15
- # 1. Richer return (+:once+ / +:always+ / +:reject+) — rejected
16
- # because it creates decision fatigue, and the long-term answer
17
- # is to make confirmations rare rather than smart (sandboxing,
18
- # agentic destructiveness analysis).
19
- # 2. Agentic destructive-or-not classifier — deferred to v2.
20
- #
21
- # The intended escape from confirmation prompts today is sandboxing
22
- # (docker / dev-container) plus the +--yolo+ flag on +bin/pikuri-code+
23
- # (which wires {AUTO_APPROVE} instead of {TERMINAL}).
24
- #
25
- # == Seam discipline
26
- #
27
- # Tools that need confirmation take a {Confirmer} via constructor and
28
- # invoke {#confirm?} with a fully-composed prompt String. Tools do
29
- # *not* call +gets+ / +puts+ directly — same lesson as listeners,
30
- # keep IO at the seam so a future TUI / web client can plug a
31
- # different implementation in without touching tool code.
32
- class Confirmer
33
- # @param prompt [String] human-readable question composed by the
34
- # calling tool. The confirmer renders it and parses the answer;
35
- # it does NOT compose its own prompt content. Caller owns the
36
- # closing punctuation and any "(y/n)" cue.
37
- # @return [Boolean] +true+ iff approved
38
- # @raise [NotImplementedError] in the abstract base
39
- def confirm?(prompt:)
40
- raise NotImplementedError, "#{self.class}#confirm? must be implemented"
41
- end
42
-
43
- # Stdin/stdout implementation: prints +prompt+ on its own line (a
44
- # leading +puts+ guarantees separation from any streamed output
45
- # the +Terminal+ listener may have produced just above), reads one
46
- # line from +$stdin+, parses it strictly:
47
- #
48
- # * +"y"+ / +"yes"+ (case-insensitive, stripped) → +true+
49
- # * +"n"+ / +"no"+ → +false+
50
- # * EOF / Ctrl+D (+gets+ returns +nil+) → +false+, deliberate abort
51
- # * anything else (blank, typo, +"maybe"+) → re-prompt with a short
52
- # "Please answer y or n: " line and loop
53
- #
54
- # No retry cap; EOF eventually breaks adversarial input.
55
- class Terminal < Confirmer
56
- # @param prompt [String]
57
- # @return [Boolean]
58
- def confirm?(prompt:)
59
- puts
60
- puts prompt
61
- $stdout.flush
62
- loop do
63
- line = $stdin.gets
64
- return false if line.nil?
65
-
66
- answer = line.strip.downcase
67
- return true if answer == 'y' || answer == 'yes'
68
- return false if answer == 'n' || answer == 'no'
69
-
70
- print 'Please answer y or n: '
71
- $stdout.flush
72
- end
73
- end
74
- end
75
-
76
- # Approves everything. Used by +bin/pikuri-code --yolo+ (docker /
77
- # dev-container mode) and by tool specs that don't want to
78
- # coordinate stdin. The name +AUTO_APPROVE+ matches the public
79
- # constant {AUTO_APPROVE}.
80
- class AutoApprove < Confirmer
81
- # @param prompt [String] ignored
82
- # @return [true]
83
- def confirm?(prompt:)
84
- true
85
- end
86
- end
87
-
88
- # Shared singleton instance of {Terminal}. Stateless; reusable
89
- # across tools and sub-agents.
90
- TERMINAL = Terminal.new
91
-
92
- # Shared singleton instance of {AutoApprove}.
93
- AUTO_APPROVE = AutoApprove.new
94
- end
95
- end
96
- end