Class: Pikuri::Workspace::Edit
- Inherits:
-
Tool
- Object
- Tool
- Pikuri::Workspace::Edit
- Defined in:
- lib/pikuri/workspace/edit.rb
Overview
The edit tool — exact-string replacement on an existing file.
Edit.new(workspace: ws) produces a tool wired like any bundled tool's.
In its default (promptless) form it takes the lone workspace: like
Read; an optional confirmer: turns on diff-confirmation.
Confirmer is optional (and self-describing)
With no confirmer: (default), Edit runs without a prompt because two
guards bound its blast radius. First, the read-before-edit gate: .edit
refuses a path the Workspace's read record doesn't show read this
conversation. Second, old_string is itself an implicit read-check — the
model can't supply correct bytes it hasn't seen — so even within a read
file the change is pinned to content the model has. (Write differs — it
always takes a confirmer; see pikuri-workspace/DESIGN.md.)
Handing Edit a confirmer: opts it into the confirm-all-writes posture
(the OS-helper wiring): every writable edit confirms with a diff via
WriteGate, an unwritable target short-circuits to "ask the user to
sudo". So the confirmer's presence is the posture — no separate flag.
Matching is strict (no fuzz cascade)
old_string must match byte-for-byte. v1 ships no fallback replacer
(no whitespace-normalized, block-anchor, etc.): predictability beats fuzz
— a failed Edit means re-read and retry, a clear failure mode with no
compounding-heuristic risk. (opencode runs a 9-replacer cascade despite
saying "must match exactly"; pi stays strict, and so do we.)
occurrence: is what keeps that strictness affordable. Uniqueness, not
exactness, is the expensive half: in a repetitive file the model must
paste a large window just to disambiguate, and the window is pure token
cost. occurrence: 2 picks the second match by position instead. It
cannot silently hit the wrong text — old_string still has to match
byte-for-byte, so a stale index fails the call rather than editing
elsewhere.
Line endings get normalized
The one exception to "strict bytes": CRLF files are matched in LF space
and the original ending restored on write. Read renders via each_line
chomp(stripping\r\nto\n), so a pure byte-match would never succeed on CRLF since the model can only supply LF. Algorithm: detect\r\nanywhere (treat as CRLF) → normalize content/+old_string+/new_stringto LF → match+replace in LF space → convert\n→\r\nback if CRLF. Caveat: a mixed-ending file is treated as CRLF, so bare-LF lines get converted — rare, acceptable for v1.
Refusals (all as "Error: ...")
Empty old_string ("use the write tool") · old_string == new_string
(no-op) · not found ("must match exactly") · multiple matches without
replace_all or occurrence · occurrence combined with replace_all ·
occurrence below 1 or past the match count · missing / directory /
binary file · not read this conversation (the gate, after the binary
refusal) · workspace boundary / EACCES.
Sharing: P_one_agent — the "not read this conversation" refusal reads
one Workspace's record, so sharing an instance widens whose read
counts. As Write for the ReadOnly flag.
Constant Summary collapse
- DESCRIPTION =
Description shown to the LLM (opencode-shape). Per-parameter constraints live in the parameter descriptions.
<<~DESC Edit a file by exact-string replacement. Usage: - You must Read the file in this conversation before editing, or the call will fail. - Use for partial changes to an existing file. - `old_string` and `new_string` must differ. - If `old_string` matches multiple times the call fails — set `occurrence` to pick one by position, set `replace_all: true`, or add surrounding context to make the match unique. - Cannot create files (rejects empty `old_string` and missing files). - Binary files are refused. - CRLF files are matched in LF space; the original line endings are preserved on write. DESC
Class Method Summary collapse
-
.edit(workspace:, path:, old_string:, new_string:, replace_all:, occurrence: nil, read_only: nil, confirmer: nil) ⇒ String
Resolve
path, run the precondition checks (non-empty / non-identical / exists / not directory / not binary / read this conversation), matchold_stringin LF-normalized form, and write back preserving the file's original line endings.
Instance Method Summary collapse
Constructor Details
#initialize(workspace:, read_only: nil, confirmer: nil) ⇒ Edit
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/pikuri/workspace/edit.rb', line 93 def initialize(workspace:, read_only: nil, confirmer: nil) super( name: 'edit', description: DESCRIPTION, parameters: Parameters.build { |p| p.required_string :path, 'Path to the file to edit. Relative paths ' \ 'resolve against the workspace root, e.g. ' \ '"lib/foo.rb".' p.required_string :old_string, 'Exact text to find in the file. Must match ' \ 'byte-for-byte (whitespace counts); strip the ' \ 'Read line prefix (line number + tab) before ' \ 'matching; must be unique unless replace_all is ' \ 'true. Example: "def foo\n bar\nend".' p.required_string :new_string, 'Replacement text. Example: "def foo\n baz\nend".' p.optional_boolean :replace_all, 'Replace every occurrence of old_string ' \ 'instead of failing on multiple matches. ' \ 'Defaults to false, e.g. true.' p.optional_integer :occurrence, 'Replace only the Nth match (1-based, counted ' \ 'top to bottom) and leave the rest alone. Use ' \ 'this instead of padding old_string with ' \ 'context when the file repeats the same ' \ 'snippet. Cannot be combined with replace_all.' }, execute: ->(path:, old_string:, new_string:, replace_all: false, occurrence: nil) { Edit.edit(workspace: workspace, read_only: read_only, confirmer: confirmer, path: path, old_string: old_string, new_string: new_string, replace_all: replace_all, occurrence: occurrence) }, # No legs, for the same reason as {Write}: the observation reports the # outcome, never the file's bytes, so nothing enters the model's # context here. The read-before-edit gate makes that concrete — the # bytes arrived through {Read}, which is where the leg is tagged. trifecta_legs: Tool::TrifectaLegs::NONE ) end |
Class Method Details
.edit(workspace:, path:, old_string:, new_string:, replace_all:, occurrence: nil, read_only: nil, confirmer: nil) ⇒ String
Resolve path, run the precondition checks (non-empty / non-identical /
exists / not directory / not binary / read this conversation), match
old_string in LF-normalized form, and write back preserving the file's
original line endings.
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/pikuri/workspace/edit.rb', line 152 def self.edit(workspace:, path:, old_string:, new_string:, replace_all:, occurrence: nil, read_only: nil, confirmer: nil) return "Error: cannot edit #{path} — #{read_only.}" if read_only&.active? return 'Error: old_string is empty; use the write tool to create or overwrite a file.' if old_string.empty? return 'Error: old_string and new_string are identical — this edit is a no-op.' if old_string == new_string if occurrence && replace_all return 'Error: occurrence and replace_all are mutually exclusive; ' \ 'drop one — occurrence picks a single match, replace_all takes them all.' end return "Error: occurrence must be 1 or greater, got #{occurrence}." if occurrence && occurrence < 1 resolved = workspace.resolve_for_write(path) return "Error: file not found: #{path}" unless resolved.exist? return "Error: #{path} is a directory" if resolved.directory? return "Error: cannot edit binary file: #{path}" if Pikuri::FileType.binary?(resolved) unless workspace.read?(resolved) return "Error: #{path} has not been read this conversation; use the read tool " \ 'first so the edit applies to the current bytes.' end raw = resolved.binread crlf = raw.include?("\r\n") content = crlf ? raw.gsub("\r\n", "\n") : raw needle = normalize_lf(old_string) patch = normalize_lf(new_string) occurrences = content.scan(needle).size if occurrences.zero? return "Error: old_string not found in #{path}. It must match the file " \ 'exactly, including whitespace and indentation; re-read with the ' \ 'read tool if uncertain.' end if occurrence && occurrence > occurrences return "Error: old_string matches #{occurrences} time#{occurrences == 1 ? '' : 's'} " \ "in #{path}, so occurrence #{occurrence} does not exist." end if occurrences > 1 && !replace_all && !occurrence return "Error: old_string matches #{occurrences} times in #{path}. " \ 'Set occurrence=N to replace just the Nth, set replace_all=true ' \ 'to replace all of them, or provide more surrounding context to ' \ 'make the match unique.' end new_content = if replace_all # Block form bypasses gsub's \1 / \& interpolation on the # replacement String — we want literal substitution. content.gsub(needle) { patch } else idx = nth_index(content, needle, occurrence || 1) content.byteslice(0, idx) + patch + content.byteslice(idx + needle.bytesize, content.bytesize - idx - needle.bytesize) end final = crlf ? new_content.gsub("\n", "\r\n") : new_content detail = if occurrence "occurrence #{occurrence} of #{occurrences}" else replaced = replace_all ? occurrences : 1 "#{replaced} occurrence#{replaced == 1 ? '' : 's'}" end if confirmer gate = WriteGate.check( confirmer: confirmer, path: path, resolved: resolved, question: "OK to edit #{path} (#{detail})?", change: Confirmer::Change.new(path: path, old: raw, new: final) ) return gate if gate end resolved.write(final) # Keep the path marked read: the editor knows the resulting bytes, so a # follow-up edit needn't re-read. Idempotent — the read? gate passed. workspace.mark_read(resolved) "Edited #{path}: replaced #{detail}." rescue Filesystem::Error => e "Error: #{e.}" rescue Errno::EACCES => e "Error: cannot edit #{path}: #{e.}" end |