ask-tools-shell 0.3.4 → 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: 6c8152ae1dcc82318421fde8c5a890ada7a6d4f11435a6e642e0724a06bab80e
4
- data.tar.gz: ce7bb3ea38eb7514ae0f4e44e9cd4adfc1ebed5cf413cc9b424b74e17ce183a8
3
+ metadata.gz: 8805be0a64d1a42f4b0eae3600c81ce0e61565fa19aaf1fd835d36fbb6fa9b58
4
+ data.tar.gz: fe497b0c0604cc2c9a90cd02bc17c31ff8f46140ac88a67e9697b5b9cd15f7ee
5
5
  SHA512:
6
- metadata.gz: 284a6529ddae8a8099ab8d5af6d53fa2e3f53d97780c6ed9b94a26cb8eb6773500c12b1efb9f4087f3f415dc85b91b8724ecad2483ce511ca5c7e863d0dd00c8
7
- data.tar.gz: a1d656f3851f88949869dc9f4aa1591f79c2ae394fa065f4e8767a55af4c004f63f84df06ef88f11bbfec27c5919b7374cc278f8eb97245f93c02799143b8e6e
6
+ metadata.gz: defeb70d692dc1da34ad21300ed52dff8496df4fd28c3365b7724d9e3a6c113afbdd7816619cfa0f9017479d9af1a061fb0ef82c140110f0e46209df8829bdc6
7
+ data.tar.gz: e855c29b7b8ed782dab2fccb0764c3eebe4b5b5b7af039aff713fc746c31b3b70f77b1a47abc250a54b217a3bacba4642bef97aa3f409e5f52ee80043a8f0610
data/CHANGELOG.md CHANGED
@@ -1,3 +1,63 @@
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
+
37
+ ## [0.4.0] - 2026-08-05
38
+
39
+ ### Added
40
+ - **`Ask::Tools::Repl`** — evaluate Ruby code in a persistent session (the
41
+ RLM / recursive language model pattern). A long-lived plain-ruby kernel
42
+ subprocess keeps state across calls: locals, `require`s, and defined
43
+ methods survive between evaluations, so the model composes capabilities as
44
+ code against a working environment instead of re-bootstrapping each time.
45
+ - Framed newline-delimited JSON protocol over stdin/stdout with
46
+ request/response id matching; concurrent calls to a session serialize.
47
+ - Per-evaluation timeout kills the session (state is lost, kernel
48
+ respawns fresh on next call); idle sessions recycle after
49
+ `Repl.idle_timeout` (default 300s).
50
+ - Named sessions shared process-wide (`session:` param, default
51
+ `"default"`); `reset: true` discards state; `Repl.close_session` /
52
+ `Repl.close_all` manage lifetimes; `at_exit` cleanup.
53
+ - Sessions are isolated subprocesses — a crash in one session can't take
54
+ others down, and a dead session is respawned transparently with one
55
+ retry.
56
+ - Kernel spawn strips bundler env vars (RUBYOPT, GEM_HOME, etc.) so the
57
+ session is plain ruby and sees globally installed gems — consistent
58
+ with the one-shot `Code` tool.
59
+ - Registered `repl` in `Shell::TOOLS` / `Shell.all`.
60
+
1
61
  ## [0.3.4] - 2026-06-25
2
62
 
3
63
  ### Fixed
data/README.md CHANGED
@@ -1,171 +1,72 @@
1
1
  # ask-tools-shell
2
2
 
3
- Shell, filesystem, and code execution tools for AI agents. Part of the ask-rb ecosystem.
3
+ [![Gem Version](https://badge.fury.io/rb/ask-tools-shell.svg)](https://badge.fury.io/rb/ask-tools-shell)
4
4
 
5
- Provides **Bash**, **Read**, **Write**, **Edit**, **Glob**, **Grep**, and **Code** the execution tools every agent needs.
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
+
7
+ ## Installation
6
8
 
7
9
  ```ruby
8
10
  gem "ask-tools-shell"
9
11
  ```
10
12
 
11
- ## Dependencies
12
-
13
- - **ask-tools** ~> 0.1 (provides `Ask::Tool` base class and `Ask::Result`)
14
-
15
- ---
16
-
17
13
  ## Quick Start
18
14
 
19
15
  ```ruby
20
16
  require "ask-tools-shell"
21
17
 
22
- # List all available tools
23
18
  Ask::Tools::Shell.all.map(&:name)
24
- # => ["bash", "read", "write", "edit", "glob", "grep", "code"]
19
+ # => ["bash", "read", "write", "edit", "glob", "grep", "code", "repl", "apply_patch"]
25
20
 
26
- # Use a tool standalone
27
21
  result = Ask::Tools::Bash.new.call(command: "echo hello")
28
- result.ok? # => true
29
- result.output[:stdout] # => "hello\n"
30
- result.output[:exit_code] # => 0
22
+ result.ok? # => true
23
+ result.output[:stdout] # => "hello\n"
24
+ result.output[:exit_code] # => 0
31
25
  ```
32
26
 
33
- ---
27
+ ## The tools
34
28
 
35
- ## Tools
29
+ | Tool | Parameters | Notes |
30
+ |---|---|---|
31
+ | `Ask::Tools::Bash` | `command`, `timeout` (30), `workdir` | Runs via `Ask::Sandbox.provider`; returns `{ stdout, stderr, exit_code, timed_out }`, output truncated to 100KB |
32
+ | `Ask::Tools::Read` | `path`, `offset` (0-indexed), `limit` (2000) | Reads files with line numbers, or lists a directory |
33
+ | `Ask::Tools::Write` | `path`, `content` | Creates parent directories automatically |
34
+ | `Ask::Tools::Edit` | `path`, `old_string`, `new_string`, `replace_all` | Exact string replacement |
35
+ | `Ask::Tools::Glob` | `pattern`, `path` | Up to 1000 files, newest first |
36
+ | `Ask::Tools::Grep` | `pattern`, `path`, `include` | Regex search; 100 matches max, skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log` |
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 |
39
+ | `Ask::Tools::ApplyPatch` | `patchText` | Applies unified diffs inside a `*** Begin Patch` / `*** End Patch` envelope (Add File, Update File, Delete File sections) |
36
40
 
37
- ### `Ask::Tools::Bash`
41
+ ## Sandboxed execution
38
42
 
39
- Execute shell commands in a sandboxed temp directory.
40
-
41
- | Param | Type | Required | Default | Description |
42
- |-------|------|----------|---------|-------------|
43
- | `command` | `string` | Yes | — | The bash command to execute |
44
- | `timeout` | `integer` | No | 30 | Timeout in seconds |
45
- | `workdir` | `string` | No | temp dir | Working directory |
46
-
47
- Returns `{ stdout, stderr, exit_code, timed_out }`. Output truncated to 100KB. Process killed on timeout.
43
+ `Bash` and `Code` run through `Ask::Sandbox.provider` (ask-sandbox-providers), which defaults to the Local provider. Switch to stronger isolation:
48
44
 
49
45
  ```ruby
50
- Ask::Tools::Bash.new.call(command: "ls -la", timeout: 10)
46
+ Ask::Sandbox.provider = :docker
51
47
  ```
52
48
 
53
- ### `Ask::Tools::Read`
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.
54
51
 
55
- Read file contents with line numbers, or list directory entries.
52
+ ### Code vs Repl
56
53
 
57
- | Param | Type | Required | Default | Description |
58
- |-------|------|----------|---------|-------------|
59
- | `path` | `string` | Yes | — | Absolute path to file or directory |
60
- | `offset` | `integer` | No | 0 | Starting line number (0-indexed) |
61
- | `limit` | `integer` | No | 2000 | Maximum lines to read |
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.
62
56
 
63
- ```ruby
64
- Ask::Tools::Read.new.call(path: "/etc/hosts")
65
- Ask::Tools::Read.new.call(path: "large.log", offset: 100, limit: 50)
66
- ```
67
-
68
- ### `Ask::Tools::Write`
69
-
70
- Write content to a file. Creates parent directories automatically.
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.
71
60
 
72
- | Param | Type | Required | Default | Description |
73
- |-------|------|----------|---------|-------------|
74
- | `path` | `string` | Yes | — | Absolute path to write to |
75
- | `content` | `string` | Yes | — | File content (max 500KB) |
76
-
77
- ```ruby
78
- Ask::Tools::Write.new.call(path: "/tmp/hello.txt", content: "Hello, World!")
79
- ```
61
+ ## Full documentation
80
62
 
81
- ### `Ask::Tools::Edit`
82
-
83
- Replace exact text in a file. Uses exact string matching.
84
-
85
- | Param | Type | Required | Default | Description |
86
- |-------|------|----------|---------|-------------|
87
- | `path` | `string` | Yes | — | Absolute path to the file |
88
- | `old_string` | `string` | Yes | — | Exact text to replace |
89
- | `new_string` | `string` | Yes | — | Replacement text |
90
- | `replace_all` | `boolean` | No | false | Replace all occurrences |
91
-
92
- ```ruby
93
- Ask::Tools::Edit.new.call(path: "file.rb", old_string: "foo", new_string: "bar")
94
- Ask::Tools::Edit.new.call(path: "file.rb", old_string: "x", new_string: "y", replace_all: true)
95
- ```
96
-
97
- ### `Ask::Tools::Glob`
98
-
99
- Find files matching a glob pattern, sorted by modification time (newest first).
100
-
101
- | Param | Type | Required | Default | Description |
102
- |-------|------|----------|---------|-------------|
103
- | `pattern` | `string` | Yes | — | Glob pattern (e.g. `**/*.rb`) |
104
- | `path` | `string` | No | current dir | Base directory |
105
-
106
- Max 1000 results.
107
-
108
- ```ruby
109
- Ask::Tools::Glob.new.call(pattern: "**/*.rb", path: "/path/to/project")
110
- ```
111
-
112
- ### `Ask::Tools::Grep`
113
-
114
- Search file contents using a regex pattern.
115
-
116
- | Param | Type | Required | Default | Description |
117
- |-------|------|----------|---------|-------------|
118
- | `pattern` | `string` | Yes | — | Regex pattern to search for |
119
- | `path` | `string` | No | current dir | Directory to search |
120
- | `include` | `string` | No | `**/*` | File pattern filter (e.g. `*.rb`) |
121
-
122
- Max 100 matches. Line content capped at 500 chars. Skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log`.
123
-
124
- ```ruby
125
- Ask::Tools::Grep.new.call(pattern: "TODO", path: ".", include: "*.rb")
126
- ```
127
-
128
- ### `Ask::Tools::Code`
129
-
130
- Write and execute Ruby code in a subprocess. Uses gems already available in the environment.
131
-
132
- | Param | Type | Required | Default | Description |
133
- |-------|------|----------|---------|-------------|
134
- | `code` | `string` | Yes | — | Ruby source code to execute |
135
-
136
- Returns `{ stdout, stderr, exit_code }`. Output truncated to 100KB.
137
-
138
- ```ruby
139
- Ask::Tools::Code.new.call(code: <<~RUBY)
140
- puts "Hello from Ruby!"
141
- result = 2 + 2
142
- puts "2 + 2 = #{result}"
143
- RUBY
144
- ```
145
-
146
- ---
147
-
148
- ## Using Tools with an Agent
149
-
150
- ```ruby
151
- require "ask-tools-shell"
152
-
153
- # All tools
154
- tools = Ask::Tools::Shell.all
155
-
156
- # Find by name
157
- bash = Ask::Tools["bash"]
158
- bash.call(command: "date")
159
- ```
160
-
161
- ---
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.
162
64
 
163
65
  ## Development
164
66
 
165
- ```bash
67
+ ```
166
68
  bundle install
167
69
  bundle exec rake test
168
- gem build ask-tools-shell.gemspec
169
70
  ```
170
71
 
171
72
  ## License
@@ -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
@@ -34,7 +34,13 @@ module Ask
34
34
 
35
35
  Dir.glob(File.join(base, glob)).each do |file|
36
36
  next unless File.file?(file)
37
- next if EXCLUDE_DIRS.any? { |d| file.include?("/#{d}/") }
37
+
38
+ # Exclude by path segment relative to the search root, not by
39
+ # absolute-path substring: the old check matched any path
40
+ # containing "/tmp/" (e.g. a search rooted under the system tmp
41
+ # dir on Linux), excluding every file.
42
+ relative = file.delete_prefix("#{base}/")
43
+ next if relative.split("/").any? { |seg| EXCLUDE_DIRS.include?(seg) }
38
44
 
39
45
  begin
40
46
  File.readlines(file).each_with_index do |line, i|
@@ -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
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Persistent Ruby kernel for Ask::Tools::Shell::Repl.
4
+ #
5
+ # A standalone plain-Ruby script run as a long-lived subprocess. Reads framed
6
+ # JSON requests from stdin, evaluates each snippet into a persistent binding,
7
+ # and writes a framed JSON response to stdout.
8
+ #
9
+ # Protocol (newline-delimited JSON):
10
+ # request: {"id": 1, "code": "1 + 1"}
11
+ # response: {"id": 1, "result": "2", "stdout": "", "stderr": "", "error": null}
12
+ #
13
+ # The binding persists across requests, so locals, requires, and defined
14
+ # methods survive between calls. stdout/stderr are captured per evaluation.
15
+
16
+ require "json"
17
+ require "stringio"
18
+
19
+ # Die immediately on TERM (sent by the parent to shut the session down).
20
+ # exit! bypasses the rescue Exception below, which would otherwise swallow
21
+ # the SignalException raised mid-eval and keep the kernel alive.
22
+ trap("TERM") { exit!(0) }
23
+
24
+ def read_frame
25
+ line = $stdin.gets
26
+ return nil if line.nil?
27
+
28
+ JSON.parse(line)
29
+ rescue JSON::ParserError
30
+ { "error" => "invalid request frame" }
31
+ end
32
+
33
+ def write_frame(frame)
34
+ $stdout.puts(JSON.generate(frame))
35
+ $stdout.flush
36
+ end
37
+
38
+ def capture_output
39
+ old_out = $stdout
40
+ old_err = $stderr
41
+ out = StringIO.new
42
+ err = StringIO.new
43
+ $stdout = out
44
+ $stderr = err
45
+ yield
46
+ [out.string, err.string]
47
+ ensure
48
+ $stdout = old_out
49
+ $stderr = old_err
50
+ end
51
+
52
+ def safe_inspect(value)
53
+ value.inspect
54
+ rescue StandardError
55
+ "#<#{value.class}>"
56
+ end
57
+
58
+ binding = TOPLEVEL_BINDING
59
+
60
+ loop do
61
+ request = read_frame
62
+ break if request.nil?
63
+
64
+ id = request["id"]
65
+ code = request["code"].to_s
66
+
67
+ result = nil
68
+ stdout = ""
69
+ stderr = ""
70
+ error = nil
71
+
72
+ begin
73
+ stdout, stderr = capture_output do
74
+ result = binding.eval(code)
75
+ end
76
+ rescue Exception => e # rubocop:disable Lint/RescueException
77
+ error = "#{e.class}: #{e.message}"
78
+ trace = (e.backtrace || []).first(10).join("\n")
79
+ stderr += trace
80
+ end
81
+
82
+ write_frame(
83
+ "id" => id,
84
+ "result" => safe_inspect(result),
85
+ "stdout" => stdout,
86
+ "stderr" => stderr,
87
+ "error" => error
88
+ )
89
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "io/wait"
6
+
7
+ module Ask
8
+ module Tools
9
+ # Evaluate Ruby code in a persistent, long-lived subprocess.
10
+ #
11
+ # Unlike {Code} (which spawns a fresh `ruby -e` per call), Repl keeps a
12
+ # kernel process alive across calls and evaluates every snippet into the
13
+ # same binding — so state (locals, requires, defined methods) survives
14
+ # between calls. This is the RLM (recursive language model) pattern: the
15
+ # model composes capabilities as code against a persistent environment.
16
+ #
17
+ # @example
18
+ # repl = Ask::Tools::Repl.new
19
+ # repl.call(code: 'require "json"; data = JSON.parse(%q({"a": 1}))')
20
+ # repl.call(code: "data['a'] + 1") # => 2 — `data` still exists
21
+ # repl.call(code: "def double(x); x * 2; end")
22
+ # repl.call(code: "double(21)") # => 42
23
+ #
24
+ # Sessions are named and shared process-wide: calling with the same
25
+ # +session+ name from any tool instance reaches the same kernel. A
26
+ # session is closed by +reset: true+, {Repl.close_session}, or after
27
+ # {Repl.idle_timeout} seconds without use.
28
+ #
29
+ # @note The kernel runs with the caller's permissions (like {Code}).
30
+ # It is a durable control environment, not a security sandbox.
31
+ class Repl < Ask::Tool
32
+ description "Evaluate Ruby code in a persistent session. " \
33
+ "State (variables, requires, defined methods) survives " \
34
+ "across calls in the same session. " \
35
+ "Use sessions to keep working context alive."
36
+
37
+ param :code, type: :string, desc: "Ruby source code to evaluate", required: true
38
+ param :session, type: :string, desc: "Session name; state persists per name", required: false
39
+ param :reset, type: :boolean, desc: "Discard session state before evaluating", required: false
40
+
41
+ # Timeout for a single evaluation, in seconds.
42
+ DEFAULT_EVAL_TIMEOUT = 30
43
+
44
+ # Sessions idle longer than this are closed on next access.
45
+ DEFAULT_IDLE_TIMEOUT = 300
46
+
47
+ # Env vars that would drag the parent's bundler context into the
48
+ # kernel subprocess (RUBYOPT=-rbundler/setup restricts $LOAD_PATH to
49
+ # the parent's Gemfile). Nil overrides remove them at spawn so the
50
+ # session is plain ruby, like the one-shot Code tool's sandbox.
51
+ BUNDLER_ENV = %w[
52
+ RUBYOPT RUBYLIB BASH_ENV GEM_PATH GEM_HOME
53
+ BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH BUNDLER_SETUP
54
+ BUNDLER_VERSION BUNDLE_WITHOUT BUNDLE_FROZEN BUNDLE_DEPLOYMENT
55
+ BUNDLE_LOCKFILE BUNDLE_APP_CONFIG
56
+ ].freeze
57
+
58
+ class << self
59
+ # @return [Integer] seconds a session may sit unused before it is
60
+ # closed on next access
61
+ attr_accessor :idle_timeout
62
+
63
+ # @return [Integer] seconds a single evaluation may take
64
+ attr_accessor :eval_timeout
65
+ end
66
+
67
+ self.idle_timeout = DEFAULT_IDLE_TIMEOUT
68
+ self.eval_timeout = DEFAULT_EVAL_TIMEOUT
69
+
70
+ @registry = {}
71
+ @registry_mutex = Monitor.new
72
+
73
+ class << self
74
+ # The kernel for +session+, spawning one if needed (or after reset).
75
+ #
76
+ # @param session [String]
77
+ # @param reset [Boolean] discard existing session state
78
+ # @return [Kernel]
79
+ def kernel_for(session, reset: false)
80
+ @registry_mutex.synchronize do
81
+ close_session(session) if reset
82
+ kernel = @registry[session]
83
+ if kernel.nil? || kernel.dead?
84
+ kernel = Kernel.new(session: session)
85
+ @registry[session] = kernel
86
+ elsif kernel.idle_seconds > idle_timeout
87
+ kernel.close
88
+ kernel = Kernel.new(session: session)
89
+ @registry[session] = kernel
90
+ end
91
+ kernel
92
+ end
93
+ end
94
+
95
+ # Close and forget a session's kernel.
96
+ #
97
+ # @param session [String]
98
+ # @return [void]
99
+ def close_session(session)
100
+ @registry_mutex.synchronize do
101
+ kernel = @registry.delete(session)
102
+ kernel&.close
103
+ end
104
+ nil
105
+ end
106
+
107
+ # Close every session kernel. Called at exit; call it explicitly to
108
+ # free subprocesses early.
109
+ #
110
+ # @return [void]
111
+ def close_all
112
+ @registry_mutex.synchronize do
113
+ @registry.each_value(&:close)
114
+ @registry.clear
115
+ end
116
+ nil
117
+ end
118
+
119
+ # @return [Array<String>] active session names
120
+ def sessions
121
+ @registry_mutex.synchronize { @registry.keys }
122
+ end
123
+ end
124
+
125
+ at_exit { Repl.close_all }
126
+
127
+ def execute(code:, session: "default", reset: false)
128
+ build_result(eval_code(session, code, reset: reset), session)
129
+ rescue Kernel::TimeoutError => e
130
+ Ask::Result.error(message: "REPL session '#{session}' timed out (#{e.timeout}s); session state was lost")
131
+ rescue Kernel::DeadError => e
132
+ # The session died (crash, external kill, closed stdin). Respawn a
133
+ # fresh kernel and retry once; only give up if it dies again.
134
+ begin
135
+ build_result(eval_code(session, code), session)
136
+ rescue Kernel::DeadError
137
+ Ask::Result.error(message: "REPL session '#{session}' died repeatedly: #{e.message}")
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def eval_code(session, code, reset: false)
144
+ Repl.kernel_for(session, reset: reset).eval(code, timeout: Repl.eval_timeout)
145
+ end
146
+
147
+ def build_result(result, session)
148
+ if result["error"]
149
+ Ask::Result.error(
150
+ message: result["error"],
151
+ metadata: { session: session, stdout: result["stdout"], stderr: result["stderr"] }
152
+ )
153
+ else
154
+ Ask::Result.ok(data: {
155
+ result: result["result"],
156
+ stdout: result["stdout"],
157
+ stderr: result["stderr"],
158
+ session: session
159
+ })
160
+ end
161
+ end
162
+
163
+ # A single long-lived Ruby subprocess executing the kernel script.
164
+ #
165
+ # Communicates over newline-delimited JSON on stdin/stdout. One eval
166
+ # at a time per kernel; concurrent calls serialize on an internal
167
+ # monitor.
168
+ class Kernel
169
+ class Error < StandardError; end
170
+ class TimeoutError < Error
171
+ attr_reader :timeout
172
+
173
+ def initialize(timeout)
174
+ @timeout = timeout
175
+ super("evaluation exceeded #{timeout}s")
176
+ end
177
+ end
178
+ class DeadError < Error; end
179
+
180
+ # @return [String] session name this kernel belongs to
181
+ attr_reader :session
182
+
183
+ # @return [Time] last time an evaluation completed
184
+ attr_reader :last_used
185
+
186
+ def initialize(session:, ruby: "ruby")
187
+ @session = session
188
+ @script = File.expand_path("repl/kernel_script.rb", __dir__)
189
+ @monitor = Monitor.new
190
+ @last_used = Time.now
191
+ @next_id = 0
192
+ spawn_process(ruby)
193
+ end
194
+
195
+ # @return [Float] seconds since the last evaluation
196
+ def idle_seconds
197
+ Time.now - @last_used
198
+ end
199
+
200
+ # @return [Boolean] whether the kernel process is gone. Reaps the
201
+ # child if it already exited (zombies count as dead).
202
+ def dead?
203
+ return true if @closed
204
+ return true unless @pid
205
+
206
+ _, status = Process.waitpid(@pid, Process::WNOHANG)
207
+ if status.nil?
208
+ false
209
+ else
210
+ @pid = nil
211
+ true
212
+ end
213
+ rescue Errno::ECHILD, Errno::ESRCH, Errno::EINTR
214
+ @pid = nil
215
+ true
216
+ end
217
+
218
+ # Evaluate +code+ in the persistent binding.
219
+ #
220
+ # @param code [String]
221
+ # @param timeout [Integer] max seconds for this evaluation
222
+ # @return [Hash] {"result" => String, "stdout" => String,
223
+ # "stderr" => String, "error" => String or nil}
224
+ # @raise [TimeoutError] evaluation exceeded +timeout+; the kernel
225
+ # was killed and session state lost
226
+ # @raise [DeadError] the kernel process died
227
+ def eval(code, timeout: Repl.eval_timeout)
228
+ @monitor.synchronize do
229
+ raise DeadError, "process not running" if dead?
230
+
231
+ id = (@next_id += 1)
232
+ write_frame("id" => id, "code" => code)
233
+ response = read_frame(id, timeout)
234
+ @last_used = Time.now
235
+ response
236
+ end
237
+ end
238
+
239
+ # Terminate the kernel process and close pipes. TERM is normally
240
+ # enough (the kernel script exits on it); KILL is the fallback for
241
+ # user code that overrode the trap or wedged the VM.
242
+ #
243
+ # @return [void]
244
+ def close
245
+ @monitor.synchronize do
246
+ return if @closed
247
+
248
+ @closed = true
249
+ if @pid
250
+ begin
251
+ Process.kill("TERM", @pid)
252
+ rescue Errno::ESRCH, Errno::ECHILD
253
+ @pid = nil
254
+ end
255
+ wait_for_exit(2)
256
+ if @pid
257
+ begin
258
+ Process.kill("KILL", @pid)
259
+ rescue Errno::ESRCH, Errno::ECHILD
260
+ @pid = nil
261
+ end
262
+ wait_for_exit(1)
263
+ end
264
+ end
265
+ @in_w.close unless @in_w.closed?
266
+ @out_r.close unless @out_r.closed?
267
+ @err_r.close unless @err_r.closed?
268
+ end
269
+ nil
270
+ end
271
+
272
+ private
273
+
274
+ # Poll until the child exits or +seconds+ elapse. Reaps the child
275
+ # when it does. Avoids blocking +Process.wait+ which Timeout cannot
276
+ # always interrupt.
277
+ #
278
+ # @param seconds [Numeric]
279
+ # @return [void]
280
+ def wait_for_exit(seconds)
281
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
282
+ loop do
283
+ _, status = Process.waitpid(@pid, Process::WNOHANG)
284
+ return if status
285
+
286
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
287
+
288
+ sleep 0.01
289
+ end
290
+ rescue Errno::ECHILD, Errno::ESRCH, Errno::EINTR
291
+ @pid = nil
292
+ end
293
+
294
+ def spawn_process(ruby)
295
+ @in_r, @in_w = IO.pipe
296
+ @out_r, @out_w = IO.pipe
297
+ @err_r, @err_w = IO.pipe
298
+
299
+ # The kernel is a plain ruby environment: remove bundler plumbing
300
+ # inherited from the parent (RUBYOPT=-rbundler/setup etc.) so the
301
+ # session sees globally installed gems, not the parent's Gemfile
302
+ # subset. Note: Process.spawn *merges* its env hash with the
303
+ # parent environment — only explicit nil values delete keys.
304
+ env = BUNDLER_ENV.to_h { |key| [key, nil] }
305
+
306
+ @pid = Process.spawn(
307
+ env, ruby, @script,
308
+ in: @in_r, out: @out_w, err: @err_w
309
+ )
310
+ @in_r.close
311
+ @out_w.close
312
+ @err_w.close
313
+ rescue StandardError
314
+ close
315
+ raise
316
+ end
317
+
318
+ def write_frame(frame)
319
+ @in_w.write(JSON.generate(frame) + "\n")
320
+ @in_w.flush
321
+ rescue Errno::EPIPE, IOError => e
322
+ raise DeadError, e.message
323
+ end
324
+
325
+ def read_frame(expected_id, timeout)
326
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
327
+
328
+ loop do
329
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
330
+ if remaining <= 0
331
+ close
332
+ raise TimeoutError, timeout
333
+ end
334
+
335
+ ready = @out_r.wait_readable(remaining)
336
+ unless ready
337
+ close
338
+ raise TimeoutError, timeout
339
+ end
340
+
341
+ line = @out_r.gets
342
+ if line.nil?
343
+ err = @err_r.read
344
+ close
345
+ raise DeadError, "kernel exited unexpectedly#{err.empty? ? "" : ": #{err.strip}"}"
346
+ end
347
+
348
+ frame = JSON.parse(line)
349
+ return frame if frame["id"] == expected_id
350
+ rescue JSON::ParserError
351
+ next
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Tools
5
5
  module Shell
6
- VERSION = "0.3.4"
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,17 +4,19 @@ 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"
10
11
  require_relative "shell/grep"
11
12
  require_relative "shell/code"
12
13
  require_relative "shell/apply_patch"
14
+ require_relative "shell/repl"
13
15
 
14
16
  module Ask
15
17
  module Tools
16
18
  module Shell
17
- TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch].freeze
19
+ TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch, Repl].freeze
18
20
 
19
21
  def self.all
20
22
  TOOLS.map(&:new)
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.3.4
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -96,10 +96,13 @@ 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
102
103
  - lib/ask/tools/shell/read.rb
104
+ - lib/ask/tools/shell/repl.rb
105
+ - lib/ask/tools/shell/repl/kernel_script.rb
103
106
  - lib/ask/tools/shell/version.rb
104
107
  - lib/ask/tools/shell/write.rb
105
108
  homepage: https://github.com/ask-rb/ask-tools-shell