Class: LittleGhost::Skills::Catalog

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/little_ghost/skills/catalog.rb

Overview

A Catalog lets an agent discover focused instructions without putting every skill in its prompt. The model sees short descriptions first and can load a skill's full instructions when the task calls for them.

catalog = LittleGhost::Skills::Catalog.new(paths: ["app/skills"])
catalog.names # => ["refund_policy", "search_orders"]
catalog.discovery_prompt.include?("refund_policy") # => true
catalog.tool # a LittleGhost::Tool that loads full instructions on demand

Each immediate child directory may contain one SKILL.md with YAML front matter. Symbolic-link escapes, unsafe names, oversized files, and invalid YAML are rejected or skipped before instructions reach a model. Optional resource listings are limited by count and depth.

Choosing skill sources

Skill files become model instructions, so keep configured roots under application control and non-user-writable. The allowed-tools field tells the model what a skill expects; the Agent's Tool list and each Tool's application checks still decide what can run. For a workspace:// resource root, the Catalog verifies the named read-only grant and rejects direct writable aliases it can identify. LittleGhost cannot identify every alias created by an outer container or mount namespace, so the application must not expose the same files through another writable bind mount.

Constant Summary collapse

DEFAULT_MAX_SKILLS =

:nodoc:

1_000
DEFAULT_MAX_FILE_BYTES =

:nodoc:

1_000_000
DEFAULT_MAX_RESOURCE_FILES =

:nodoc:

20
MAX_RESOURCE_DEPTH =

:nodoc:

3
RESOURCE_DIRECTORIES =

:nodoc:

%w[scripts references assets].freeze
SAFE_NAME_PATTERN =

:nodoc:

/\A[a-zA-Z0-9_-]+\z/

Instance Method Summary collapse

Constructor Details

#initialize(paths:, max_skills: DEFAULT_MAX_SKILLS, max_file_bytes: DEFAULT_MAX_FILE_BYTES, max_resource_files: DEFAULT_MAX_RESOURCE_FILES, only: nil, resource_root: nil, workspace: nil, sandbox: nil) ⇒ Catalog

Loads valid skills immediately using the supplied safety limits. resource_root may be an absolute process-visible path. A workspace://name reference also requires workspace and sandbox; it must resolve to every configured skill root through a read-only file-tool grant.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/little_ghost/skills/catalog.rb', line 51

def initialize(
  paths:,
  max_skills: DEFAULT_MAX_SKILLS,
  max_file_bytes: DEFAULT_MAX_FILE_BYTES,
  max_resource_files: DEFAULT_MAX_RESOURCE_FILES,
  only: nil,
  resource_root: nil,
  workspace: nil,
  sandbox: nil
)
  @paths = PathSet.new(paths)
  @max_skills = positive_integer(max_skills, :max_skills)
  @max_file_bytes = positive_integer(max_file_bytes, :max_file_bytes)
  @max_resource_files = positive_integer(max_resource_files, :max_resource_files)
  @only = Array(only).map(&:to_s).freeze if only
  @resource_root = ResourceRoot.normalize(resource_root)
  validate_workspace_resource_root!(workspace, sandbox)
  @skills = load_skills
  validate_workspace_resource_aliases!(sandbox)
end

Instance Method Details

#discovery_promptObject

Produces the escaped, metadata-only prompt used for discovery.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/little_ghost/skills/catalog.rb', line 93

def discovery_prompt
  return "" if @skills.empty?

  lines = ["<available_skills>"]
  @skills.each_value do |skill|
    lines.concat([
      "<skill>",
      "<name>#{ERB::Util.html_escape(skill.name)}</name>",
      "<description>#{ERB::Util.html_escape(skill.description)}</description>",
      "<location>#{ERB::Util.html_escape(skill.path)}</location>",
      "</skill>"
    ])
  end
  lines << "</available_skills>"
  lines.join("\n")
end

#each(&block) ⇒ Object

Yields each Skill in lookup order.



73
74
75
# File 'lib/little_ghost/skills/catalog.rb', line 73

def each(&block)
  @skills.each_value(&block)
end

#fetch(name) ⇒ Object

Finds the named Skill. When no Skill matches, the ConfigurationError names the available skills so the caller can correct the lookup.



79
80
81
82
83
84
85
# File 'lib/little_ghost/skills/catalog.rb', line 79

def fetch(name)
  @skills.fetch(name.to_s) do
    message = "Unknown skill: #{name}"
    message += ". Available skills: #{names.join(", ")}" unless @skills.empty?
    raise ConfigurationError, message
  end
end

#format(skill) ⇒ Object

Formats one Skill, including allowed tools, compatibility, and bounded resource paths.



141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/little_ghost/skills/catalog.rb', line 141

def format(skill)
  parts = [skill.instructions]
   = []
   << "Allowed tools: #{skill.allowed_tools.join(", ")}" unless skill.allowed_tools.empty?
   << "Compatibility: #{skill.compatibility}" if skill.compatibility
   << "Location: #{skill.path}"
  parts << "\n---\n#{.join("\n")}" unless .empty?
  resources = skill_resources(skill)
  unless resources.empty?
    parts << "\nAvailable resources:\n#{resources.map { |path| "  #{path}" }.join("\n")}"
  end
  parts.join("\n")
end

#namesObject

Lists immutable skill names in lookup order.



88
89
90
# File 'lib/little_ghost/skills/catalog.rb', line 88

def names
  @skills.keys.freeze
end

#toolObject

Exposes full instructions on demand through a skills Tool.



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
# File 'lib/little_ghost/skills/catalog.rb', line 111

def tool
  catalog = self
  Tool.define(
    name: "skills",
    description: <<~DESCRIPTION.strip,
      Activate a skill to load its full instructions.

      Use this tool to load the complete instructions for a skill listed in
      the available_skills section of your system prompt.
    DESCRIPTION
    input_schema: {
      type: "object",
      properties: {
        skill_name: {
          type: "string",
          description: "Exact name of one skill from available_skills. Pass only the bare name here; keep arguments and surrounding instructions in the task request."
        }
      },
      required: ["skill_name"],
      additionalProperties: false
    }
  ) do |input|
    catalog.format(catalog.fetch(input.fetch("skill_name")))
  rescue ConfigurationError => error
    raise ToolError, error.message
  end
end