Class: Pikuri::Workspace::Search::Grep
- Inherits:
-
Tool
- Object
- Tool
- Pikuri::Workspace::Search::Grep
- 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→ flatpath:line:contentrows 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
popen2egives 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.
50 * 1024
- MAX_BYTES_LABEL =
Returns 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. 2000- OUTPUT_MODES =
Returns valid
output_modevalues. %w[content files_with_matches count].freeze
- DEFAULT_OUTPUT_MODE =
Returns default
output_mode. 'content'- DESCRIPTION =
Description shown to the LLM (opencode-shape). Per-parameter constraints live in the parameter descriptions.
<<~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
-
.search(workspace:, pattern:, path:, glob:, case_insensitive:, output_mode:, include_gitignored: false) ⇒ String
Validate inputs, resolve the path, spawn rg, render the observation.
Instance Method Summary collapse
- #initialize(workspace:) ⇒ Grep constructor
-
#with_workspace(workspace) ⇒ Grep
A new Grep bound to
workspace.
Constructor Details
#initialize(workspace:) ⇒ Grep
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: ...".
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 (pattern: pattern, path: path) else "Error: ripgrep: #{truncate_error(result.output, exit_code)}" end rescue Filesystem::Error => e "Error: #{e.}" 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.
157 158 159 |
# File 'lib/pikuri/workspace/search/grep.rb', line 157 def with_workspace(workspace) self.class.new(workspace: workspace) end |