Class: Ask::Tools::Read

Inherits:
Ask::Tool
  • Object
show all
Defined in:
lib/ask/tools/shell/read.rb

Overview

Read file contents with line numbers, or list directory contents.

Engineered for token budgets. Three ceilings stop the three shapes of hostile file — the long file (line window), the wide file (byte budget), the minified bundle (per-line clamp):

max_lines      = 2000 lines  — the window
byte_budget    = 128 KB      — chars of output returned
max_line_chars = 2000        — per-line clamp

Truncation is a fact, not an error: reads that stop short return ok with a precomputed resume offset, so the model never does pagination arithmetic and never treats a fact about the world as a failure.

The other decisions that make a read cheap instead of expensive:

- strict offset/limit repair (never silently mangle "2abc" into 2)
- device blocklist — /dev/zero would hang a read forever
- filename repair: NFD/NFC, narrow NBSP, curly quotes, did-you-mean
- a self-expiring dedup stub for unchanged re-reads (consumed on use,
complete reads only, kill-switchable)
- a partial-view ledger that Write consults before overwriting

Constant Summary collapse

DEFAULT_MAX_LINES =
2000
DEFAULT_BYTE_BUDGET =
128_000
DEFAULT_MAX_LINE_CHARS =
2000
DEVICE_PATHS =

Device files that never end or block forever — refused by name before any I/O, so a read can never hang on them.

%w[
  /dev/zero /dev/random /dev/urandom
  /dev/stdin /dev/stdout /dev/stderr
].freeze
MIME_TYPES =
{
  ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
  ".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml",
  ".pdf" => "application/pdf", ".zip" => "application/zip",
  ".gz" => "application/gzip", ".mp3" => "audio/mpeg", ".mp4" => "video/mp4"
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeRead

Returns a new instance of Read.



58
59
60
61
62
63
64
65
# File 'lib/ask/tools/shell/read.rb', line 58

def initialize
  super
  @max_lines = DEFAULT_MAX_LINES
  @byte_budget = (ENV["ASK_TOOLS_SHELL_READ_BYTE_BUDGET"] || DEFAULT_BYTE_BUDGET).to_i
  @max_line_chars = DEFAULT_MAX_LINE_CHARS
  @dedup_enabled = ENV["ASK_TOOLS_SHELL_READ_NO_CACHE"] != "1"
  @dedup = {}
end

Instance Attribute Details

#byte_budgetObject

Returns the value of attribute byte_budget.



55
56
57
# File 'lib/ask/tools/shell/read.rb', line 55

def byte_budget
  @byte_budget
end

#dedup_enabledObject (readonly)

Returns the value of attribute dedup_enabled.



55
56
57
# File 'lib/ask/tools/shell/read.rb', line 55

def dedup_enabled
  @dedup_enabled
end

#max_line_charsObject

Returns the value of attribute max_line_chars.



55
56
57
# File 'lib/ask/tools/shell/read.rb', line 55

def max_line_chars
  @max_line_chars
end

#max_linesObject

Returns the value of attribute max_lines.



55
56
57
# File 'lib/ask/tools/shell/read.rb', line 55

def max_lines
  @max_lines
end

Instance Method Details

#execute(path:, offset: nil, limit: nil) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/ask/tools/shell/read.rb', line 67

def execute(path:, offset: nil, limit: nil)
  path = File.expand_path(path)

  if device_path?(path)
    return Ask::Result.error(message: "Refusing to read device file: #{path} (can block forever).")
  end

  unless File.exist?(path)
    return Ask::Result.error(message: missing_path_message(path))
  end

  if File.directory?(path)
    return directory_listing(path)
  end

  unless File.file?(path)
    return Ask::Result.error(message: "Not a file: #{path}")
  end

  offset = coerce_int("offset", offset)
  return Ask::Result.error(message: offset) if offset.is_a?(String)
  limit = coerce_int("limit", limit)
  return Ask::Result.error(message: limit) if limit.is_a?(String)

  offset ||= 0
  limit ||= @max_lines
  return Ask::Result.error(message: "Invalid offset: #{offset} (must be >= 0).") if offset.negative?
  return Ask::Result.error(message: "Invalid limit: #{limit} (must be >= 1).") if limit < 1

  special = sniff(path)
  return special if special

  read = read_lines(path, offset, limit)
  partial_view = read[:more] || read[:clamped].positive?

  if @dedup_enabled && !partial_view && read[:lines].any?
    key = [path, File.mtime(path).to_f, File.size(path), offset, limit]
    if @dedup.key?(key)
      @dedup.delete(key) # self-expiring: one stub, then real content again
      return Ask::Result.ok(
        data: "File unchanged since last read — content is already in context.",
        metadata: { dedup: true }
      )
    end
    @dedup[key] = true
  end

  Shell::FileLedger.record(path, partial: partial_view, lines_seen: [offset, offset + read[:lines].size])

  data, resume_offset = format_output(path, offset, read)

   = {
    total_lines: read[:more] || (read[:lines].empty? && read[:saw_any]) ? nil : offset + read[:lines].size,
    start_line: read[:lines].empty? ? nil : offset + 1,
    end_line: offset + read[:lines].size,
    truncated: read[:more],
    partial_view: partial_view,
    clamped_lines: read[:clamped],
    resume_offset: resume_offset
  }
  .delete(:resume_offset) unless read[:more]
  Ask::Result.ok(data: data, metadata: )
end