Class: Pikuri::Workspace::Search::Glob

Inherits:
Tool
  • Object
show all
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.

Returns:

  • (Integer)

    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.

Returns:

  • (String)

    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.

Returns:

  • (String)
<<~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_PATHNAME for ** recursion + path-aware / matching, FNM_EXTGLOB for {a,b} alternation, FNM_DOTMATCH to match dotfiles (rg does this when --hidden is set).

Returns:

  • (Integer)

    flags for File.fnmatch?: FNM_PATHNAME for ** recursion + path-aware / matching, FNM_EXTGLOB for {a,b} alternation, FNM_DOTMATCH to match dotfiles (rg does this when --hidden is set).

File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(workspace:) ⇒ Glob

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).



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: ..."+.

Parameters:

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

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

Returns:

  • (String)


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
    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) ⇒ 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.

Parameters:

Returns:



135
136
137
# File 'lib/pikuri/workspace/search/glob.rb', line 135

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