Module: Mistri::Skills

Defined in:
lib/mistri/skills.rb

Overview

Loads skills and wires them into an agent: their one-line descriptions ride the system prompt, and the model pulls a full body on demand with the read_skill tool — so a large library costs almost nothing until a skill is actually used.

Class Method Summary collapse

Class Method Details

.amend(system, skills) ⇒ Object



48
49
50
51
52
# File 'lib/mistri/skills.rb', line 48

def amend(system, skills)
  return system if skills.empty?

  [system, section(skills)].compact.join("\n\n")
end

.load(path) ⇒ Object

Reads a directory of skills in either layout:

//SKILL.md or /.md. Frontmatter (name:, description:) overrides the path-derived name.

Raises:



14
15
16
17
18
19
20
21
22
23
24
25
26
# File 'lib/mistri/skills.rb', line 14

def load(path)
  raise ConfigurationError, "no skills directory at #{path}" unless File.directory?(path)

  skills = Dir.children(path).sort.filter_map do |child|
    full = File.join(path, child)
    if File.directory?(full) && File.file?(File.join(full, "SKILL.md"))
      read(File.join(full, "SKILL.md"), default_name: child)
    elsif child.end_with?(".md") && File.file?(full)
      read(full, default_name: File.basename(child, ".md"))
    end
  end
  skills.sort_by(&:name)
end

.parse(text) ⇒ Object

Frontmatter is deliberately a subset: flat string keys between --- markers, quotes optional. name and description are the whole contract, and a YAML dependency is not worth two fields.



68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/mistri/skills.rb', line 68

def parse(text)
  return [{}, text] unless text.start_with?("---\n")

  head, separator, body = text[4..].partition("\n---\n")
  return [{}, text] if separator.empty?

  meta = {}
  head.scan(/^([a-z_]+):[ \t]*(.+?)[ \t]*$/) do |key, value|
    meta[key] = value.gsub(/\A["']|["']\z/, "")
  end
  [meta, body.sub(/\A\n+/, "")]
end

.read(file, default_name:) ⇒ Object



28
29
30
31
32
# File 'lib/mistri/skills.rb', line 28

def read(file, default_name:)
  meta, body = parse(File.read(file))
  Skill.new(name: meta.fetch("name", default_name),
            description: meta.fetch("description", ""), body: body)
end

.reader(skills) ⇒ Object



54
55
56
57
58
59
60
61
62
63
# File 'lib/mistri/skills.rb', line 54

def reader(skills)
  by_name = skills.to_h { |skill| [skill.name, skill] }
  Tool.define("read_skill", "Reads the full playbook for a named skill.",
              schema: -> { string :name, "Skill name", required: true }) do |args|
    skill = by_name[args["name"]]
    next skill.body if skill

    "Unknown skill #{args["name"].inspect}. Available: #{by_name.keys.join(", ")}"
  end
end

.section(skills) ⇒ Object

The always-present cost of a skill library: one line per skill.



35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/mistri/skills.rb', line 35

def section(skills)
  lines = skills.map { |skill| "- #{skill.name}: #{skill.description}" }
  <<~TEXT.strip
    ## Skills

    Expert playbooks. Before acting, check this list: when a skill
    matches the task, you MUST call read_skill with its name and follow
    the playbook.

    #{lines.join("\n")}
  TEXT
end