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 +4 -4
- data/CHANGELOG.md +37 -1
- data/README.md +15 -2
- data/lib/ask/tools/shell/edit.rb +4 -0
- data/lib/ask/tools/shell/file_ledger.rb +70 -0
- data/lib/ask/tools/shell/read.rb +298 -30
- data/lib/ask/tools/shell/version.rb +1 -1
- data/lib/ask/tools/shell/write.rb +13 -0
- data/lib/ask/tools/shell.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8805be0a64d1a42f4b0eae3600c81ce0e61565fa19aaf1fd835d36fbb6fa9b58
|
|
4
|
+
data.tar.gz: fe497b0c0604cc2c9a90cd02bc17c31ff8f46140ac88a67e9697b5b9cd15f7ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
[](https://badge.fury.io/rb/ask-tools-shell)
|
|
4
4
|
|
|
5
|
-
Shell, filesystem, and code execution tools for AI agents. Ships
|
|
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.
|
data/lib/ask/tools/shell/edit.rb
CHANGED
|
@@ -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
|
data/lib/ask/tools/shell/read.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
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:
|
|
75
|
+
return Ask::Result.error(message: missing_path_message(path))
|
|
25
76
|
end
|
|
26
77
|
|
|
27
78
|
if File.directory?(path)
|
|
28
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
288
|
+
"Path does not exist: #{path}"
|
|
289
|
+
end
|
|
53
290
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
@@ -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
|
|
data/lib/ask/tools/shell.rb
CHANGED
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
|
+
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
|