rails_console_ai 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +18 -1
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +1 -8
  5. data/app/controllers/rails_console_ai/agents_controller.rb +24 -47
  6. data/app/controllers/rails_console_ai/memories_controller.rb +13 -36
  7. data/app/controllers/rails_console_ai/memory_versions_controller.rb +1 -5
  8. data/app/controllers/rails_console_ai/skill_versions_controller.rb +1 -7
  9. data/app/controllers/rails_console_ai/skills_controller.rb +18 -47
  10. data/app/models/rails_console_ai/agent.rb +33 -65
  11. data/app/models/rails_console_ai/agent_version.rb +8 -20
  12. data/app/models/rails_console_ai/memory.rb +28 -23
  13. data/app/models/rails_console_ai/memory_version.rb +5 -20
  14. data/app/models/rails_console_ai/skill.rb +55 -105
  15. data/app/models/rails_console_ai/skill_version.rb +7 -28
  16. data/app/views/rails_console_ai/agents/_form.html.erb +9 -34
  17. data/app/views/rails_console_ai/agents/diff.html.erb +1 -5
  18. data/app/views/rails_console_ai/agents/new.html.erb +0 -16
  19. data/app/views/rails_console_ai/memories/_form.html.erb +6 -13
  20. data/app/views/rails_console_ai/memories/diff.html.erb +1 -5
  21. data/app/views/rails_console_ai/memories/new.html.erb +0 -15
  22. data/app/views/rails_console_ai/skills/_form.html.erb +7 -29
  23. data/app/views/rails_console_ai/skills/diff.html.erb +1 -8
  24. data/app/views/rails_console_ai/skills/new.html.erb +0 -17
  25. data/config/routes.rb +0 -3
  26. data/lib/rails_console_ai/agent_loader.rb +18 -10
  27. data/lib/rails_console_ai/agent_runner.rb +55 -4
  28. data/lib/rails_console_ai/session_logger.rb +4 -0
  29. data/lib/rails_console_ai/skill_loader.rb +18 -9
  30. data/lib/rails_console_ai/storage/database_storage.rb +14 -20
  31. data/lib/rails_console_ai/tools/memory_tools.rb +8 -0
  32. data/lib/rails_console_ai/version.rb +1 -1
  33. data/lib/rails_console_ai.rb +54 -70
  34. metadata +1 -1
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/tools/memory_tools'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class MemoryVersion < ActiveRecord::Base
@@ -21,26 +21,11 @@ module RailsConsoleAi
21
21
  end
22
22
  end
23
23
 
24
- def tags
25
- decode_json_array(read_attribute(:tags))
24
+ def parsed
25
+ @parsed ||= (RailsConsoleAi::Tools::MemoryTools.parse(content.to_s) || {})
26
26
  end
27
27
 
28
- def tags=(value)
29
- write_attribute(:tags, encode_json_array(value))
30
- end
31
-
32
- private
33
-
34
- def decode_json_array(raw)
35
- return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
36
- return raw if raw.is_a?(Array)
37
- JSON.parse(raw)
38
- rescue JSON::ParserError
39
- []
40
- end
41
-
42
- def encode_json_array(value)
43
- JSON.dump(Array(value))
44
- end
28
+ def description; parsed['description']; end
29
+ def tags; Array(parsed['tags']); end
45
30
  end
46
31
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/skill_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class Skill < ActiveRecord::Base
@@ -8,19 +8,18 @@ module RailsConsoleAi
8
8
  STATUS_APPROVED = 'approved'.freeze
9
9
  STATUSES = [STATUS_PROPOSED, STATUS_APPROVED].freeze
10
10
 
11
- # Attributes that, if changed, invalidate the current approval and revert
12
- # the skill back to "proposed". Status / approver columns are excluded so
13
- # that an explicit approve! call doesn't reset its own approval.
14
- CONTENT_ATTRIBUTES = %w[name description body tags bypass_guards_for_methods].freeze
15
-
16
11
  has_many :versions,
17
12
  -> { order(created_at: :desc) },
18
13
  class_name: 'RailsConsoleAi::SkillVersion',
19
14
  foreign_key: :skill_id,
20
15
  dependent: :nullify
21
16
 
17
+ validates :content, presence: true
22
18
  validates :name, presence: true, uniqueness: { case_sensitive: false }
23
- validates :status, inclusion: { in: STATUSES }, if: :has_attribute_status?
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validate :content_parses
21
+
22
+ before_validation :sync_name_from_content
24
23
 
25
24
  scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
26
25
  scope :approved, -> { where(status: STATUS_APPROVED) }
@@ -36,42 +35,38 @@ module RailsConsoleAi
36
35
  end
37
36
  end
38
37
 
39
- # Manual JSON accessors keep us off Rails-version-specific `serialize` syntax
40
- # (positional coder in Rails 5–6, keyword coder in Rails 7+).
41
- def tags
42
- decode_json_array(read_attribute(:tags))
43
- end
44
-
45
- def tags=(value)
46
- write_attribute(:tags, encode_json_array(value))
47
- end
48
-
49
- def bypass_guards_for_methods
50
- decode_json_array(read_attribute(:bypass_guards_for_methods))
51
- end
52
-
53
- def bypass_guards_for_methods=(value)
54
- write_attribute(:bypass_guards_for_methods, encode_json_array(value))
55
- end
56
-
57
- # Defensive accessors — if `ai_db_migrate` hasn't been run yet, the status
58
- # / approval columns may be missing on an older table. Return safe defaults
59
- # instead of blowing up with NameError.
60
- def status
61
- has_attribute_status? ? read_attribute(:status) : STATUS_PROPOSED
38
+ # Parsed view of the raw markdown content. Memoized per-instance and cleared
39
+ # on assignment. Returns {} for invalid content so callers don't need to nil-guard.
40
+ def parsed
41
+ @parsed ||= (RailsConsoleAi::SkillLoader.parse(content.to_s) || {})
62
42
  end
63
43
 
64
- def approved_by
65
- has_attribute?(:approved_by) ? read_attribute(:approved_by) : nil
44
+ def content=(value)
45
+ @parsed = nil
46
+ super
66
47
  end
67
48
 
68
- def approved_at
69
- has_attribute?(:approved_at) ? read_attribute(:approved_at) : nil
70
- end
49
+ def description; parsed['description']; end
50
+ def body; parsed['body']; end
51
+ def tags; Array(parsed['tags']); end
52
+ def bypass_guards_for_methods; Array(parsed['bypass_guards_for_methods']); end
71
53
 
72
54
  def proposed?; status.to_s == STATUS_PROPOSED; end
73
55
  def approved?; status.to_s == STATUS_APPROVED; end
74
56
 
57
+ # Atomically bump use_count + last_used_at without firing callbacks /
58
+ # validations / updated_at. Safe to call from concurrent AI tool calls.
59
+ def self.record_use!(id)
60
+ where(id: id).update_all([
61
+ 'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
62
+ Time.now.utc
63
+ ])
64
+ true
65
+ rescue ::ActiveRecord::ActiveRecordError => e
66
+ RailsConsoleAi.logger.warn("RailsConsoleAi::Skill.record_use!(#{id.inspect}) failed: #{e.message}")
67
+ false
68
+ end
69
+
75
70
  def to_hash
76
71
  {
77
72
  'id' => id,
@@ -80,6 +75,7 @@ module RailsConsoleAi
80
75
  'body' => body,
81
76
  'tags' => tags,
82
77
  'bypass_guards_for_methods' => bypass_guards_for_methods,
78
+ 'content' => content,
83
79
  'status' => status,
84
80
  'approved_by' => approved_by,
85
81
  'approved_at' => approved_at,
@@ -90,66 +86,14 @@ module RailsConsoleAi
90
86
  }
91
87
  end
92
88
 
93
- def has_attribute_status?
94
- has_attribute?(:status)
95
- end
96
-
97
- # Atomically bump use_count + last_used_at without firing callbacks /
98
- # validations / updated_at. Safe to call from concurrent AI tool calls.
99
- # No-op (returns false) if the table doesn't have the columns yet — that
100
- # keeps older installs working until they run ai_db_migrate.
101
- def self.record_use!(id)
102
- return false unless connection.column_exists?(table_name, :use_count)
103
- where(id: id).update_all([
104
- 'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
105
- Time.now.utc
106
- ])
107
- true
108
- rescue ::ActiveRecord::ActiveRecordError => e
109
- RailsConsoleAi.logger.warn("RailsConsoleAi::Skill.record_use!(#{id.inspect}) failed: #{e.message}")
110
- false
111
- end
112
-
113
- def use_count
114
- has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
115
- end
116
-
117
- def last_used_at
118
- has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
119
- end
120
-
121
- def self.decode_json_array(raw)
122
- return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
123
- return raw if raw.is_a?(Array)
124
- JSON.parse(raw)
125
- rescue JSON::ParserError
126
- []
127
- end
128
-
129
- def self.encode_json_array(value)
130
- JSON.dump(Array(value))
131
- end
132
-
133
- def decode_json_array(raw)
134
- self.class.decode_json_array(raw)
135
- end
136
-
137
- def encode_json_array(value)
138
- self.class.encode_json_array(value)
139
- end
140
-
141
- # Assigns attrs, saves, and records one SkillVersion snapshot of the post-save state.
142
- # Every save produces exactly one version row, so the version log is a complete history
143
- # including the current state (the most recent version mirrors `self`).
144
- #
145
- # If `preserve_approval` is false (the default), any change to a content attribute
146
- # reverts the skill back to "proposed" and clears the approver. Pass true from the
147
- # approve! flow so approval doesn't reset itself.
89
+ # Assigns attrs, saves, and records one SkillVersion snapshot.
90
+ # Any change to `content` reverts approval back to "proposed" unless
91
+ # `preserve_approval: true` is passed (approve! does this).
148
92
  def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
149
93
  transaction do
150
94
  assign_attributes(attrs)
151
95
 
152
- if !preserve_approval && approved? && content_dirty?
96
+ if !preserve_approval && approved? && changes.key?('content')
153
97
  self.status = STATUS_PROPOSED
154
98
  self.approved_by = nil
155
99
  self.approved_at = nil
@@ -157,22 +101,17 @@ module RailsConsoleAi
157
101
 
158
102
  save!
159
103
  RailsConsoleAi::SkillVersion.create!(
160
- skill_id: id,
161
- name: name,
162
- description: description,
163
- body: body,
164
- tags: tags,
165
- bypass_guards_for_methods: bypass_guards_for_methods,
166
- status: status,
167
- edited_by: edited_by,
168
- change_note: change_note
104
+ skill_id: id,
105
+ name: name,
106
+ content: content,
107
+ status: status,
108
+ edited_by: edited_by,
109
+ change_note: change_note
169
110
  )
170
111
  end
171
112
  self
172
113
  end
173
114
 
174
- # Marks the current head as approved. Logs a version row with the approver name
175
- # so the audit trail captures the approval moment.
176
115
  def approve!(approved_by:)
177
116
  raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
178
117
 
@@ -190,9 +129,20 @@ module RailsConsoleAi
190
129
 
191
130
  private
192
131
 
193
- # Did any content-bearing attribute change in this assign_attributes pass?
194
- def content_dirty?
195
- CONTENT_ATTRIBUTES.any? { |a| changes.key?(a) }
132
+ def sync_name_from_content
133
+ return if content.to_s.strip.empty?
134
+ parsed_name = parsed['name'].to_s.strip
135
+ self.name = parsed_name unless parsed_name.empty?
136
+ end
137
+
138
+ def content_parses
139
+ return if content.to_s.strip.empty? # presence validator handles blank
140
+ hash = RailsConsoleAi::SkillLoader.parse(content.to_s)
141
+ if hash.nil?
142
+ errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
143
+ elsif hash['name'].to_s.strip.empty?
144
+ errors.add(:content, "frontmatter is missing a `name:` field")
145
+ end
196
146
  end
197
147
  end
198
148
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/skill_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class SkillVersion < ActiveRecord::Base
@@ -21,34 +21,13 @@ module RailsConsoleAi
21
21
  end
22
22
  end
23
23
 
24
- def tags
25
- decode_json_array(read_attribute(:tags))
24
+ def parsed
25
+ @parsed ||= (RailsConsoleAi::SkillLoader.parse(content.to_s) || {})
26
26
  end
27
27
 
28
- def tags=(value)
29
- write_attribute(:tags, encode_json_array(value))
30
- end
31
-
32
- def bypass_guards_for_methods
33
- decode_json_array(read_attribute(:bypass_guards_for_methods))
34
- end
35
-
36
- def bypass_guards_for_methods=(value)
37
- write_attribute(:bypass_guards_for_methods, encode_json_array(value))
38
- end
39
-
40
- private
41
-
42
- def decode_json_array(raw)
43
- return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
44
- return raw if raw.is_a?(Array)
45
- JSON.parse(raw)
46
- rescue JSON::ParserError
47
- []
48
- end
49
-
50
- def encode_json_array(value)
51
- JSON.dump(Array(value))
52
- end
28
+ def description; parsed['description']; end
29
+ def body; parsed['body']; end
30
+ def tags; Array(parsed['tags']); end
31
+ def bypass_guards_for_methods; Array(parsed['bypass_guards_for_methods']); end
53
32
  end
54
33
  end
@@ -1,47 +1,23 @@
1
1
  <% if @agent.persisted? && @agent.respond_to?(:approved?) && @agent.approved? %>
2
2
  <div class="flash flash-alert" style="margin-bottom:16px;">
3
- Heads up: this agent is currently <strong>approved</strong>. Editing it will revert its status to <strong>proposed</strong> and the AI won't be able to invoke it again until someone re-approves it.
3
+ Heads up: this agent is currently <strong>approved</strong>. Editing it will revert its status to <strong>proposed</strong>.
4
4
  </div>
5
5
  <% elsif !@agent.persisted? %>
6
6
  <div class="flash flash-notice" style="margin-bottom:16px;">
7
- New DB agents start as <strong>proposed</strong>. After saving, an approver (likely you) needs to click the Approve button on the show page before the AI can invoke this agent via delegate_task.
7
+ New DB agents start as <strong>proposed</strong>. After saving, an approver (likely you) needs to click the Approve button before delegate_task can invoke it.
8
8
  </div>
9
9
  <% end %>
10
10
 
11
11
  <%= form_with model: @agent, url: form_url, method: form_method, local: true do |f| %>
12
12
  <div class="form-card">
13
13
  <div class="form-row">
14
- <label>Name</label>
15
- <%= f.text_field :name, value: @agent.name, required: true %>
16
- </div>
17
-
18
- <div class="form-row">
19
- <label>Description</label>
20
- <%= f.text_field :description, value: @agent.description, placeholder: 'One-line description of what this agent specializes in' %>
21
- </div>
22
-
23
- <div class="form-row">
24
- <label>Body / system instructions (markdown)</label>
25
- <%= f.text_area :body, value: @agent.body, rows: 18 %>
26
- <div class="hint">Persona + strategy + rules. Be specific about what tools/approaches the sub-agent should use and what format its summary should take.</div>
27
- </div>
28
-
29
- <div class="form-row">
30
- <label>Max rounds (optional)</label>
31
- <%= f.number_field :max_rounds, value: @agent.max_rounds, min: 1, max: 100 %>
32
- <div class="hint">Maximum tool-loop iterations for the sub-agent. Defaults to the global <code>config.sub_agent_max_rounds</code>. Smaller = tighter scope, fewer tokens.</div>
33
- </div>
34
-
35
- <div class="form-row">
36
- <label>Model (optional)</label>
37
- <%= f.text_field :model, value: @agent.model, placeholder: 'e.g. claude-haiku-4-5' %>
38
- <div class="hint">Model override. Leave blank to use the global model. Useful for cheap/fast agents.</div>
39
- </div>
40
-
41
- <div class="form-row">
42
- <label>Tool whitelist (one per line, optional)</label>
43
- <%= f.text_area :tools, value: Array(@agent.tools).join("\n"), rows: 4 %>
44
- <div class="hint">Tool names the sub-agent is allowed to call (e.g. <code>execute_code</code>, <code>search_code</code>, <code>read_file</code>). Leave blank to use the default tool set. Adding tools here can broaden what the sub-agent can do — this is why DB agents need approval.</div>
14
+ <label>Markdown (frontmatter + body)</label>
15
+ <%= f.text_area :content, value: @agent.content, rows: 28, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
16
+ <div class="hint">
17
+ YAML frontmatter between <code>---</code> lines, then the agent persona / instructions. Required:
18
+ <code>name</code>, <code>description</code>. Optional: <code>max_rounds</code>, <code>model</code>,
19
+ <code>tools</code> (whitelist; empty/omitted = default set).
20
+ </div>
45
21
  </div>
46
22
 
47
23
  <hr style="margin: 20px 0; border: 0; border-top: 1px solid #eee;">
@@ -49,7 +25,6 @@
49
25
  <div class="form-row">
50
26
  <label>Edited by</label>
51
27
  <input type="text" name="edited_by" value="<%= params[:edited_by] %>" placeholder="Your name or handle">
52
- <div class="hint">Recorded with this version. Free text — the gem does not assume authentication beyond the admin login.</div>
53
28
  </div>
54
29
 
55
30
  <div class="form-row">
@@ -5,11 +5,7 @@
5
5
  Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
6
  </p>
7
7
 
8
- <h3 style="margin:16px 0 6px;">Body</h3>
9
- <%= render_text_diff(@from.body, @to_body, left_label: "v##{@from.id}", right_label: @to_label) %>
10
-
11
- <h3 style="margin:24px 0 6px;">Tool whitelist</h3>
12
- <%= render_json_diff(Array(@from.tools), @to_tools, left_label: "v##{@from.id}", right_label: @to_label) %>
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
13
9
 
14
10
  <div class="btn-bar" style="margin-top:24px;">
15
11
  <% if @to.nil? %>
@@ -1,22 +1,6 @@
1
1
  <%= link_to '← All agents', agents_path, class: 'back-link' %>
2
2
  <h2 style="margin-bottom:16px;">New agent</h2>
3
3
 
4
- <%= form_with url: import_agents_path, method: :post, local: true do %>
5
- <div class="form-card" style="background:#f8f9fa;">
6
- <div class="form-row" style="margin-bottom:8px;">
7
- <label>Paste a .md file (optional)</label>
8
- <textarea name="content" rows="14" placeholder="---&#10;name: My agent&#10;description: What it specializes in&#10;max_rounds: 10&#10;model: claude-haiku-4-5&#10;tools: [execute_code, search_code]&#10;---&#10;&#10;Persona, strategy, rules …"></textarea>
9
- <div class="hint">
10
- Paste the full contents of a Markdown file (YAML frontmatter + body) — same
11
- format as <code>.rails_console_ai/agents/*.md</code> or the built-in agents
12
- shipped with the gem. Useful for moving an existing on-disk agent into the
13
- versioned DB store.
14
- </div>
15
- </div>
16
- <button type="submit" class="btn btn-secondary">Parse pasted content ↓</button>
17
- </div>
18
- <% end %>
19
-
20
4
  <%= render 'form',
21
5
  form_url: agents_path,
22
6
  form_method: :post,
@@ -1,19 +1,12 @@
1
1
  <%= form_with model: @memory, url: form_url, method: form_method, local: true do |f| %>
2
2
  <div class="form-card">
3
3
  <div class="form-row">
4
- <label>Name</label>
5
- <%= f.text_field :name, value: @memory.name, required: true %>
6
- </div>
7
-
8
- <div class="form-row">
9
- <label>Description</label>
10
- <%= f.text_area :description, value: @memory.description, rows: 14 %>
11
- <div class="hint">The fact or pattern you're persisting. Markdown is fine.</div>
12
- </div>
13
-
14
- <div class="form-row">
15
- <label>Tags (comma-separated)</label>
16
- <%= f.text_field :tags, value: Array(@memory.tags).join(', '), placeholder: 'database, sharding' %>
4
+ <label>Markdown (frontmatter + body)</label>
5
+ <%= f.text_area :content, value: @memory.content, rows: 22, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
6
+ <div class="hint">
7
+ YAML frontmatter between <code>---</code> lines (required: <code>name</code>; optional: <code>tags</code>),
8
+ then the memory body — the fact or pattern you're persisting.
9
+ </div>
17
10
  </div>
18
11
 
19
12
  <hr style="margin: 20px 0; border: 0; border-top: 1px solid #eee;">
@@ -5,11 +5,7 @@
5
5
  Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
6
  </p>
7
7
 
8
- <h3 style="margin:16px 0 6px;">Description</h3>
9
- <%= render_text_diff(@from.description, @to_description, left_label: "v##{@from.id}", right_label: @to_label) %>
10
-
11
- <h3 style="margin:24px 0 6px;">Tags</h3>
12
- <%= render_json_diff(Array(@from.tags), @to_tags, left_label: "v##{@from.id}", right_label: @to_label) %>
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
13
9
 
14
10
  <div class="btn-bar" style="margin-top:24px;">
15
11
  <% if @to.nil? %>
@@ -1,21 +1,6 @@
1
1
  <%= link_to '← All memories', memories_path, class: 'back-link' %>
2
2
  <h2 style="margin-bottom:16px;">New memory</h2>
3
3
 
4
- <%= form_with url: import_memories_path, method: :post, local: true do %>
5
- <div class="form-card" style="background:#f8f9fa;">
6
- <div class="form-row" style="margin-bottom:8px;">
7
- <label>Paste a .md file (optional)</label>
8
- <textarea name="content" rows="12" placeholder="---&#10;name: My memory&#10;tags: [database]&#10;---&#10;&#10;The fact or pattern …"></textarea>
9
- <div class="hint">
10
- Paste the full contents of a Markdown file (YAML frontmatter + body) — same
11
- format as <code>.rails_console_ai/memories/*.md</code>. The fields below will
12
- be prefilled from the paste.
13
- </div>
14
- </div>
15
- <button type="submit" class="btn btn-secondary">Parse pasted content ↓</button>
16
- </div>
17
- <% end %>
18
-
19
4
  <%= render 'form',
20
5
  form_url: memories_path,
21
6
  form_method: :post,
@@ -11,36 +11,14 @@
11
11
  <%= form_with model: @skill, url: form_url, method: form_method, local: true do |f| %>
12
12
  <div class="form-card">
13
13
  <div class="form-row">
14
- <label>Name</label>
15
- <%= f.text_field :name, value: @skill.name, required: true %>
16
- </div>
17
-
18
- <div class="form-row">
19
- <label>Description</label>
20
- <%= f.text_field :description, value: @skill.description, placeholder: 'One-line description of when to use this skill' %>
21
- </div>
22
-
23
- <div class="form-row">
24
- <label>Body (markdown)</label>
25
- <%= f.text_area :body, value: @skill.body, rows: 18 %>
26
- <div class="hint">Include "## When to use", "## Recipe" (numbered steps with code blocks), "## Notes".</div>
27
- </div>
28
-
29
- <div class="form-row">
30
- <label>Tags (comma-separated)</label>
31
- <%= f.text_field :tags, value: Array(@skill.tags).join(', '), placeholder: 'booking-page, admin' %>
32
- </div>
33
-
34
- <div class="form-row">
35
- <label>Bypass guards for methods (one per line)</label>
36
- <%= f.text_area :bypass_guards_for_methods, value: Array(@skill.bypass_guards_for_methods).join("\n"), rows: 4 %>
14
+ <label>Markdown (frontmatter + body)</label>
15
+ <%= f.text_area :content, value: @skill.content, rows: 28, required: true, style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;' %>
37
16
  <div class="hint">
38
- Two forms accepted anything else is ignored:
39
- <ul style="margin:6px 0 0 18px; padding:0;">
40
- <li><code>ClassName#method</code> instance method, e.g. <code>BookingPage#save!</code></li>
41
- <li><code>ClassName.method</code> — class (singleton) method, e.g. <code>User.create_admin!</code></li>
42
- </ul>
43
- Namespaced classes work: <code>Acme::Booking#destroy</code>. The <code>!</code> is part of the match, so <code>save</code> and <code>save!</code> are different specs. While the skill is active, calls to these methods skip the configured safety guards (DB writes, HTTP mutations, email, etc.) <strong>for that tool-call only</strong>.
17
+ YAML frontmatter between <code>---</code> lines, then the markdown body. Required frontmatter:
18
+ <code>name</code>, <code>description</code>. Optional: <code>tags</code>,
19
+ <code>bypass_guards_for_methods</code> (entries shaped like <code>ClassName#method</code> or
20
+ <code>ClassName.method</code> — while the skill is active, calls to these methods skip the
21
+ configured safety guards for that tool-call only).
44
22
  </div>
45
23
  </div>
46
24
 
@@ -5,14 +5,7 @@
5
5
  Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
6
6
  </p>
7
7
 
8
- <h3 style="margin:16px 0 6px;">Body</h3>
9
- <%= render_text_diff(@from.body, @to_body, left_label: "v##{@from.id}", right_label: @to_label) %>
10
-
11
- <h3 style="margin:24px 0 6px;">Tags</h3>
12
- <%= render_json_diff(Array(@from.tags), @to_tags, left_label: "v##{@from.id}", right_label: @to_label) %>
13
-
14
- <h3 style="margin:24px 0 6px;">Bypass guards</h3>
15
- <%= render_json_diff(Array(@from.bypass_guards_for_methods), @to_bypass, left_label: "v##{@from.id}", right_label: @to_label) %>
8
+ <%= render_text_diff(@from.content, @to_content, left_label: "v##{@from.id}", right_label: @to_label) %>
16
9
 
17
10
  <div class="btn-bar" style="margin-top:24px;">
18
11
  <% if @to.nil? %>
@@ -1,23 +1,6 @@
1
1
  <%= link_to '← All skills', skills_path, class: 'back-link' %>
2
2
  <h2 style="margin-bottom:16px;">New skill</h2>
3
3
 
4
- <%= form_with url: import_skills_path, method: :post, local: true do %>
5
- <div class="form-card" style="background:#f8f9fa;">
6
- <div class="form-row" style="margin-bottom:8px;">
7
- <label>Paste a .md file (optional)</label>
8
- <textarea name="content" rows="14" placeholder="---&#10;name: My skill&#10;description: What it does&#10;tags: [admin]&#10;bypass_guards_for_methods: []&#10;---&#10;&#10;## When to use&#10;…&#10;&#10;## Recipe&#10;1. …"></textarea>
9
- <div class="hint">
10
- Paste the full contents of a Markdown file (YAML frontmatter between
11
- <code>---</code> lines, then a markdown body) — same format as
12
- <code>.rails_console_ai/skills/*.md</code>. The fields below will be
13
- prefilled from the paste. You can review and tweak before clicking
14
- <strong>Create skill</strong>.
15
- </div>
16
- </div>
17
- <button type="submit" class="btn btn-secondary">Parse pasted content ↓</button>
18
- </div>
19
- <% end %>
20
-
21
4
  <%= render 'form',
22
5
  form_url: skills_path,
23
6
  form_method: :post,
data/config/routes.rb CHANGED
@@ -8,7 +8,6 @@ RailsConsoleAi::Engine.routes.draw do
8
8
  end
9
9
  collection do
10
10
  get :diff
11
- post :import
12
11
  end
13
12
  resources :versions, only: [:index, :show], controller: 'skill_versions' do
14
13
  member do
@@ -20,7 +19,6 @@ RailsConsoleAi::Engine.routes.draw do
20
19
  resources :memories do
21
20
  collection do
22
21
  get :diff
23
- post :import
24
22
  end
25
23
  resources :versions, only: [:index, :show], controller: 'memory_versions' do
26
24
  member do
@@ -35,7 +33,6 @@ RailsConsoleAi::Engine.routes.draw do
35
33
  end
36
34
  collection do
37
35
  get :diff
38
- post :import
39
36
  end
40
37
  resources :versions, only: [:index, :show], controller: 'agent_versions' do
41
38
  member do
@@ -159,16 +159,10 @@ module RailsConsoleAi
159
159
  key = agent_key(name)
160
160
  existing = load_agent_file(key)
161
161
 
162
- frontmatter = {
163
- 'name' => name,
164
- 'description' => description
165
- }
166
- frontmatter['max_rounds'] = max_rounds if max_rounds
167
- frontmatter['model'] = model if model
168
- tool_list = Array(tools)
169
- frontmatter['tools'] = tool_list unless tool_list.empty?
170
-
171
- content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
162
+ content = self.class.dump(
163
+ name: name, description: description, body: body,
164
+ max_rounds: max_rounds, model: model, tools: tools
165
+ )
172
166
  @storage.write(key, content)
173
167
 
174
168
  path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
@@ -210,5 +204,19 @@ module RailsConsoleAi
210
204
  rescue Psych::SyntaxError
211
205
  nil
212
206
  end
207
+
208
+ # Inverse of parse: emit a canonical .md (frontmatter + body) string from
209
+ # structured attrs.
210
+ def self.dump(name:, description:, body:, max_rounds: nil, model: nil, tools: nil)
211
+ frontmatter = {
212
+ 'name' => name,
213
+ 'description' => description
214
+ }
215
+ frontmatter['max_rounds'] = max_rounds if max_rounds
216
+ frontmatter['model'] = model if model
217
+ tool_list = Array(tools)
218
+ frontmatter['tools'] = tool_list unless tool_list.empty?
219
+ "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
220
+ end
213
221
  end
214
222
  end