Class: Pikuri::Workspace::Edit

Inherits:
Tool
  • Object
show all
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\n to \n), so a pure byte-match would never succeed on CRLF since the model can only supply LF. Algorithm: detect \r\n anywhere (treat as CRLF) → normalize content/+old_string+/ new_string to LF → match+replace in LF space → convert \n\r\n back 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.

Returns:

  • (String)
<<~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

Instance Method Summary collapse

Constructor Details

#initialize(workspace:, read_only: nil, confirmer: nil) ⇒ Edit

Parameters:

  • workspace (Workspace)

    captured for path resolution and the read-before-edit gate; edits route through resolve_for_write (Edit modifies, so the write-set) and the gate consults read?.

  • read_only (ReadOnly, nil) (defaults to: nil)

    when set and ReadOnly#active?, every edit is refused before any filesystem touch — the hard half of the read-only posture. nil (default) disables the gate.

  • confirmer (Confirmer, nil) (defaults to: nil)

    non-nil opts Edit into the confirm-all-writes posture (every writable edit confirms with a diff, unwritable targets short-circuit). nil (default) keeps Edit promptless (see "Confirmer is optional").



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.

Parameters:

  • workspace (Workspace)
  • path (String)

    raw path from the LLM

  • old_string (String)

    text to find

  • new_string (String)

    text to substitute

  • replace_all (Boolean)

    true replaces every occurrence; false (default) errors on multiple matches unless occurrence picks one

  • occurrence (Integer, nil) (defaults to: nil)

    1-based index of the single match to replace, lifting the uniqueness requirement; nil (default) keeps it. Mutually exclusive with replace_all.

  • read_only (ReadOnly, nil) (defaults to: nil)

    read-only gate; see #initialize (default nil for direct callers/specs).

  • confirmer (Confirmer, nil) (defaults to: nil)

    see #initialize (default nil).

Returns:

  • (String)

    tool observation



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.message}" 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.message}"
rescue Errno::EACCES => e
  "Error: cannot edit #{path}: #{e.message}"
end