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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/GETTING_STARTED.md +223 -0
- data/LICENSE +21 -0
- data/README.md +193 -0
- data/lib/pikuri/agent/chat_transport.rb +41 -0
- data/lib/pikuri/agent/context_window_detector.rb +101 -0
- data/lib/pikuri/agent/listener/in_memory_message_list.rb +33 -0
- data/lib/pikuri/agent/listener/message_listener.rb +93 -0
- data/lib/pikuri/agent/listener/step_limit.rb +97 -0
- data/lib/pikuri/agent/listener/terminal.rb +137 -0
- data/lib/pikuri/agent/listener/token_log.rb +166 -0
- data/lib/pikuri/agent/listener_list.rb +113 -0
- data/lib/pikuri/agent/message.rb +61 -0
- data/lib/pikuri/agent/synthesizer.rb +120 -0
- data/lib/pikuri/agent/tokens.rb +56 -0
- data/lib/pikuri/agent.rb +286 -0
- data/lib/pikuri/subprocess.rb +166 -0
- data/lib/pikuri/tool/bash.rb +272 -0
- data/lib/pikuri/tool/calculator.rb +82 -0
- data/lib/pikuri/tool/confirmer.rb +96 -0
- data/lib/pikuri/tool/edit.rb +196 -0
- data/lib/pikuri/tool/fetch.rb +167 -0
- data/lib/pikuri/tool/glob.rb +310 -0
- data/lib/pikuri/tool/grep.rb +338 -0
- data/lib/pikuri/tool/parameters.rb +314 -0
- data/lib/pikuri/tool/read.rb +254 -0
- data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
- data/lib/pikuri/tool/scraper/html.rb +285 -0
- data/lib/pikuri/tool/scraper/pdf.rb +54 -0
- data/lib/pikuri/tool/scraper/simple.rb +177 -0
- data/lib/pikuri/tool/search/brave.rb +184 -0
- data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
- data/lib/pikuri/tool/search/engines.rb +154 -0
- data/lib/pikuri/tool/search/exa.rb +217 -0
- data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
- data/lib/pikuri/tool/search/result.rb +29 -0
- data/lib/pikuri/tool/skill.rb +80 -0
- data/lib/pikuri/tool/skill_catalog.rb +376 -0
- data/lib/pikuri/tool/sub_agent.rb +102 -0
- data/lib/pikuri/tool/web_scrape.rb +117 -0
- data/lib/pikuri/tool/web_search.rb +38 -0
- data/lib/pikuri/tool/workspace.rb +150 -0
- data/lib/pikuri/tool/write.rb +170 -0
- data/lib/pikuri/tool.rb +118 -0
- data/lib/pikuri/url_cache.rb +106 -0
- data/lib/pikuri/version.rb +10 -0
- data/lib/pikuri.rb +165 -0
- data/prompts/coding-system-prompt.txt +28 -0
- data/prompts/pikuri-chat.txt +15 -0
- 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
|