Class: Pikuri::Workspace::Read

Inherits:
Tool
  • Object
show all
Defined in:
lib/pikuri/workspace/read.rb

Overview

The read tool as a Tool subclass: Read.new(workspace: ws) produces a tool whose Tool#to_ruby_llm_tool wiring is identical to any bundled tool's. Workspace is captured by the execute closure.

Output format

cat-n: each line "%6d\t%s" (6-col line number, tab, content). Chosen for training-data familiarity — cat -n is everywhere, so even small local models recognize it; opencode's "<n>: " saves tokens but trades familiarity, pi omits numbers (cheapest, but no range citation or Edit boundaries). The numbering and DESCRIPTION's read-a-larger-window nudge were confirmed as a pair by an outside-model review (Grok 4.6, 2026-08) — don't trade either back for tokens.

Truncation rules

Line/byte windowing is delegated to FileType.read_as_text_paged, which returns a Extractor::Page this tool renders (same windower backs VectorDb::Tools::Read). Two limits, first to fire wins: a line limit (DEFAULT_LIMIT, overridable via limit) and a byte cap (MAX_BYTES, not a parameter, bypassable by paging via offset). Lines over MAX_LINE_LENGTH are truncated with LINE_TRUNCATION_MARKER. (The constants alias the PAGE_* ones on Extractor.)

PDF and other extracted formats

Which formats read as text is the Extractor registry's business: with pikuri-pdf registered, PDFs are claimed by %PDF- ahead of the binary refusal and extracted with one "--- Page N ---" header per page, lazily where the format allows (a 500-page PDF's first window parses only its pages). PDF line numbers are for citation only — PDFs aren't editable. No extractable text (scanned/empty) → an LLM-actionable hint; encrypted/malformed → "Error: ...". No OCR.

Image attachments

PNG/JPEG/GIF/WebP (magic-byte detected) route to Read.format_image ahead of the binary sniff, returning a RubyLLM::Content: a metadata note ("Read image: …") the model cites, plus the file as an attachment the model looks at. +offset+/+limit+ are ignored (no line paging). Over MAX_IMAGE_BYTES"Error: image too large…" rather than a payload the provider rejects; no auto-resize (see ideas/coding-agent.md). Vision capability is not checked — a non-vision model's provider error is something the LLM reacts to, cheaper than tracking model metadata.

Refusals (all as "Error: ...")

Path outside the workspace (Filesystem::Error), not found, EACCES, a directory (refused, but the refusal carries the directory's own entries — see Read.directory_refusal), image over MAX_IMAGE_BYTES, binary content (nothing in the registry claims it — the FileType.binary? heuristic catches archives/artifacts with no extension list), offset past EOF.

Sharing: P_one_agent — every successful read marks the path on its Workspace, which is the read-before-edit gate's state, so a shared instance would let one agent's read unlock another's Edit. with_workspace rebinds a copy when a sub-agent needs a different tree.

Constant Summary collapse

DEFAULT_LIMIT =

Returns default value of the limit parameter (lines per call).

Returns:

  • (Integer)

    default value of the limit parameter (lines per call).

Pikuri::Extractor::PAGE_DEFAULT_LIMIT
MAX_LINE_LENGTH =

Returns per-line character cap; longer lines are truncated with LINE_TRUNCATION_MARKER.

Returns:

Pikuri::Extractor::PAGE_MAX_LINE_LENGTH
LINE_TRUNCATION_MARKER =

Returns suffix appended to lines truncated by MAX_LINE_LENGTH.

Returns:

Pikuri::Extractor::PAGE_LINE_TRUNCATION_MARKER
MAX_BYTES =

Returns hard byte cap on input content per call (line bytes + the joining newline; rendered output is larger due to the "%6d\t" prefix).

Returns:

  • (Integer)

    hard byte cap on input content per call (line bytes + the joining newline; rendered output is larger due to the "%6d\t" prefix).

Pikuri::Extractor::PAGE_MAX_BYTES
MAX_BYTES_LABEL =

Returns human-readable MAX_BYTES for the continuation marker.

Returns:

  • (String)

    human-readable MAX_BYTES for the continuation marker.

"#{MAX_BYTES / 1024} KB"
MAX_IMAGE_BYTES =

Returns hard size cap on inline-attached images (Anthropic's per-image limit; same order on OpenAI/Gemini). Above it we refuse rather than encode a payload the provider would reject.

Returns:

  • (Integer)

    hard size cap on inline-attached images (Anthropic's per-image limit; same order on OpenAI/Gemini). Above it we refuse rather than encode a payload the provider would reject.

5 * 1024 * 1024
MAX_IMAGE_BYTES_LABEL =

Returns human-readable MAX_IMAGE_BYTES for refusal messages.

Returns:

"#{MAX_IMAGE_BYTES / (1024 * 1024)} MB"
DESCRIPTION =

Description shown to the LLM (opencode-shape). Per-parameter constraints live in the parameter descriptions.

Returns:

  • (String)
<<~DESC
  Read a file from the workspace and return its contents with line numbers.

  Usage:
  - Output is line-numbered in `cat -n` style so subsequent edits can reference exact line numbers.
  - Use `offset` and `limit` to page through large files; when the response ends in `Use offset=N to continue`, call again with that offset.
  - Lines longer than #{MAX_LINE_LENGTH} chars are truncated with a marker — use `grep` for content inside such files.
  - PDFs are text-extracted page-by-page with `--- Page N ---` markers in the output. Cite pages back to the user from those markers. PDFs cannot be modified with `edit`.
  - PNG / JPEG / GIF / WebP files are attached as images you can see directly, alongside a short text note with the path and size. Requires a vision-capable model; on a text-only model the provider will reject the call. Images cannot be modified with `edit`.
  - Other binary files (archives, compiled artifacts) are refused; this tool reads text otherwise.
  - If unsure of the path, use `glob` first to look up filenames.
  - Avoid tiny repeated slices — if you need more context, read a larger window.
DESC

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(workspace:) ⇒ Read

Parameters:

  • workspace (Workspace)

    captured for path resolution and read-ledger marking (+resolve_for_read+ / mark_read).



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/pikuri/workspace/read.rb', line 118

def initialize(workspace:)
  super(
    name: 'read',
    description: DESCRIPTION,
    parameters: Parameters.build { |p|
      p.required_string :path,
                        'Path to the file to read. Relative paths ' \
                        'resolve against the workspace root, e.g. ' \
                        '"lib/foo.rb" or "/abs/path/to/file.txt".'
      p.optional_integer :offset,
                         'Line number to start reading from (1-indexed). ' \
                         "Defaults to 1, e.g. 200."
      p.optional_integer :limit,
                         'Maximum number of lines to read. Defaults to ' \
                         "#{DEFAULT_LIMIT}, e.g. 500."
    },
    execute: ->(path:, offset: 1, limit: DEFAULT_LIMIT) {
      Read.read(workspace: workspace, path: path, offset: offset, limit: limit)
    },
    trifecta_legs: Tool::TrifectaLegs.new(
      private: workspace.filesystem.private?,
      untrusted: workspace.filesystem.trusted? ? :none : :hard,
      egress_payload_review: :no_egress
    )
  )
end

Class Method Details

.read(workspace:, path:, offset:, limit:) ⇒ String, RubyLLM::Content

Resolve path against workspace, refuse directories/binaries/missing files, return the cat-n slice or an "Error: ..." observation.

Parameters:

  • workspace (Workspace)
  • path (String)

    raw path from the LLM

  • offset (Integer)

    1-indexed start line

  • limit (Integer)

    maximum lines

Returns:

  • (String, RubyLLM::Content)

    text/PDF → String; a supported image → RubyLLM::Content with the file attached (see "Image attachments")



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/pikuri/workspace/read.rb', line 166

def self.read(workspace:, path:, offset:, limit:)
  return "Error: offset must be >= 1, got #{offset}" if offset < 1
  return "Error: limit must be >= 1, got #{limit}"   if limit < 1

  resolved = workspace.resolve_for_read(path)
  return "Error: file not found: #{path}" unless resolved.exist?
  return directory_refusal(workspace: workspace, path: path, resolved: resolved) if resolved.directory?

  mime = Pikuri::FileType.detect_mime(resolved)
  return format_image(path: path, resolved: resolved, mime: mime) if mime&.start_with?('image/')

  page = Pikuri::FileType.read_as_text_paged(
    resolved, offset: offset, limit: limit,
    max_bytes: MAX_BYTES, max_line_length: MAX_LINE_LENGTH
  )
  # Record the read so a later edit/overwrite clears the read-before-edit
  # gate. Any successful text/PDF read counts (even a partial window) —
  # the exact-bytes guard is Edit's +old_string+ match; the ledger only
  # attests "engaged via read".
  workspace.mark_read(resolved)
  render_page(page)
rescue Filesystem::Error => e
  "Error: #{e.message}"
rescue Errno::EACCES => e
  "Error: cannot read #{path}: #{e.message}"
rescue ArgumentError
  # Nothing in the Extractor registry claimed the content —
  # read_as_text_paged's binary refusal (directories and images
  # were already handled above).
  "Error: cannot read binary file: #{path}"
rescue RuntimeError => e
  # Extraction failure (malformed / unsupported PDF, ...)
  # surfaced by read_as_text_paged.
  "Error: #{e.message}"
end

Instance Method Details

#with_workspace(workspace) ⇒ Read

A new Pikuri::Workspace::Read bound to workspace. Used by SubAgent::SubAgentTool when a persona needs a fresh (temp) workspace, so paths resolve against the right root and reads land in that workspace's ledger.

Parameters:

Returns:



152
153
154
# File 'lib/pikuri/workspace/read.rb', line 152

def with_workspace(workspace)
  self.class.new(workspace: workspace)
end