ask-tools-shell 0.4.0 → 0.5.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9920ae3e941453ed140a053b18b39f59d336f5dbc20334c6b8a262b7554f956
4
- data.tar.gz: 3a4ad48154b393b9d69f39d2a6cc4af8fcf4a465a59921017fe7d2c62892d440
3
+ metadata.gz: 8805be0a64d1a42f4b0eae3600c81ce0e61565fa19aaf1fd835d36fbb6fa9b58
4
+ data.tar.gz: fe497b0c0604cc2c9a90cd02bc17c31ff8f46140ac88a67e9697b5b9cd15f7ee
5
5
  SHA512:
6
- metadata.gz: 8ff7cd4230e7e4be6e2ae9b2a2a0410ed994959fedb0da3628caedff322cb84bd531e8bb62cf5d9b669fb12d759c675ef47d2f4deb32738d292bbbf710a193f7
7
- data.tar.gz: a404e7da5f72e0ca458f04bfee0d72ed56d609e39db59e665f4e3a837b1b229a11879317770e6fa901c6cb5ba6e1813834d7464fbba69d34f2dee4fa64a9fba3
6
+ metadata.gz: defeb70d692dc1da34ad21300ed52dff8496df4fd28c3365b7724d9e3a6c113afbdd7816619cfa0f9017479d9af1a061fb0ef82c140110f0e46209df8829bdc6
7
+ data.tar.gz: e855c29b7b8ed782dab2fccb0764c3eebe4b5b5b7af039aff713fc746c31b3b70f77b1a47abc250a54b217a3bacba4642bef97aa3f409e5f52ee80043a8f0610
data/CHANGELOG.md CHANGED
@@ -1,8 +1,44 @@
1
+ ## [0.5.0] - 2026-08-10
2
+
3
+ ### Added
4
+ - **Read tool engineering pass** — reads are the bill for building context,
5
+ so every decision inside `Ask::Tools::Read` is a token-budget decision:
6
+ - **Three ceilings, not one**: the 2000-line window (existing), a byte
7
+ budget (128 KB of output by default, `ASK_TOOLS_SHELL_READ_BYTE_BUDGET`
8
+ to override) for wide files, and a per-line clamp (2000 chars) for
9
+ minified bundles. Truncated reads return ok with a **precomputed resume
10
+ offset** — no pagination arithmetic for the model.
11
+ - **Named recovery, facts not errors**: empty files, past-EOF offsets,
12
+ binary files (mime note, never garbage bytes), and PDFs (pdftotext hint)
13
+ all return ok with a one-line answer instead of an error.
14
+ - **Streaming reads**: `File.foreach` with an early break at the budget —
15
+ a 400 MB log costs one read, not one load. "Is there more file?" is only
16
+ answered when it can be (peek, never guess).
17
+ - **Strict input repair**: `offset`/`limit` accept `"2000"` and `2.0` but
18
+ reject `"2abc"` and `1.5` instead of silently reading the wrong window.
19
+ - **Device blocklist**: `/dev/zero`, `/dev/urandom`, `/dev/stdin`,
20
+ `/dev/fd/*`, `/proc/*/fd/*` refused by name before any I/O — a read can
21
+ never hang on them.
22
+ - **Filename repair**: NFD/NFC, narrow NBSP, and curly-quote variants are
23
+ retried for the model; then "did you mean?" (substring + bounded
24
+ Levenshtein ≤ 2, catches `AGENT.md` → `AGENTS.md`).
25
+ - **Self-expiring dedup**: re-reading the same unchanged (path, mtime,
26
+ size, offset, limit) window returns a one-line "already in context" stub
27
+ — consumed on use, complete reads only, kill-switch
28
+ `ASK_TOOLS_SHELL_READ_NO_CACHE=1`.
29
+ - **Partial-view ledger** (`Ask::Tools::Shell::FileLedger`): Read records
30
+ what it showed; **Write refuses to overwrite a partially-read unchanged
31
+ file** ("re-read the full file first"), and Edit records a full read so
32
+ the invariant can't deadlock. Ledger entries auto-invalidate when the
33
+ file's mtime/size change.
34
+ - **Hygiene**: BOM stripped, CRLF → LF, invalid UTF-8 replaced instead of
35
+ raising (a read never crashes on bytes).
36
+
1
37
  ## [0.4.0] - 2026-08-05
2
38
 
3
39
  ### Added
4
40
  - **`Ask::Tools::Repl`** — evaluate Ruby code in a persistent session (the
5
- RLM / recursive-language-model pattern). A long-lived plain-ruby kernel
41
+ RLM / recursive language model pattern). A long-lived plain-ruby kernel
6
42
  subprocess keeps state across calls: locals, `require`s, and defined
7
43
  methods survive between evaluations, so the model composes capabilities as
8
44
  code against a working environment instead of re-bootstrapping each time.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/ask-tools-shell.svg)](https://badge.fury.io/rb/ask-tools-shell)
4
4
 
5
- Shell, filesystem, and code execution tools for AI agents. Ships 8 tools: Bash, Read, Write, Edit, Glob, Grep, Code, and ApplyPatch. Bash and Code execute through ask-sandbox-providers; the rest operate directly on the local filesystem.
5
+ Shell, filesystem, and code execution tools for AI agents. Ships 9 tools: Bash, Read, Write, Edit, Glob, Grep, Code, Repl, and ApplyPatch. Bash and Code execute through ask-sandbox-providers; Repl runs a persistent plain-ruby kernel; the rest operate directly on the local filesystem.
6
6
 
7
7
  ## Installation
8
8
 
@@ -16,7 +16,7 @@ gem "ask-tools-shell"
16
16
  require "ask-tools-shell"
17
17
 
18
18
  Ask::Tools::Shell.all.map(&:name)
19
- # => ["bash", "read", "write", "edit", "glob", "grep", "code", "apply_patch"]
19
+ # => ["bash", "read", "write", "edit", "glob", "grep", "code", "repl", "apply_patch"]
20
20
 
21
21
  result = Ask::Tools::Bash.new.call(command: "echo hello")
22
22
  result.ok? # => true
@@ -35,6 +35,7 @@ result.output[:exit_code] # => 0
35
35
  | `Ask::Tools::Glob` | `pattern`, `path` | Up to 1000 files, newest first |
36
36
  | `Ask::Tools::Grep` | `pattern`, `path`, `include` | Regex search; 100 matches max, skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log` |
37
37
  | `Ask::Tools::Code` | `code` | Runs Ruby via `Ask::Sandbox.provider`; returns `{ stdout, stderr, exit_code }` |
38
+ | `Ask::Tools::Repl` | `code`, `session`, `reset` | Evaluates Ruby in a persistent session — state (variables, requires, methods) survives across calls; timeouts kill the session and respawn fresh |
38
39
  | `Ask::Tools::ApplyPatch` | `patchText` | Applies unified diffs inside a `*** Begin Patch` / `*** End Patch` envelope (Add File, Update File, Delete File sections) |
39
40
 
40
41
  ## Sandboxed execution
@@ -45,6 +46,18 @@ result.output[:exit_code] # => 0
45
46
  Ask::Sandbox.provider = :docker
46
47
  ```
47
48
 
49
+ `Repl` is a durable control environment (a persistent subprocess that must
50
+ keep state) and is not sandboxed — don't point it at untrusted code.
51
+
52
+ ### Code vs Repl
53
+
54
+ `Code` runs one Ruby snippet in a sandboxed subprocess and forgets it.
55
+ `Repl` keeps a session alive so variables and methods survive across calls.
56
+
57
+ Use `Code` for isolated one-off snippets and for code you don't trust (the
58
+ sandbox is the safety boundary). Use `Repl` for multi-step work: load data
59
+ and define helpers once, then keep working with them.
60
+
48
61
  ## Full documentation
49
62
 
50
63
  The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [ask-tools in depth](https://ask-rb.github.io/ask-docs/core/tools) covers the shell tools, the ApplyPatch format, and sandbox configuration. API reference: https://ask-rb.github.io/ask-docs/reference/api.
@@ -95,6 +95,10 @@ module Ask
95
95
  end
96
96
 
97
97
  raw = operations.read_file(path)
98
+ # Edit reads the whole file, so the model has seen all of it — record
99
+ # that before the write so a later Write isn't blocked by a stale
100
+ # partial view (and so the ledger reflects what was actually shown).
101
+ Shell::FileLedger.record(path, partial: false, lines_seen: [0, raw.count("\n") + 1])
98
102
  bom, content = Shell.strip_bom(raw)
99
103
  original_ending = Shell.detect_line_ending(content)
100
104
  content = Shell.normalize_line_endings(content)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Ask
6
+ module Tools
7
+ module Shell
8
+ # Records what Read has shown of each file, so Write can refuse to
9
+ # destroy content the model never saw. Entries are validated against
10
+ # the file's current mtime/size — a changed file invalidates its
11
+ # entry, so a stale ledger can never block a write it shouldn't.
12
+ #
13
+ # Class-level on purpose: a partial view is a fact about the world,
14
+ # not about one tool instance (agent sessions new up fresh tool
15
+ # instances, and the invariant must survive across them).
16
+ class FileLedger
17
+ Entry = Struct.new(:path, :mtime, :size, :partial, :lines_seen, keyword_init: true)
18
+
19
+ @entries = {}
20
+ @mutex = Monitor.new
21
+
22
+ class << self
23
+ # Record what a read showed of a file.
24
+ # @param partial [Boolean] true when the view was clamped/truncated
25
+ # @param lines_seen [Array(Integer, Integer)] [start, stop) line
26
+ # indices shown, 0-indexed
27
+ def record(path, partial:, lines_seen:)
28
+ path = File.expand_path(path)
29
+ @mutex.synchronize do
30
+ @entries[path] = Entry.new(
31
+ path: path,
32
+ mtime: File.mtime(path),
33
+ size: File.size(path),
34
+ partial: partial,
35
+ lines_seen: lines_seen
36
+ )
37
+ end
38
+ rescue Errno::ENOENT
39
+ nil # file vanished mid-read; nothing to record
40
+ end
41
+
42
+ # The entry for a path, or nil when the file changed since the read.
43
+ def entry_for(path)
44
+ path = File.expand_path(path)
45
+ @mutex.synchronize do
46
+ entry = @entries[path]
47
+ next nil unless entry
48
+
49
+ current = File.stat(path)
50
+ (current.mtime == entry.mtime && current.size == entry.size) ? entry : nil
51
+ end
52
+ rescue Errno::ENOENT
53
+ nil
54
+ end
55
+
56
+ # True when the file was only partially read and hasn't changed
57
+ # since. Write consults this before overwriting.
58
+ def partially_seen?(path)
59
+ entry = entry_for(path)
60
+ !entry.nil? && entry.partial
61
+ end
62
+
63
+ def reset!
64
+ @mutex.synchronize { @entries.clear }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -5,64 +5,332 @@ require "fileutils"
5
5
  module Ask
6
6
  module Tools
7
7
  # Read file contents with line numbers, or list directory contents.
8
- # Output is truncated to 2000 lines.
8
+ #
9
+ # Engineered for token budgets. Three ceilings stop the three shapes of
10
+ # hostile file — the long file (line window), the wide file (byte
11
+ # budget), the minified bundle (per-line clamp):
12
+ #
13
+ # max_lines = 2000 lines — the window
14
+ # byte_budget = 128 KB — chars of output returned
15
+ # max_line_chars = 2000 — per-line clamp
16
+ #
17
+ # Truncation is a fact, not an error: reads that stop short return ok
18
+ # with a precomputed resume offset, so the model never does pagination
19
+ # arithmetic and never treats a fact about the world as a failure.
20
+ #
21
+ # The other decisions that make a read cheap instead of expensive:
22
+ # - strict offset/limit repair (never silently mangle "2abc" into 2)
23
+ # - device blocklist — /dev/zero would hang a read forever
24
+ # - filename repair: NFD/NFC, narrow NBSP, curly quotes, did-you-mean
25
+ # - a self-expiring dedup stub for unchanged re-reads (consumed on use,
26
+ # complete reads only, kill-switchable)
27
+ # - a partial-view ledger that Write consults before overwriting
9
28
  class Read < Ask::Tool
10
29
  description "Read the contents of a file or list a directory. " \
11
- "Files are displayed with line numbers. " \
12
- "Output is truncated to 2000 lines."
30
+ "Files are displayed with line numbers. Output is bounded " \
31
+ "to 2000 lines and 128 KB, with resume hints when truncated."
13
32
 
14
33
  param :path, type: :string, desc: "Absolute path to the file or directory", required: true
15
34
  param :offset, type: :integer, desc: "Starting line number (0-indexed)", required: false
16
35
  param :limit, type: :integer, desc: "Maximum number of lines to read", required: false
17
36
 
18
- MAX_LINES = 2000
37
+ DEFAULT_MAX_LINES = 2000
38
+ DEFAULT_BYTE_BUDGET = 128_000
39
+ DEFAULT_MAX_LINE_CHARS = 2000
40
+
41
+ # Device files that never end or block forever — refused by name
42
+ # before any I/O, so a read can never hang on them.
43
+ DEVICE_PATHS = %w[
44
+ /dev/zero /dev/random /dev/urandom
45
+ /dev/stdin /dev/stdout /dev/stderr
46
+ ].freeze
47
+
48
+ MIME_TYPES = {
49
+ ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
50
+ ".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml",
51
+ ".pdf" => "application/pdf", ".zip" => "application/zip",
52
+ ".gz" => "application/gzip", ".mp3" => "audio/mpeg", ".mp4" => "video/mp4"
53
+ }.freeze
54
+
55
+ attr_reader :max_lines, :byte_budget, :max_line_chars, :dedup_enabled
56
+ attr_writer :max_lines, :byte_budget, :max_line_chars
57
+
58
+ def initialize
59
+ super
60
+ @max_lines = DEFAULT_MAX_LINES
61
+ @byte_budget = (ENV["ASK_TOOLS_SHELL_READ_BYTE_BUDGET"] || DEFAULT_BYTE_BUDGET).to_i
62
+ @max_line_chars = DEFAULT_MAX_LINE_CHARS
63
+ @dedup_enabled = ENV["ASK_TOOLS_SHELL_READ_NO_CACHE"] != "1"
64
+ @dedup = {}
65
+ end
19
66
 
20
67
  def execute(path:, offset: nil, limit: nil)
21
68
  path = File.expand_path(path)
22
69
 
70
+ if device_path?(path)
71
+ return Ask::Result.error(message: "Refusing to read device file: #{path} (can block forever).")
72
+ end
73
+
23
74
  unless File.exist?(path)
24
- return Ask::Result.error(message: "Path does not exist: #{path}")
75
+ return Ask::Result.error(message: missing_path_message(path))
25
76
  end
26
77
 
27
78
  if File.directory?(path)
28
- entries = Dir.children(path).sort
29
- entries.map! do |e|
30
- full = File.join(path, e)
31
- "#{e}#{File.directory?(full) ? '/' : ''}"
32
- end
33
- return Ask::Result.ok(data: entries.join("\n"), metadata: { type: "directory", count: entries.size })
79
+ return directory_listing(path)
34
80
  end
35
81
 
36
82
  unless File.file?(path)
37
83
  return Ask::Result.error(message: "Not a file: #{path}")
38
84
  end
39
85
 
40
- if File.size(path) > 1_000_000
41
- return Ask::Result.error(
42
- message: "File too large (#{File.size(path)} bytes). Use offset/limit to read portions."
86
+ offset = coerce_int("offset", offset)
87
+ return Ask::Result.error(message: offset) if offset.is_a?(String)
88
+ limit = coerce_int("limit", limit)
89
+ return Ask::Result.error(message: limit) if limit.is_a?(String)
90
+
91
+ offset ||= 0
92
+ limit ||= @max_lines
93
+ return Ask::Result.error(message: "Invalid offset: #{offset} (must be >= 0).") if offset.negative?
94
+ return Ask::Result.error(message: "Invalid limit: #{limit} (must be >= 1).") if limit < 1
95
+
96
+ special = sniff(path)
97
+ return special if special
98
+
99
+ read = read_lines(path, offset, limit)
100
+ partial_view = read[:more] || read[:clamped].positive?
101
+
102
+ if @dedup_enabled && !partial_view && read[:lines].any?
103
+ key = [path, File.mtime(path).to_f, File.size(path), offset, limit]
104
+ if @dedup.key?(key)
105
+ @dedup.delete(key) # self-expiring: one stub, then real content again
106
+ return Ask::Result.ok(
107
+ data: "File unchanged since last read — content is already in context.",
108
+ metadata: { dedup: true }
109
+ )
110
+ end
111
+ @dedup[key] = true
112
+ end
113
+
114
+ Shell::FileLedger.record(path, partial: partial_view, lines_seen: [offset, offset + read[:lines].size])
115
+
116
+ data, resume_offset = format_output(path, offset, read)
117
+
118
+ metadata = {
119
+ total_lines: read[:more] || (read[:lines].empty? && read[:saw_any]) ? nil : offset + read[:lines].size,
120
+ start_line: read[:lines].empty? ? nil : offset + 1,
121
+ end_line: offset + read[:lines].size,
122
+ truncated: read[:more],
123
+ partial_view: partial_view,
124
+ clamped_lines: read[:clamped],
125
+ resume_offset: resume_offset
126
+ }
127
+ metadata.delete(:resume_offset) unless read[:more]
128
+ Ask::Result.ok(data: data, metadata: metadata)
129
+ end
130
+
131
+ private
132
+
133
+ # ── the three ceilings ─────────────────────────────────────────────
134
+
135
+ # Stream the file line by line (never load the whole thing), stopping
136
+ # at the line window, the byte budget, or EOF. Returns
137
+ # { lines:, more:, clamped:, mid_cut:, saw_any: }.
138
+ #
139
+ # "Is there more file?" is only answered when it can be: the window
140
+ # break proves it by having skipped a line; the budget break proves it
141
+ # by holding a line that didn't fit; at EOF there is none.
142
+ def read_lines(path, offset, limit)
143
+ selected = []
144
+ more = false
145
+ clamped = 0
146
+ mid_cut = false
147
+ output_len = 0
148
+ saw_any = false
149
+
150
+ File.foreach(path, chomp: true, invalid: :replace, undef: :replace).with_index do |line, i|
151
+ saw_any = true
152
+ next if i < offset
153
+
154
+ if selected.size >= limit
155
+ more = true
156
+ break
157
+ end
158
+
159
+ line = line.delete_suffix("\r") # CRLF → LF
160
+ line = Shell.strip_bom(line).last if i.zero? && offset.zero?
161
+
162
+ if line.length > @max_line_chars
163
+ line = line[0, @max_line_chars] + "…[clamped at #{@max_line_chars} chars]"
164
+ clamped += 1
165
+ end
166
+
167
+ numbered = "#{i + 1}: #{line}"
168
+ cost = numbered.length + 1
169
+ if output_len + cost > @byte_budget
170
+ if selected.empty?
171
+ # One line that outgrows the whole budget: show a slice rather
172
+ # than silence — silence is the most expensive thing a tool can
173
+ # return.
174
+ room = [@byte_budget - output_len - 2, 1].max
175
+ selected << "#{i + 1}: #{line[0, room]}…"
176
+ clamped += 1
177
+ mid_cut = true
178
+ end
179
+ more = true
180
+ break
181
+ end
182
+
183
+ output_len += cost
184
+ selected << numbered
185
+ end
186
+
187
+ { lines: selected, more: more, clamped: clamped, mid_cut: mid_cut, saw_any: saw_any }
188
+ end
189
+
190
+ # ── named recovery: facts, not errors ──────────────────────────────
191
+
192
+ def format_output(path, offset, read)
193
+ if read[:lines].empty?
194
+ return read[:saw_any] ?
195
+ ["Offset #{offset} is past the end of the file — retry with a smaller offset.", nil] :
196
+ ["File is empty (0 lines).", nil]
197
+ end
198
+
199
+ data = read[:lines].join("\n")
200
+ resume = nil
201
+ if read[:more]
202
+ # A mid-line cut resumes ON the line that was cut (it is the last
203
+ # line shown); every other truncation resumes on the next line.
204
+ resume = read[:mid_cut] ? offset + read[:lines].size - 1 : offset + read[:lines].size
205
+ data << "\n... (more lines) — resume with offset=#{resume}"
206
+ end
207
+ [data, resume]
208
+ end
209
+
210
+ def directory_listing(path)
211
+ entries = Dir.children(path).sort
212
+ entries.map! do |e|
213
+ full = File.join(path, e)
214
+ "#{e}#{File.directory?(full) ? '/' : ''}"
215
+ end
216
+ Ask::Result.ok(data: entries.join("\n"), metadata: { type: "directory", count: entries.size })
217
+ end
218
+
219
+ # Peek the head of the file: PDF magic first (it's also binary), then
220
+ # a null byte means binary. Returns an Ask::Result for special formats.
221
+ def sniff(path)
222
+ head = File.open(path, "rb") { |f| f.read(1024) } || ""
223
+ if head.start_with?("%PDF")
224
+ return Ask::Result.ok(
225
+ data: "PDF document (#{File.size(path)} bytes) — extract text with pdftotext.",
226
+ metadata: { format: "pdf" }
43
227
  )
44
228
  end
229
+ if head.include?("\x00")
230
+ mime = MIME_TYPES[File.extname(path).downcase] || "application/octet-stream"
231
+ return Ask::Result.ok(
232
+ data: "Binary file (#{mime}, #{File.size(path)} bytes) — content not shown.",
233
+ metadata: { format: "binary", mime: mime }
234
+ )
235
+ end
236
+ nil
237
+ end
238
+
239
+ # ── input repair ───────────────────────────────────────────────────
240
+
241
+ # Coerce the value an LLM actually sent into an integer, or return an
242
+ # error message string. Accepts Integer, "2000", 2.0 — rejects "2abc"
243
+ # and 1.5 rather than silently reading a wrong window.
244
+ def coerce_int(name, value)
245
+ case value
246
+ when nil then nil
247
+ when Integer then value
248
+ when String
249
+ value.match?(/\A-?\d+\z/) ? value.to_i : "Invalid #{name}: #{value.inspect} — expected an integer."
250
+ when Float
251
+ value == value.to_i ? value.to_i : "Invalid #{name}: #{value.inspect} — expected an integer."
252
+ else
253
+ "Invalid #{name}: #{value.inspect} — expected an integer."
254
+ end
255
+ end
256
+
257
+ # ── device blocklist ───────────────────────────────────────────────
258
+
259
+ def device_path?(path)
260
+ DEVICE_PATHS.include?(path) ||
261
+ path.start_with?("/dev/fd/") ||
262
+ path.match?(%r{\A/proc/\d+/fd/}) ||
263
+ path.match?(%r{\A/proc/\d+/task/\d+/fd/})
264
+ end
265
+
266
+ # ── filename repair ────────────────────────────────────────────────
267
+
268
+ def missing_path_message(path)
269
+ base = File.basename(path)
270
+ dir = File.dirname(path)
271
+
272
+ # The model can't see byte-level differences: narrow NBSP vs space,
273
+ # NFD vs NFC, straight vs curly quotes. Retry the candidates for it.
274
+ match = filename_variants(base).find { |c| File.exist?(File.join(dir, c)) }
275
+ if match
276
+ return "Path does not exist: #{path} — a close match exists: " \
277
+ "#{File.join(dir, match)} (different characters; use that exact path)."
278
+ end
45
279
 
46
- lines = File.readlines(path, chomp: true)
47
- total = lines.size
48
- offset_val = offset.to_i.clamp(0, total)
49
- limit_val = limit || MAX_LINES
280
+ if File.directory?(dir)
281
+ suggestions = did_you_mean(base, Dir.children(dir))
282
+ unless suggestions.empty?
283
+ return "Path does not exist: #{path} — did you mean: " \
284
+ "#{suggestions.map { |s| File.join(dir, s) }.join(", ")}?"
285
+ end
286
+ end
50
287
 
51
- selected = lines[offset_val, limit_val]
52
- truncated = selected.size < (total - offset_val)
288
+ "Path does not exist: #{path}"
289
+ end
53
290
 
54
- result = selected.each_with_index.map do |line, i|
55
- "#{offset_val + i + 1}: #{line}"
56
- end.join("\n")
291
+ def filename_variants(name)
292
+ variants = []
293
+ [["\u202F", " "], ["\u00A0", " "]].each do |special, plain| # narrow NBSP / NBSP
294
+ variants << name.gsub(special, plain) if name.include?(special)
295
+ variants << name.gsub(plain, special) if name.include?(plain)
296
+ end
297
+ variants << name.unicode_normalize(:nfd) unless name.unicode_normalized?(:nfd)
298
+ variants << name.unicode_normalize(:nfc) unless name.unicode_normalized?(:nfc)
299
+ [["'", "\u2018"], ["'", "\u2019"], ['"', "\u201C"], ['"', "\u201D"]].each do |straight, curly|
300
+ variants << name.gsub(straight, curly) if name.include?(straight)
301
+ variants << name.gsub(curly, straight) if name.include?(curly)
302
+ end
303
+ variants.uniq.reject { |v| v == name }
304
+ end
305
+
306
+ def did_you_mean(name, siblings)
307
+ others = siblings.reject { |s| s == name }
308
+ if name.length >= 3
309
+ sub = others.select { |s| s.include?(name) || name.include?(s) }
310
+ return sub.sort_by(&:length).first(3) unless sub.empty?
311
+ end
312
+ others
313
+ .select { |s| levenshtein(name, s) <= 2 }
314
+ .sort_by { |s| levenshtein(name, s) }
315
+ .first(3)
316
+ end
57
317
 
58
- result << "\n... (#{total - offset_val - selected.size} more lines)" if truncated
318
+ # Bounded Wagner–Fischer; cheap because we bail on length gap > max.
319
+ def levenshtein(a, b, max: 2)
320
+ return 0 if a == b
321
+ return max + 1 if (a.length - b.length).abs > max
59
322
 
60
- Ask::Result.ok(data: result, metadata: {
61
- total_lines: total,
62
- start_line: offset_val + 1,
63
- end_line: offset_val + selected.size,
64
- truncated: truncated
65
- })
323
+ row = (0..b.length).to_a
324
+ a.each_char do |ac|
325
+ prev = row[0]
326
+ row[0] = prev + 1
327
+ b.each_char.with_index do |bc, j|
328
+ cur = row[j + 1]
329
+ row[j + 1] = [cur + 1, row[j] + 1, prev + (ac == bc ? 0 : 1)].min
330
+ prev = cur
331
+ end
332
+ end
333
+ row[b.length]
66
334
  end
67
335
  end
68
336
  end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Tools
5
5
  module Shell
6
- VERSION = "0.4.0"
6
+ VERSION = "0.5.0"
7
7
  end
8
8
  end
9
9
  end
@@ -24,6 +24,19 @@ module Ask
24
24
  )
25
25
  end
26
26
 
27
+ # Never destroy what the model never saw: if Read only showed part of
28
+ # this file and it hasn't changed since, the rest may hold something
29
+ # the overwrite would silently erase.
30
+ if Shell::FileLedger.partially_seen?(path)
31
+ entry = Shell::FileLedger.entry_for(path)
32
+ seen = entry.lines_seen
33
+ return Ask::Result.error(
34
+ message: "Refusing to overwrite #{path}: only part of the file has been read " \
35
+ "(lines #{seen.first + 1}–#{seen.last} shown, more lines exist). " \
36
+ "Re-read the full file first."
37
+ )
38
+ end
39
+
27
40
  dir = File.dirname(path)
28
41
  FileUtils.mkdir_p(dir) unless File.directory?(dir)
29
42
 
@@ -4,6 +4,7 @@ require_relative "shell/version"
4
4
  require "ask/tools/tool"
5
5
  require_relative "shell/bash"
6
6
  require_relative "shell/read"
7
+ require_relative "shell/file_ledger"
7
8
  require_relative "shell/write"
8
9
  require_relative "shell/edit"
9
10
  require_relative "shell/glob"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-tools-shell
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -96,6 +96,7 @@ files:
96
96
  - lib/ask/tools/shell/bash.rb
97
97
  - lib/ask/tools/shell/code.rb
98
98
  - lib/ask/tools/shell/edit.rb
99
+ - lib/ask/tools/shell/file_ledger.rb
99
100
  - lib/ask/tools/shell/file_mutation_queue.rb
100
101
  - lib/ask/tools/shell/glob.rb
101
102
  - lib/ask/tools/shell/grep.rb