Class: Pikuri::Workspace::Search::Grep

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

Overview

The grep tool — content search across the workspace via ripgrep. Grep.new(workspace: ws) produces a tool wired like any bundled tool's (workspace captured by the execute closure, no confirmer — read-only).

ripgrep dependency

Hard: Grep.check_binaries! runs in initialize and raises if rg isn't on PATH. No Ruby fallback — replicating rg's Rust-regex dialect, globs, and .gitignore parsing is a dead end. The failure message includes the install hint.

Argv

rg --line-number --color=never --no-heading --with-filename \
 --hidden --max-columns=2000 --max-columns-preview --sort=path \
 [-i] [--glob <g>] [--files-with-matches|--count-matches] \
 -- <pattern> <path-or-dot>
  • --no-heading + --with-filename → flat path:line:content rows even for a single-file search (rg suppresses the filename there by default).
  • --hidden → search dotfiles (still respects .gitignore).
  • --max-columns=2000 --max-columns-preview → rg truncates long lines server-side with a preview marker.
  • --sort=path → deterministic (single-threaded; fine under ~10k files).
  • The subprocess is always given an explicit path arg: Subprocess's popen2e gives the child a piped stdin, and rg's no-path heuristic would then search that (closed) stdin and match nothing. Output ./ prefixes (from path .) are stripped post-rg.

Output modes

content (default, path:line:content) · files_with_matches (paths) · count (+path:count+). Use files_with_matches to scope a broad search cheaply before paying for content. The cheap modes and the MAX_BYTES cap are one budget lever, confirmed as such by an outside-model review (Grok 4.6, 2026-08): collapsing to a +content+-only grep is a regression, not a simplification.

Credential paths

Pruned twice: rg --glob '!…' keeps rg out of the subtree, and every surviving output line whose path sits under a denied root is dropped — the guarantee if the pruning ever goes partial (see Utils.reject_denied_lines). Without it a single grep 'BEGIN PRIVATE KEY' returns what Read refuses to open.

Truncation / exit codes

Head-truncated to MAX_BYTES (head-only — grep tails carry less signal; opposite bias from Code::Bash), cut at a line boundary with a marker. The exit-2 error blob shares the ceiling (Grep.truncate_error — rg emits one diagnostic per unreadable path). Exit 0 → results with footer; 1 → "No matches"; 2"Error: ripgrep: ...".

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

Empty pattern; unknown output_mode; path outside the workspace (Filesystem::Error); nonexistent path; an oversized root (+/+ or the whole home dir). The oversized-root refusal doesn't dead-end: it hands back a one-level listing of the root's entries so the model can pick a subdir as path and retry with no extra call (see Utils.oversized_root_refusal).

Sharing: P_one_agent, as Glob — stateless in itself, bound to one agent's Workspace. Each call shells out to a fresh rg, so concurrent searches don't interact.

Constant Summary collapse

MAX_BYTES =

Returns hard byte cap on combined rg output. Same value as Read::MAX_BYTES.

Returns:

  • (Integer)

    hard byte cap on combined rg output. Same value as Read::MAX_BYTES.

50 * 1024
MAX_BYTES_LABEL =

Returns human-readable MAX_BYTES for the truncation marker.

Returns:

  • (String)

    human-readable MAX_BYTES for the truncation marker.

"#{MAX_BYTES / 1024} KB"
MAX_LINE_LENGTH =

Returns per-line cap passed to rg's --max-columns; long lines are truncated server-side with a preview marker.

Returns:

  • (Integer)

    per-line cap passed to rg's --max-columns; long lines are truncated server-side with a preview marker.

2000
OUTPUT_MODES =

Returns valid output_mode values.

Returns:

  • (Array<String>)

    valid output_mode values.

%w[content files_with_matches count].freeze
DEFAULT_OUTPUT_MODE =

Returns default output_mode.

Returns:

  • (String)

    default output_mode.

'content'
DESCRIPTION =

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

Returns:

  • (String)
<<~DESC
  Search file contents for a regex pattern across the workspace.

  Usage:
  - Wraps `ripgrep` — regex syntax is rg's Rust-regex dialect (mostly PCRE-compatible; no lookbehind).
  - Default search root is the workspace root; pass `path` to narrow to a file or subdirectory.
  - Respects `.gitignore` by default; set `include_gitignored: true` to also search ignored files.
  - Use `files_with_matches` first to scope a broad search, then `content` (or `read`) to investigate — saves tokens.
  - Output is truncated to #{MAX_BYTES_LABEL}; refine the pattern or narrow `path` if the response ends in a truncation marker.
  - Long lines are truncated to #{MAX_LINE_LENGTH} chars with a preview marker; use `read` to see full lines.
DESC

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(workspace:) ⇒ Grep

Parameters:

  • workspace (Workspace)

    captured for path resolution and as chdir for rg; path arguments route through resolve_for_read.

Raises:

  • (RuntimeError)

    if rg isn't on PATH (fail-loud at construction).



110
111
112
113
114
115
116
117
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
144
145
146
147
148
149
# File 'lib/pikuri/workspace/search/grep.rb', line 110

def initialize(workspace:)
  Grep.send(:check_binaries!)
  super(
    name: 'grep',
    description: DESCRIPTION,
    parameters: Parameters.build { |p|
      p.required_string :pattern,
                        'Regex pattern to search for (rg Rust-regex ' \
                        'dialect), e.g. "def\s+\w+" or "TODO".'
      p.optional_string :path,
                        'File or directory to search (e.g. "lib" or ' \
                        '"src/app.rb"). Relative paths resolve against ' \
                        'the workspace root. Defaults to the workspace root.'
      p.optional_string :glob,
                        'Filename glob restricting which files are ' \
                        'searched: ** matches any number of ' \
                        'directories, {a,b} is alternation. E.g. ' \
                        '"*.rb" or "src/**/*.{ts,tsx}".'
      p.optional_boolean :case_insensitive,
                         'Match case-insensitively. Defaults to false, e.g. true.'
      p.optional_string :output_mode,
                        "One of #{OUTPUT_MODES.join(', ')}. Defaults to " \
                        "#{DEFAULT_OUTPUT_MODE}, e.g. \"files_with_matches\"."
      p.optional_boolean :include_gitignored,
                         'Also search files excluded by .gitignore. ' \
                         'Defaults to false, e.g. true.'
    },
    execute: lambda { |pattern:, path: nil, glob: nil, case_insensitive: false,
                      output_mode: DEFAULT_OUTPUT_MODE, include_gitignored: false|
      Grep.search(workspace: workspace, pattern: pattern, path: path,
                  glob: glob, case_insensitive: case_insensitive,
                  output_mode: output_mode, include_gitignored: include_gitignored)
    },
    trifecta_legs: Tool::TrifectaLegs.new(
      private: workspace.filesystem.private?,
      untrusted: workspace.filesystem.trusted? ? :none : :hard,
      egress_payload_review: :no_egress
    )
  )
end

Class Method Details

.search(workspace:, pattern:, path:, glob:, case_insensitive:, output_mode:, include_gitignored: false) ⇒ String

Validate inputs, resolve the path, spawn rg, render the observation. Returns the results, a "no matches" string, or "Error: ...".

Parameters:

  • workspace (Workspace)
  • pattern (String)
  • path (String, nil)
  • glob (String, nil)
  • case_insensitive (Boolean)
  • output_mode (String)
  • include_gitignored (Boolean) (defaults to: false)

    pass --no-ignore to rg, so +.gitignore+'d files are searched too. Defaults to false.

Returns:

  • (String)


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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/pikuri/workspace/search/grep.rb', line 173

def self.search(workspace:, pattern:, path:, glob:, case_insensitive:, output_mode:,
                include_gitignored: false)
  return 'Error: empty pattern.' if pattern.empty?
  unless OUTPUT_MODES.include?(output_mode)
    return "Error: output_mode must be one of #{OUTPUT_MODES.join(', ')}, " \
           "got #{output_mode.inspect}."
  end

  search_target = '.'
  resolved_root = workspace.project_root
  if path
    resolved = workspace.resolve_for_read(path)
    return "Error: path not found: #{path}" unless resolved.exist?

    resolved_root = resolved
    rel = resolved.relative_path_from(workspace.project_root).to_s
    search_target = rel
  end

  if (refusal = Utils.oversized_root_refusal(resolved_root, filesystem: workspace.filesystem,
                                             verb: 'search'))
    return refusal
  end

  denied_prefixes = Utils.denied_prefixes(filesystem: workspace.filesystem,
                                          root: workspace.project_root)
  argv = build_argv(pattern: pattern, glob: glob,
                    case_insensitive: case_insensitive,
                    output_mode: output_mode, path: search_target,
                    include_gitignored: include_gitignored,
                    denied_globs: denied_prefixes.flat_map { |rel| ['--glob', "!#{rel}"] })

  result = Pikuri::Subprocess.spawn(*argv, chdir: workspace.project_root.to_s).wait
  exit_code = result.status.exitstatus

  case exit_code
  when 0
    format_output(result.output, output_mode: output_mode,
                  pattern: pattern, path: path,
                  denied_prefixes: denied_prefixes)
  when 1
    no_match_message(pattern: pattern, path: path)
  else
    "Error: ripgrep: #{truncate_error(result.output, exit_code)}"
  end
rescue Filesystem::Error => e
  "Error: #{e.message}"
end

Instance Method Details

#with_workspace(workspace) ⇒ Grep

A new Pikuri::Workspace::Search::Grep bound to workspace. Used by SubAgent::SubAgentTool when a persona supplies a workspace_factory:, so paths resolve against the sub-agent's root.

Parameters:

Returns:



157
158
159
# File 'lib/pikuri/workspace/search/grep.rb', line 157

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