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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7455c7e1d55abd75c69bfa99621f1f83ec53a5faa2313e3095b3d80b88ec9938
4
- data.tar.gz: 0a12cdef783d5f101d0c45b7fce9876e54bf31b1874fc4178a5d2103064ecb0a
3
+ metadata.gz: '083adc4ffd8b69f950fbc4ab18f2ac056e8642c6946c8ba46e67e19e4299e0f2'
4
+ data.tar.gz: a01fef0fe45dfa157d638a7d4edb1ee5c8d9753be3a79956b3ebde220d3ee939
5
5
  SHA512:
6
- metadata.gz: 7433c944553f7c683b61e58653e76138e389e41ff29dff45c28fb344913a50514d2a58783fca2c916ff6f86a46a4d71cbad47803625e8078aa25895a2b12a057
7
- data.tar.gz: 6cb61503f513cf4864fd918e95719f167733fc94ef001bb61290f6e0b262be0b2a3d83a936904ea7100096103c6c5c8145f74f5e99536088e45ad3aeb90d5a99
6
+ metadata.gz: 56b8fdcd0cf445d9480d4e56e252271241af017c8ca51e2a11af36d2158ce4a3a1fdf9670685f931b8cbd0994e6828e6bac08d10247e8bec1af8c66ad83c47f4
7
+ data.tar.gz: 6ccb72b8b8e9b157b28722d1e532d2abf5df8037f4ba4f1f3635bb37ad8a3441e48d832d903f6f92b6dce80106082215368dde15626eb0fc41d93da60100f261
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.31.0]
6
+
7
+ - Simplify DB-backed skills, sub-agents, and memories to a single content field per record, replacing the separate frontmatter/body columns and streamlining the models, controllers, and forms
8
+ - Add options for running agents
9
+ - Fix the `/script/release` script
10
+
5
11
  ## [0.30.0]
6
12
 
7
13
  - Add background agent support — new `run_agent` mechanism to launch agents asynchronously alongside an HTTP API channel
data/README.md CHANGED
@@ -521,6 +521,23 @@ RailsConsoleAi.get_agent_response(id)
521
521
 
522
522
  `run_agent` enqueues a row in the sessions table with `mode='agent_api'` and `status='queued'`. A separate long-running rake task picks them up and runs each in its own thread using the same engine that powers `ai "..."` in the console.
523
523
 
524
+ ### Per-run options
525
+
526
+ `run_agent` accepts two extra keyword arguments to tune individual runs:
527
+
528
+ ```ruby
529
+ RailsConsoleAi.run_agent(
530
+ "Trace why nightly billing is double-charging some accounts",
531
+ use_thinking_model: true, # run on the thinking-tier model (e.g. Opus)
532
+ max_wall_clock_seconds: 1800 # hard kill after 30 minutes; pass nil for no cap
533
+ )
534
+ ```
535
+
536
+ - `use_thinking_model:` (default `false`) — switches the run to `config.thinking_model` (or the provider default thinking model) for the duration of the agent. Useful for harder, multi-step problems.
537
+ - `max_wall_clock_seconds:` (default `600`) — hard ceiling on wall-clock time. If the run exceeds the cap, the worker thread is killed and the session is marked `status='failed'` with `error_message: "exceeded max_wall_clock_seconds (Ns)"`. Pass `nil` to opt out of any cap.
538
+
539
+ These (along with any future per-run options) are stored in a JSON `options` column on the session row, so they survive the handoff to the background runner.
540
+
524
541
  ### Running the background runner
525
542
 
526
543
  ```bash
@@ -535,7 +552,7 @@ Run it separately from your web server, alongside (or instead of) the Slack bot.
535
552
 
536
553
  ### Requirements
537
554
 
538
- - `RailsConsoleAi.setup!` must have been run so the sessions table has the `status`, `result`, and `error_message` columns. `ai_db_setup` (or `ai_db_migrate` on existing installs) handles this.
555
+ - `RailsConsoleAi.setup!` must have been run so the sessions table has the `status`, `result`, `error_message`, and `options` columns. `ai_db_setup` (or `ai_db_migrate` on existing installs) handles this.
539
556
  - `session_logging` must be enabled (it is by default).
540
557
 
541
558
  ### Behavior notes
@@ -13,14 +13,7 @@ module RailsConsoleAi
13
13
  def restore
14
14
  version = @agent.versions.find(params[:id])
15
15
  @agent.update_with_version!(
16
- {
17
- name: version.name,
18
- description: version.description,
19
- body: version.body,
20
- max_rounds: version.max_rounds,
21
- model: version.model,
22
- tools: Array(version.tools)
23
- },
16
+ { content: version.content },
24
17
  edited_by: params[:edited_by].presence || 'web',
25
18
  change_note: "Restored from version ##{version.id}"
26
19
  )
@@ -32,14 +32,17 @@ module RailsConsoleAi
32
32
  a['source'] == :builtin && a['name'].to_s.downcase == params[:from_builtin].to_s.downcase
33
33
  }
34
34
  if builtin
35
- @agent.name = builtin['name']
36
- @agent.description = builtin['description']
37
- @agent.body = builtin['body']
38
- @agent.max_rounds = builtin['max_rounds']
39
- @agent.model = builtin['model']
40
- @agent.tools = Array(builtin['tools'])
35
+ @agent.content = AgentLoader.dump(
36
+ name: builtin['name'],
37
+ description: builtin['description'],
38
+ body: builtin['body'],
39
+ max_rounds: builtin['max_rounds'],
40
+ model: builtin['model'],
41
+ tools: Array(builtin['tools'])
42
+ )
41
43
  end
42
44
  end
45
+ @agent.content ||= new_agent_template
43
46
  end
44
47
 
45
48
  def create
@@ -57,34 +60,6 @@ module RailsConsoleAi
57
60
  end
58
61
  end
59
62
 
60
- # POST /agents/import — parse a pasted .md blob and re-render `new` with fields prefilled.
61
- def import
62
- content = params[:content].to_s
63
- if content.strip.empty?
64
- redirect_to new_agent_path, alert: 'Nothing to parse — paste the .md content into the box first.'
65
- return
66
- end
67
-
68
- parsed = AgentLoader.parse(content)
69
- if parsed.nil? || parsed['name'].to_s.strip.empty?
70
- redirect_to new_agent_path,
71
- alert: 'Could not parse. Expected YAML frontmatter (between `---` lines) with at least a `name` field, followed by the agent body.'
72
- return
73
- end
74
-
75
- @agent = Agent.new(
76
- name: parsed['name'],
77
- description: parsed['description'],
78
- body: parsed['body'],
79
- max_rounds: parsed['max_rounds'],
80
- model: parsed['model']
81
- )
82
- @agent.tools = Array(parsed['tools'])
83
-
84
- flash.now[:notice] = "Parsed \"#{parsed['name']}\" from pasted content. Review the fields below and click Create agent to save to the DB."
85
- render :new
86
- end
87
-
88
63
  def edit
89
64
  redirect_to agents_path, alert: read_only_message and return unless @agent.is_a?(RailsConsoleAi::Agent)
90
65
  end
@@ -136,9 +111,8 @@ module RailsConsoleAi
136
111
  @agent = Agent.find(params[:agent_id])
137
112
  @from = @agent.versions.find(params[:from])
138
113
  @to = params[:to].present? ? @agent.versions.find(params[:to]) : nil
139
- @to_label = @to ? "Version ##{@to.id}" : 'Current'
140
- @to_body = @to ? @to.body : @agent.body
141
- @to_tools = @to ? Array(@to.tools) : Array(@agent.tools)
114
+ @to_label = @to ? "Version ##{@to.id}" : 'Current'
115
+ @to_content = @to ? @to.content : @agent.content
142
116
  end
143
117
 
144
118
  private
@@ -163,22 +137,25 @@ module RailsConsoleAi
163
137
  end
164
138
 
165
139
  def agent_params
166
- {
167
- name: params.require(:agent)[:name],
168
- description: params[:agent][:description],
169
- body: params[:agent][:body],
170
- max_rounds: params[:agent][:max_rounds].presence&.to_i,
171
- model: params[:agent][:model].presence,
172
- tools: split_lines(params[:agent][:tools])
173
- }
140
+ { content: params.require(:agent)[:content].to_s }
174
141
  end
175
142
 
176
143
  def edited_by_param
177
144
  params[:edited_by].presence || 'web'
178
145
  end
179
146
 
180
- def split_lines(str)
181
- str.to_s.split(/[\r\n,]+/).map(&:strip).reject(&:empty?)
147
+ def new_agent_template
148
+ <<~MD
149
+ ---
150
+ name:
151
+ description:
152
+ max_rounds:
153
+ model:
154
+ tools: []
155
+ ---
156
+
157
+ Persona, strategy, rules…
158
+ MD
182
159
  end
183
160
 
184
161
  def slugify(name)
@@ -25,32 +25,7 @@ module RailsConsoleAi
25
25
  end
26
26
 
27
27
  def new
28
- @memory = Memory.new
29
- end
30
-
31
- # POST /memories/import — parse a pasted .md blob and re-render `new` with fields prefilled.
32
- def import
33
- content = params[:content].to_s
34
- if content.strip.empty?
35
- redirect_to new_memory_path, alert: 'Nothing to parse — paste the .md content into the box first.'
36
- return
37
- end
38
-
39
- parsed = Tools::MemoryTools.parse(content)
40
- if parsed.nil? || parsed['name'].to_s.strip.empty?
41
- redirect_to new_memory_path,
42
- alert: 'Could not parse. Expected YAML frontmatter (between `---` lines) with at least a `name` field, followed by the memory body.'
43
- return
44
- end
45
-
46
- @memory = Memory.new(
47
- name: parsed['name'],
48
- description: parsed['description']
49
- )
50
- @memory.tags = Array(parsed['tags'])
51
-
52
- flash.now[:notice] = "Parsed \"#{parsed['name']}\" from pasted content. Review the fields below and click Create memory to save to the DB."
53
- render :new
28
+ @memory = Memory.new(content: new_memory_template)
54
29
  end
55
30
 
56
31
  def create
@@ -102,9 +77,8 @@ module RailsConsoleAi
102
77
  @memory = Memory.find(params[:memory_id])
103
78
  @from = @memory.versions.find(params[:from])
104
79
  @to = params[:to].present? ? @memory.versions.find(params[:to]) : nil
105
- @to_label = @to ? "Version ##{@to.id}" : 'Current'
106
- @to_description = @to ? @to.description : @memory.description
107
- @to_tags = @to ? Array(@to.tags) : Array(@memory.tags)
80
+ @to_label = @to ? "Version ##{@to.id}" : 'Current'
81
+ @to_content = @to ? @to.content : @memory.content
108
82
  end
109
83
 
110
84
  private
@@ -130,19 +104,22 @@ module RailsConsoleAi
130
104
  end
131
105
 
132
106
  def memory_params
133
- {
134
- name: params.require(:memory)[:name],
135
- description: params[:memory][:description],
136
- tags: split_csv(params[:memory][:tags])
137
- }
107
+ { content: params.require(:memory)[:content].to_s }
138
108
  end
139
109
 
140
110
  def edited_by_param
141
111
  params[:edited_by].presence || 'web'
142
112
  end
143
113
 
144
- def split_csv(str)
145
- str.to_s.split(',').map(&:strip).reject(&:empty?)
114
+ def new_memory_template
115
+ <<~MD
116
+ ---
117
+ name:
118
+ tags: []
119
+ ---
120
+
121
+ The fact or pattern you're persisting.
122
+ MD
146
123
  end
147
124
 
148
125
  def slugify(name)
@@ -13,11 +13,7 @@ module RailsConsoleAi
13
13
  def restore
14
14
  version = @memory.versions.find(params[:id])
15
15
  @memory.update_with_version!(
16
- {
17
- name: version.name,
18
- description: version.description,
19
- tags: Array(version.tags)
20
- },
16
+ { content: version.content },
21
17
  edited_by: params[:edited_by].presence || 'web',
22
18
  change_note: "Restored from version ##{version.id}"
23
19
  )
@@ -13,13 +13,7 @@ module RailsConsoleAi
13
13
  def restore
14
14
  version = @skill.versions.find(params[:id])
15
15
  @skill.update_with_version!(
16
- {
17
- name: version.name,
18
- description: version.description,
19
- body: version.body,
20
- tags: Array(version.tags),
21
- bypass_guards_for_methods: Array(version.bypass_guards_for_methods)
22
- },
16
+ { content: version.content },
23
17
  edited_by: params[:edited_by].presence || 'web',
24
18
  change_note: "Restored from version ##{version.id}"
25
19
  )
@@ -26,37 +26,7 @@ module RailsConsoleAi
26
26
  end
27
27
 
28
28
  def new
29
- @skill = Skill.new
30
- end
31
-
32
- # POST /skills/import — accepts a pasted .md blob in params[:content], parses
33
- # YAML frontmatter + body, and re-renders `new` with the fields pre-populated.
34
- # The user reviews + clicks Create skill to actually persist (normal proposed-
35
- # status + version-row flow applies).
36
- def import
37
- content = params[:content].to_s
38
- if content.strip.empty?
39
- redirect_to new_skill_path, alert: 'Nothing to parse — paste the .md content into the box first.'
40
- return
41
- end
42
-
43
- parsed = SkillLoader.parse(content)
44
- if parsed.nil? || parsed['name'].to_s.strip.empty?
45
- redirect_to new_skill_path,
46
- alert: 'Could not parse. Expected YAML frontmatter (between `---` lines) with at least a `name` field, followed by a markdown body.'
47
- return
48
- end
49
-
50
- @skill = Skill.new(
51
- name: parsed['name'],
52
- description: parsed['description'],
53
- body: parsed['body']
54
- )
55
- @skill.tags = Array(parsed['tags'])
56
- @skill.bypass_guards_for_methods = Array(parsed['bypass_guards_for_methods'])
57
-
58
- flash.now[:notice] = "Parsed \"#{parsed['name']}\" from pasted content. Review the fields below and click Create skill to save to the DB."
59
- render :new
29
+ @skill = Skill.new(content: new_skill_template)
60
30
  end
61
31
 
62
32
  def create
@@ -130,10 +100,8 @@ module RailsConsoleAi
130
100
  @from = @skill.versions.find(params[:from])
131
101
  @to = params[:to].present? ? @skill.versions.find(params[:to]) : nil
132
102
  # If `to` is omitted, diff against the current skill.
133
- @to_label = @to ? "Version ##{@to.id}" : 'Current'
134
- @to_body = @to ? @to.body : @skill.body
135
- @to_tags = @to ? Array(@to.tags) : Array(@skill.tags)
136
- @to_bypass = @to ? Array(@to.bypass_guards_for_methods) : Array(@skill.bypass_guards_for_methods)
103
+ @to_label = @to ? "Version ##{@to.id}" : 'Current'
104
+ @to_content = @to ? @to.content : @skill.content
137
105
  end
138
106
 
139
107
  private
@@ -165,25 +133,28 @@ module RailsConsoleAi
165
133
  end
166
134
 
167
135
  def skill_params
168
- {
169
- name: params.require(:skill)[:name],
170
- description: params[:skill][:description],
171
- body: params[:skill][:body],
172
- tags: split_csv(params[:skill][:tags]),
173
- bypass_guards_for_methods: split_lines(params[:skill][:bypass_guards_for_methods])
174
- }
136
+ { content: params.require(:skill)[:content].to_s }
175
137
  end
176
138
 
177
139
  def edited_by_param
178
140
  params[:edited_by].presence || 'web'
179
141
  end
180
142
 
181
- def split_csv(str)
182
- str.to_s.split(',').map(&:strip).reject(&:empty?)
183
- end
143
+ def new_skill_template
144
+ <<~MD
145
+ ---
146
+ name:
147
+ description:
148
+ tags: []
149
+ bypass_guards_for_methods: []
150
+ ---
151
+
152
+ ## When to use
153
+
154
+ ## Recipe
184
155
 
185
- def split_lines(str)
186
- str.to_s.split(/[\r\n]+/).map(&:strip).reject(&:empty?)
156
+ ## Notes
157
+ MD
187
158
  end
188
159
 
189
160
  def slugify(name)
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/agent_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class Agent < 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 agent 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 max_rounds model tools].freeze
15
-
16
11
  has_many :versions,
17
12
  -> { order(created_at: :desc) },
18
13
  class_name: 'RailsConsoleAi::AgentVersion',
19
14
  foreign_key: :agent_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,43 +35,25 @@ module RailsConsoleAi
36
35
  end
37
36
  end
38
37
 
39
- # Manual JSON accessor for `tools` — same approach we use for skill tags,
40
- # avoids Rails-version-specific `serialize` API.
41
- def tools
42
- decode_json_array(read_attribute(:tools))
43
- end
44
-
45
- def tools=(value)
46
- write_attribute(:tools, encode_json_array(value))
38
+ def parsed
39
+ @parsed ||= (RailsConsoleAi::AgentLoader.parse(content.to_s) || {})
47
40
  end
48
41
 
49
- # Defensive accessors — if `ai_db_migrate` hasn't been run yet, the status
50
- # / approval columns may be missing on an older table.
51
- def status
52
- has_attribute_status? ? read_attribute(:status) : STATUS_PROPOSED
42
+ def content=(value)
43
+ @parsed = nil
44
+ super
53
45
  end
54
46
 
55
- def approved_by
56
- has_attribute?(:approved_by) ? read_attribute(:approved_by) : nil
57
- end
58
-
59
- def approved_at
60
- has_attribute?(:approved_at) ? read_attribute(:approved_at) : nil
61
- end
47
+ def description; parsed['description']; end
48
+ def body; parsed['body']; end
49
+ def max_rounds; parsed['max_rounds']; end
50
+ def model; parsed['model']; end
51
+ def tools; Array(parsed['tools']); end
62
52
 
63
53
  def proposed?; status.to_s == STATUS_PROPOSED; end
64
54
  def approved?; status.to_s == STATUS_APPROVED; end
65
55
 
66
- def use_count
67
- has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
68
- end
69
-
70
- def last_used_at
71
- has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
72
- end
73
-
74
56
  def self.record_use!(id)
75
- return false unless connection.column_exists?(table_name, :use_count)
76
57
  where(id: id).update_all([
77
58
  'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
78
59
  Time.now.utc
@@ -92,6 +73,7 @@ module RailsConsoleAi
92
73
  'max_rounds' => max_rounds,
93
74
  'model' => model,
94
75
  'tools' => tools,
76
+ 'content' => content,
95
77
  'status' => status,
96
78
  'approved_by' => approved_by,
97
79
  'approved_at' => approved_at,
@@ -102,33 +84,11 @@ module RailsConsoleAi
102
84
  }
103
85
  end
104
86
 
105
- def has_attribute_status?
106
- has_attribute?(:status)
107
- end
108
-
109
- def self.decode_json_array(raw)
110
- return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
111
- return raw if raw.is_a?(Array)
112
- JSON.parse(raw)
113
- rescue JSON::ParserError
114
- []
115
- end
116
-
117
- def self.encode_json_array(value)
118
- JSON.dump(Array(value))
119
- end
120
-
121
- def decode_json_array(raw); self.class.decode_json_array(raw); end
122
- def encode_json_array(value); self.class.encode_json_array(value); end
123
-
124
- # Assigns attrs, saves, and records one AgentVersion snapshot of the post-save state.
125
- # If `preserve_approval` is false (the default), any change to a content attribute
126
- # reverts the agent back to "proposed" and clears the approver.
127
87
  def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
128
88
  transaction do
129
89
  assign_attributes(attrs)
130
90
 
131
- if !preserve_approval && approved? && content_dirty?
91
+ if !preserve_approval && approved? && changes.key?('content')
132
92
  self.status = STATUS_PROPOSED
133
93
  self.approved_by = nil
134
94
  self.approved_at = nil
@@ -138,11 +98,7 @@ module RailsConsoleAi
138
98
  RailsConsoleAi::AgentVersion.create!(
139
99
  agent_id: id,
140
100
  name: name,
141
- description: description,
142
- body: body,
143
- max_rounds: max_rounds,
144
- model: model,
145
- tools: tools,
101
+ content: content,
146
102
  status: status,
147
103
  edited_by: edited_by,
148
104
  change_note: change_note
@@ -168,8 +124,20 @@ module RailsConsoleAi
168
124
 
169
125
  private
170
126
 
171
- def content_dirty?
172
- CONTENT_ATTRIBUTES.any? { |a| changes.key?(a) }
127
+ def sync_name_from_content
128
+ return if content.to_s.strip.empty?
129
+ parsed_name = parsed['name'].to_s.strip
130
+ self.name = parsed_name unless parsed_name.empty?
131
+ end
132
+
133
+ def content_parses
134
+ return if content.to_s.strip.empty?
135
+ hash = RailsConsoleAi::AgentLoader.parse(content.to_s)
136
+ if hash.nil?
137
+ errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
138
+ elsif hash['name'].to_s.strip.empty?
139
+ errors.add(:content, "frontmatter is missing a `name:` field")
140
+ end
173
141
  end
174
142
  end
175
143
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/agent_loader'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class AgentVersion < ActiveRecord::Base
@@ -21,26 +21,14 @@ module RailsConsoleAi
21
21
  end
22
22
  end
23
23
 
24
- def tools
25
- decode_json_array(read_attribute(:tools))
24
+ def parsed
25
+ @parsed ||= (RailsConsoleAi::AgentLoader.parse(content.to_s) || {})
26
26
  end
27
27
 
28
- def tools=(value)
29
- write_attribute(:tools, 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 body; parsed['body']; end
30
+ def max_rounds; parsed['max_rounds']; end
31
+ def model; parsed['model']; end
32
+ def tools; Array(parsed['tools']); end
45
33
  end
46
34
  end
@@ -1,4 +1,4 @@
1
- require 'json'
1
+ require 'rails_console_ai/tools/memory_tools'
2
2
 
3
3
  module RailsConsoleAi
4
4
  class Memory < ActiveRecord::Base
@@ -10,7 +10,11 @@ module RailsConsoleAi
10
10
  foreign_key: :memory_id,
11
11
  dependent: :nullify
12
12
 
13
+ validates :content, presence: true
13
14
  validates :name, presence: true, uniqueness: { case_sensitive: false }
15
+ validate :content_parses
16
+
17
+ before_validation :sync_name_from_content
14
18
 
15
19
  scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
16
20
 
@@ -24,24 +28,21 @@ module RailsConsoleAi
24
28
  end
25
29
  end
26
30
 
27
- def tags
28
- decode_json_array(read_attribute(:tags))
31
+ def parsed
32
+ @parsed ||= (RailsConsoleAi::Tools::MemoryTools.parse(content.to_s) || {})
29
33
  end
30
34
 
31
- def tags=(value)
32
- write_attribute(:tags, encode_json_array(value))
35
+ def content=(value)
36
+ @parsed = nil
37
+ super
33
38
  end
34
39
 
35
- def use_count
36
- has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
37
- end
38
-
39
- def last_used_at
40
- has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
41
- end
40
+ # Memories don't have a separate description vs body — the markdown body
41
+ # IS the memory. Parser exposes it under 'description'.
42
+ def description; parsed['description']; end
43
+ def tags; Array(parsed['tags']); end
42
44
 
43
45
  def self.record_use!(id)
44
- return false unless connection.column_exists?(table_name, :use_count)
45
46
  where(id: id).update_all([
46
47
  'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
47
48
  Time.now.utc
@@ -58,6 +59,7 @@ module RailsConsoleAi
58
59
  'name' => name,
59
60
  'description' => description,
60
61
  'tags' => tags,
62
+ 'content' => content,
61
63
  'use_count' => use_count,
62
64
  'last_used_at' => last_used_at,
63
65
  'source' => :db,
@@ -72,8 +74,7 @@ module RailsConsoleAi
72
74
  RailsConsoleAi::MemoryVersion.create!(
73
75
  memory_id: id,
74
76
  name: name,
75
- description: description,
76
- tags: tags,
77
+ content: content,
77
78
  edited_by: edited_by,
78
79
  change_note: change_note
79
80
  )
@@ -83,16 +84,20 @@ module RailsConsoleAi
83
84
 
84
85
  private
85
86
 
86
- def decode_json_array(raw)
87
- return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
88
- return raw if raw.is_a?(Array)
89
- JSON.parse(raw)
90
- rescue JSON::ParserError
91
- []
87
+ def sync_name_from_content
88
+ return if content.to_s.strip.empty?
89
+ parsed_name = parsed['name'].to_s.strip
90
+ self.name = parsed_name unless parsed_name.empty?
92
91
  end
93
92
 
94
- def encode_json_array(value)
95
- JSON.dump(Array(value))
93
+ def content_parses
94
+ return if content.to_s.strip.empty?
95
+ hash = RailsConsoleAi::Tools::MemoryTools.parse(content.to_s)
96
+ if hash.nil?
97
+ errors.add(:content, "could not be parsed — expected YAML frontmatter between `---` lines followed by a markdown body")
98
+ elsif hash['name'].to_s.strip.empty?
99
+ errors.add(:content, "frontmatter is missing a `name:` field")
100
+ end
96
101
  end
97
102
  end
98
103
  end