pikuri 0.0.1

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 +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/GETTING_STARTED.md +223 -0
  4. data/LICENSE +21 -0
  5. data/README.md +193 -0
  6. data/lib/pikuri/agent/chat_transport.rb +41 -0
  7. data/lib/pikuri/agent/context_window_detector.rb +101 -0
  8. data/lib/pikuri/agent/listener/in_memory_message_list.rb +33 -0
  9. data/lib/pikuri/agent/listener/message_listener.rb +93 -0
  10. data/lib/pikuri/agent/listener/step_limit.rb +97 -0
  11. data/lib/pikuri/agent/listener/terminal.rb +137 -0
  12. data/lib/pikuri/agent/listener/token_log.rb +166 -0
  13. data/lib/pikuri/agent/listener_list.rb +113 -0
  14. data/lib/pikuri/agent/message.rb +61 -0
  15. data/lib/pikuri/agent/synthesizer.rb +120 -0
  16. data/lib/pikuri/agent/tokens.rb +56 -0
  17. data/lib/pikuri/agent.rb +286 -0
  18. data/lib/pikuri/subprocess.rb +166 -0
  19. data/lib/pikuri/tool/bash.rb +272 -0
  20. data/lib/pikuri/tool/calculator.rb +82 -0
  21. data/lib/pikuri/tool/confirmer.rb +96 -0
  22. data/lib/pikuri/tool/edit.rb +196 -0
  23. data/lib/pikuri/tool/fetch.rb +167 -0
  24. data/lib/pikuri/tool/glob.rb +310 -0
  25. data/lib/pikuri/tool/grep.rb +338 -0
  26. data/lib/pikuri/tool/parameters.rb +314 -0
  27. data/lib/pikuri/tool/read.rb +254 -0
  28. data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
  29. data/lib/pikuri/tool/scraper/html.rb +285 -0
  30. data/lib/pikuri/tool/scraper/pdf.rb +54 -0
  31. data/lib/pikuri/tool/scraper/simple.rb +177 -0
  32. data/lib/pikuri/tool/search/brave.rb +184 -0
  33. data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
  34. data/lib/pikuri/tool/search/engines.rb +154 -0
  35. data/lib/pikuri/tool/search/exa.rb +217 -0
  36. data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
  37. data/lib/pikuri/tool/search/result.rb +29 -0
  38. data/lib/pikuri/tool/skill.rb +80 -0
  39. data/lib/pikuri/tool/skill_catalog.rb +376 -0
  40. data/lib/pikuri/tool/sub_agent.rb +102 -0
  41. data/lib/pikuri/tool/web_scrape.rb +117 -0
  42. data/lib/pikuri/tool/web_search.rb +38 -0
  43. data/lib/pikuri/tool/workspace.rb +150 -0
  44. data/lib/pikuri/tool/write.rb +170 -0
  45. data/lib/pikuri/tool.rb +118 -0
  46. data/lib/pikuri/url_cache.rb +106 -0
  47. data/lib/pikuri/version.rb +10 -0
  48. data/lib/pikuri.rb +165 -0
  49. data/prompts/coding-system-prompt.txt +28 -0
  50. data/prompts/pikuri-chat.txt +15 -0
  51. metadata +259 -0
@@ -0,0 +1,272 @@
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
@@ -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,96 @@
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
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Tool
5
+ # The +edit+ tool — exact-string replacement on an existing file.
6
+ # Instantiating +Tool::Edit.new(workspace: ws)+ produces a tool whose
7
+ # {Tool#to_ruby_llm_tool} wiring is identical to any bundled tool's.
8
+ # Same shape as {Tool::Read} (workspace captured by +execute+; no
9
+ # confirmer needed).
10
+ #
11
+ # == Why no confirmer
12
+ #
13
+ # The +old_string+ argument is itself an implicit read-check: the
14
+ # model can't write a correct +old_string+ without having seen the
15
+ # file (via {Tool::Read} or out-of-band), so the blast radius of any
16
+ # Edit is bounded by the model's actual knowledge of file state.
17
+ # That makes Edit safe to execute without prompting — by contrast,
18
+ # {Tool::Write} requires a confirmer because a hallucinated 500-line
19
+ # +content+ could clobber unread bytes.
20
+ #
21
+ # == Matching is strict (no fuzz cascade)
22
+ #
23
+ # +old_string+ must match the file byte-for-byte. v1 ships *no*
24
+ # fallback replacer (no whitespace-normalized, line-trimmed, block-
25
+ # anchor, etc.). Predictability beats fuzz: when an Edit fails, the
26
+ # model re-reads with {Tool::Read} and retries — clear failure mode,
27
+ # no compounding-heuristic risk. opencode runs a 9-replacer cascade
28
+ # under the hood despite its own description saying "must match
29
+ # exactly"; pi stays strict. We match pi.
30
+ #
31
+ # == Line endings get normalized
32
+ #
33
+ # The one structural exception to "strict bytes": files with CRLF
34
+ # line endings get matched in LF space, and the original line ending
35
+ # is restored on write. Reason: {Tool::Read} renders content via
36
+ # +each_line+ + +chomp+, which strips +\r\n+ to +\n+ in what the
37
+ # model sees. A pure strict byte-match would then never succeed on
38
+ # CRLF files because the model can only ever supply LF. opencode and
39
+ # pi both do this normalization for the same reason.
40
+ #
41
+ # Algorithm:
42
+ #
43
+ # 1. Detect whether the file contains +\r\n+ anywhere (treat as CRLF).
44
+ # 2. Normalize content, +old_string+, and +new_string+ to LF.
45
+ # 3. Match + replace in LF space.
46
+ # 4. If the file was CRLF, convert +\n+ → +\r\n+ on the way back out.
47
+ #
48
+ # Caveat: a mixed-line-ending file is treated as CRLF, which means
49
+ # any pre-existing bare-LF lines get converted on write. Rare in
50
+ # practice; acceptable for v1.
51
+ #
52
+ # == Refusals
53
+ #
54
+ # All returned as +"Error: ..."+ observations the LLM can react to:
55
+ #
56
+ # * Empty +old_string+ → "use the write tool" (keeps Edit/Write roles
57
+ # non-overlapping).
58
+ # * +old_string+ == +new_string+ → no-op error.
59
+ # * +old_string+ not found in file → "must match exactly" error
60
+ # pointing at the read tool.
61
+ # * +old_string+ found multiple times without +replace_all+ →
62
+ # multi-match error suggesting more context or +replace_all+.
63
+ # * File missing / is a directory / is binary → respective error.
64
+ # * Workspace boundary violation / EACCES → standard rescue path.
65
+ class Edit < Tool
66
+ # Description shown to the LLM. Follows the opencode-shape (summary
67
+ # + +Usage:+ bullets) prescribed by the project's tool-description
68
+ # convention. Per-parameter constraints live in the parameter
69
+ # descriptions.
70
+ #
71
+ # @return [String]
72
+ DESCRIPTION = <<~DESC
73
+ Edit a file by exact-string replacement.
74
+
75
+ Usage:
76
+ - Use for partial changes to an existing file; for full rewrites or new files use `write` instead.
77
+ - `old_string` must match the file byte-for-byte (whitespace and indentation count); re-read the file with `read` if uncertain.
78
+ - `old_string` and `new_string` must differ.
79
+ - If `old_string` matches multiple times the call fails — add surrounding context to make the match unique, or set `replace_all: true`.
80
+ - Cannot create files (rejects empty `old_string` and missing files).
81
+ - Binary files are refused.
82
+ - CRLF files are matched in LF space; the original line endings are preserved on write.
83
+ DESC
84
+
85
+ # @param workspace [Tool::Workspace] captured for path resolution;
86
+ # all reads/writes route through +workspace.resolve_for_write+
87
+ # (Edit modifies, so it uses the write-set even though it doesn't
88
+ # create files).
89
+ # @return [Edit]
90
+ def initialize(workspace:)
91
+ super(
92
+ name: 'edit',
93
+ description: DESCRIPTION,
94
+ parameters: Parameters.build { |p|
95
+ p.required_string :path,
96
+ 'Path to the file to edit. Relative paths ' \
97
+ 'resolve against the workspace root, e.g. ' \
98
+ '"lib/foo.rb".'
99
+ p.required_string :old_string,
100
+ 'Exact text to find in the file. Must match ' \
101
+ 'byte-for-byte (whitespace counts); must be ' \
102
+ 'unique unless replace_all is true. Example: ' \
103
+ '"def foo\n bar\nend".'
104
+ p.required_string :new_string,
105
+ 'Replacement text. Must differ from ' \
106
+ 'old_string. Example: "def foo\n baz\nend".'
107
+ p.optional_boolean :replace_all,
108
+ 'Replace every occurrence of old_string ' \
109
+ 'instead of failing on multiple matches. ' \
110
+ 'Defaults to false, e.g. true.'
111
+ },
112
+ execute: ->(path:, old_string:, new_string:, replace_all: false) {
113
+ Edit.edit(workspace: workspace, path: path,
114
+ old_string: old_string, new_string: new_string,
115
+ replace_all: replace_all)
116
+ }
117
+ )
118
+ end
119
+
120
+ # Resolve +path+ against +workspace+, run the precondition checks
121
+ # (non-empty / non-identical / file exists / not directory / not
122
+ # binary), match +old_string+ in line-ending-normalized form, and
123
+ # write the result back preserving the file's original line endings.
124
+ #
125
+ # @param workspace [Tool::Workspace]
126
+ # @param path [String] raw path as supplied by the LLM
127
+ # @param old_string [String] text to find
128
+ # @param new_string [String] text to substitute in
129
+ # @param replace_all [Boolean] when true, every occurrence is
130
+ # replaced; when false (default) multiple matches are an error
131
+ # @return [String] tool observation
132
+ def self.edit(workspace:, path:, old_string:, new_string:, replace_all:)
133
+ return 'Error: old_string is empty; use the write tool to create or overwrite a file.' if old_string.empty?
134
+ return 'Error: old_string and new_string are identical — this edit is a no-op.' if old_string == new_string
135
+
136
+ resolved = workspace.resolve_for_write(path)
137
+ return "Error: file not found: #{path}" unless resolved.exist?
138
+ return "Error: #{path} is a directory" if resolved.directory?
139
+
140
+ raw = resolved.binread
141
+ sample = raw.byteslice(0, Tool::Read::BINARY_SAMPLE_BYTES)
142
+ return "Error: cannot edit binary file: #{path}" if Tool::Read.binary?(sample)
143
+
144
+ crlf = raw.include?("\r\n")
145
+ content = crlf ? raw.gsub("\r\n", "\n") : raw
146
+ needle = normalize_lf(old_string)
147
+ patch = normalize_lf(new_string)
148
+
149
+ occurrences = content.scan(needle).size
150
+ if occurrences.zero?
151
+ return "Error: old_string not found in #{path}. It must match the file " \
152
+ 'exactly, including whitespace and indentation; re-read with the ' \
153
+ 'read tool if uncertain.'
154
+ end
155
+ if occurrences > 1 && !replace_all
156
+ return "Error: old_string matches #{occurrences} times in #{path}. " \
157
+ 'Provide more surrounding context to make the match unique, ' \
158
+ 'or set replace_all=true to replace all occurrences.'
159
+ end
160
+
161
+ replaced = replace_all ? occurrences : 1
162
+ new_content =
163
+ if replace_all
164
+ # Block form bypasses gsub's \1 / \& interpolation on the
165
+ # replacement String — we want literal substitution.
166
+ content.gsub(needle) { patch }
167
+ else
168
+ idx = content.index(needle)
169
+ content.byteslice(0, idx) + patch + content.byteslice(idx + needle.bytesize, content.bytesize - idx - needle.bytesize)
170
+ end
171
+
172
+ final = crlf ? new_content.gsub("\n", "\r\n") : new_content
173
+ resolved.write(final)
174
+
175
+ "Edited #{path}: replaced #{replaced} occurrence#{replaced == 1 ? '' : 's'}."
176
+ rescue Tool::Workspace::Error => e
177
+ "Error: #{e.message}"
178
+ rescue Errno::EACCES => e
179
+ "Error: cannot edit #{path}: #{e.message}"
180
+ end
181
+
182
+ # Force a String to BINARY encoding and collapse +\r\n+ → +\n+ so
183
+ # all matching/replacement happens in LF space with byte-stable
184
+ # comparisons. Applied to the file content, +old_string+, and
185
+ # +new_string+ alike — symmetric normalization keeps the byte-match
186
+ # semantics consistent across all three inputs.
187
+ #
188
+ # @param str [String]
189
+ # @return [String] BINARY-encoded, CRLF-collapsed copy
190
+ def self.normalize_lf(str)
191
+ str.b.gsub("\r\n", "\n")
192
+ end
193
+ private_class_method :normalize_lf
194
+ end
195
+ end
196
+ end