Class: Pikuri::Workspace::Search::Glob
- Inherits:
-
Tool
- Object
- Tool
- Pikuri::Workspace::Search::Glob
- Defined in:
- lib/pikuri/workspace/search/glob.rb
Overview
The glob tool — list files matching a glob pattern via rg --files,
sorted by mtime (newest first). Glob.new(workspace: ws) produces a tool
wired like any bundled tool's (workspace captured by the execute closure,
no confirmer — read-only).
Why a separate tool from Grep
The unique capability is mtime-descending sort — "what's been touched
recently", which Grep can't express. The rest is reachable via Grep with
pattern=".", but a separate tool keeps Read/Grep/Glob three clean roles:
read one file, search content, list files by name.
ripgrep dependency
Hard: Glob.check_binaries! runs in initialize and raises if rg isn't on
PATH. Each tool owns its probe so construction order doesn't matter.
Argv & filter pipeline
rg --files --color=never --hidden --glob '!.git/*' -- <path-or-dot>
# …then filter the list in Ruby with File.fnmatch?
The user pattern is NOT passed to rg as --glob: rg's --glob "always
overrides any other ignore logic", so it would re-include +.gitignore+'d
files, breaking the gitignore-respect promise. Instead rg produces the
full gitignore-respecting list and Ruby filters with
File.fnmatch?(pattern, p, FNM_PATHNAME | FNM_EXTGLOB | FNM_DOTMATCH) —
the three flags covering ** recursion, {a,b} alternation, and dotfiles
(matching --hidden). The .git/ exclusion stays on the rg side (an
explicit --glob, so it survives --no-ignore). Output ./ prefixes
(from search path .) are stripped post-rg.
Credential paths
Pruned twice: rg --glob '!…' keeps rg out of the subtree, and a
Filesystem#denied? pass over the surviving list is the guarantee if
that ever goes partial (see Utils.denied_globs).
Sort / truncation / exit codes
mtime-descending in Ruby (path-ascending tiebreaker), one stat per
result — bounded in practice by rg's .gitignore filter. Output
head-truncated to MAX_BYTES after sort so the newest rows survive; the
exit-2 error blob shares the same ceiling (Glob.truncate_error — rg emits one
diagnostic per unreadable path). Exit 0 → listing with footer; 1 → "No
files match"; 2 → "Error: ripgrep: ...".
Refusals (all as "Error: ...")
Empty pattern; path is a regular file (→ read tool); path not found;
path outside the workspace (Filesystem::Error); 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 FileList: no state of
its own, but the Workspace it searches is one
agent's tree. with_workspace rebinds a copy.
Constant Summary collapse
- MAX_BYTES =
Returns hard byte cap on combined rg output. Same value as Pikuri::Workspace::Search::Grep::MAX_BYTES; re-declared rather than cross-referenced because Zeitwerk's eager-load order between siblings isn't guaranteed.
50 * 1024
- MAX_BYTES_LABEL =
Returns human-readable MAX_BYTES for the truncation marker.
"#{MAX_BYTES / 1024} KB"- DESCRIPTION =
Description shown to the LLM (opencode-shape). Per-parameter constraints live in the parameter descriptions.
<<~DESC List files matching a glob pattern, sorted by modification time (newest first). Usage: - Respects `.gitignore` by default; set `include_gitignored: true` to also list ignored files. - Default search root is the workspace root; pass `path` to narrow to a subdirectory. - Use `glob` to find files by name; use `grep` to find files by content. - Output is sorted by mtime descending — recently-touched files come first, so broad patterns still surface relevant files near the top. - Output is truncated to #{MAX_BYTES_LABEL}; refine the pattern or narrow `path` if the response ends in a truncation marker. DESC
- FNMATCH_FLAGS =
Returns flags for File.fnmatch?:
FNM_PATHNAMEfor**recursion + path-aware/matching,FNM_EXTGLOBfor{a,b}alternation,FNM_DOTMATCHto match dotfiles (rg does this when--hiddenis set). File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
Class Method Summary collapse
-
.search(workspace:, pattern:, path:, include_gitignored: false) ⇒ String
Validate inputs, resolve the path, spawn rg, mtime-sort, head-truncate, render.
Instance Method Summary collapse
- #initialize(workspace:) ⇒ Glob constructor
-
#with_workspace(workspace) ⇒ Glob
A new Glob bound to
workspace.
Constructor Details
#initialize(workspace:) ⇒ Glob
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 |
# File 'lib/pikuri/workspace/search/glob.rb', line 93 def initialize(workspace:) Glob.send(:check_binaries!) super( name: 'glob', description: DESCRIPTION, parameters: Parameters.build { |p| p.required_string :pattern, 'Glob pattern: ** matches any number of ' \ 'directories, * matches any filename chars ' \ '(not /), {a,b} is alternation. E.g. ' \ '"src/**/*.{ts,tsx}" finds every TS/TSX file ' \ 'anywhere under src/.' p.optional_string :path, 'Directory to search in (e.g. "lib" or ' \ '"spec/pikuri"). Relative paths resolve against ' \ 'the workspace root. Defaults to the workspace ' \ 'root if missing.' p.optional_boolean :include_gitignored, 'Also list files excluded by .gitignore. ' \ 'Defaults to false, e.g. true.' }, execute: lambda { |pattern:, path: nil, include_gitignored: false| Glob.search(workspace: workspace, pattern: pattern, path: path, include_gitignored: include_gitignored) }, # Filenames, not contents — but a path can be as sensitive as a file # (+~/medical/2026-diagnosis.pdf+) and is as attacker-authorable # (a cloned dep names its own files), so this reads the same as Read. 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:, include_gitignored: false) ⇒ String
Validate inputs, resolve the path, spawn rg, mtime-sort, head-truncate, render. Returns the listing, a "no files match" message, or +"Error: ..."+.
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/pikuri/workspace/search/glob.rb', line 149 def self.search(workspace:, pattern:, path:, include_gitignored: false) return 'Error: empty pattern.' if pattern.empty? search_target = '.' resolved_root = workspace.project_root if path resolved = workspace.resolve_for_read(path) return "Error: path not found: #{path}" unless resolved.exist? if resolved.file? return "Error: #{path} is a file, not a directory; use the read tool to view it." end 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: 'list')) return refusal end argv = build_argv(path: search_target, include_gitignored: include_gitignored, denied_globs: Utils.denied_globs(filesystem: workspace.filesystem, root: workspace.project_root)) 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, workspace: workspace, pattern: pattern, path: path) 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) ⇒ Glob
A new Pikuri::Workspace::Search::Glob bound to workspace. Used by
SubAgent::SubAgentTool when a persona supplies a
workspace_factory:, so paths resolve against the sub-agent's root.
135 136 137 |
# File 'lib/pikuri/workspace/search/glob.rb', line 135 def with_workspace(workspace) self.class.new(workspace: workspace) end |