Class: RailsConsoleAi::SkillLoader

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_console_ai/skill_loader.rb

Constant Summary collapse

SKILLS_DIR =
'skills'

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(storage = nil) ⇒ SkillLoader

Returns a new instance of SkillLoader.



8
9
10
# File 'lib/rails_console_ai/skill_loader.rb', line 8

def initialize(storage = nil)
  @storage = storage || RailsConsoleAi.storage
end

Class Method Details

.parse(content) ⇒ Object

Public: parse a raw .md (YAML frontmatter + body) string into a hash. Returns nil for content that doesn’t have valid frontmatter so the caller can show a clear error instead of producing a half-formed record.



185
186
187
188
189
190
191
192
# File 'lib/rails_console_ai/skill_loader.rb', line 185

def self.parse(content)
  return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
  frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
  body = $2.strip
  frontmatter.merge('body' => body)
rescue Psych::SyntaxError
  nil
end

Instance Method Details

#delete_skill(name:) ⇒ Object

Tries DB first, falls back to file. Reports which source it removed from.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/rails_console_ai/skill_loader.rb', line 100

def delete_skill(name:)
  if Storage::DatabaseStorage.delete_skill_by_name(name)
    return "Skill deleted (db): \"#{name}\""
  end

  key = skill_key(name)
  unless @storage.exists?(key)
    found = safe_load_file_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
    return "No skill found: \"#{name}\"" unless found
    key = skill_key(found['name'])
  end

  skill = load_skill_file(key)
  @storage.delete(key)
  "Skill deleted: \"#{skill ? skill['name'] : name}\""
rescue Storage::StorageError => e
  "FAILED to delete skill (#{e.message})."
end

#find_any_skill(name) ⇒ Object

UI-facing: includes proposed skills too, with name-collision DB wins.



53
54
55
# File 'lib/rails_console_ai/skill_loader.rb', line 53

def find_any_skill(name)
  load_all_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
end

#find_skill(name) ⇒ Object



48
49
50
# File 'lib/rails_console_ai/skill_loader.rb', line 48

def find_skill(name)
  load_activatable_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
end

#load_activatable_skillsObject

Skills the AI is allowed to see / activate: approved DB skills + all file skills. File skills are considered pre-approved because they’re git-tracked.



31
32
33
34
35
# File 'lib/rails_console_ai/skill_loader.rb', line 31

def load_activatable_skills
  # Use string literal so this doesn't require the AR model to be autoloaded
  # in environments that don't reference it.
  load_all_skills.reject { |s| s['source'] == :db && s['status'] != 'approved' }
end

#load_all_skillsObject

Returns the union of DB-backed skills and file-backed skills. When the same name appears in both, the DB record wins and the file record is shadowed (but the file isn’t touched).

Includes proposed (unapproved) DB skills — they show up in the admin UI with a “PROPOSED” badge. The AI-facing surface (#skill_summaries, #find_skill) filters them out, so an unapproved skill can never be activated.



19
20
21
22
23
24
25
26
27
# File 'lib/rails_console_ai/skill_loader.rb', line 19

def load_all_skills
  db = safe_load_db_skills
  file = safe_load_file_skills

  names = db.map { |s| s['name'].to_s.downcase }
  file.reject! { |s| names.include?(s['name'].to_s.downcase) }

  (db + file).sort_by { |s| s['name'].to_s.downcase }
end

#save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [], target: :db, edited_by: nil, change_note: nil) ⇒ Object

target: :db (default) | :file Falls back to :file (with a notice in the return string) if DB tables aren’t set up.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/rails_console_ai/skill_loader.rb', line 59

def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [], target: :db, edited_by: nil, change_note: nil)
  target = (target || :db).to_sym
  db_fell_back = false
  if target == :db && !Storage::DatabaseStorage.available?
    target = :file
    db_fell_back = true
  end

  if target == :file
    result = save_skill_to_file(
      name: name, description: description, body: body,
      tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
    )
    if db_fell_back
      result += "\nNOTE: DB storage was requested but the rails_console_ai_skills table does not exist. " \
                "Run `ai_db_setup` in your Rails console to enable the versioned DB store. " \
                "Saved to a file instead."
    end
    result
  else
    record, was_new = Storage::DatabaseStorage.save_skill(
      name: name, description: description, body: body,
      tags: tags, bypass_guards_for_methods: bypass_guards_for_methods,
      edited_by: edited_by || 'ai', change_note: change_note
    )
    status_note = if record.respond_to?(:proposed?) && record.proposed?
                    ' — status: PROPOSED. A human must approve it at /rails_console_ai/skills before it can be activated.'
                  else
                    ''
                  end
    if was_new
      "Skill created (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
    else
      "Skill updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
    end
  end
rescue Storage::StorageError, ::ActiveRecord::RecordInvalid => e
  "FAILED to save skill (#{e.message})."
end

#skill_summariesObject



37
38
39
40
41
42
43
44
45
46
# File 'lib/rails_console_ai/skill_loader.rb', line 37

def skill_summaries
  skills = load_activatable_skills
  return nil if skills.empty?

  skills.map { |s|
    tags = Array(s['tags'])
    tag_str = tags.empty? ? '' : " [#{tags.join(', ')}]"
    "- **#{s['name']}**#{tag_str}: #{s['description']}"
  }
end