ask-rails 0.3.0 → 0.11.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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Tools
6
+ class SchemaGraph < Ask::Rails::Tool
7
+ description "Return the full application schema graph — all models, tables, columns with types, " \
8
+ "associations (belongs_to, has_many, has_one, HABTM, through), validations, indexes, " \
9
+ "and polymorphic relationships. One call gives the agent a complete mental model " \
10
+ "of the application's data layer."
11
+
12
+ param :detail, type: :string, desc: "Detail level: 'all' (default), 'models', 'associations', 'tables'", required: false
13
+
14
+ TABLE_EXCLUSIONS = %w[schema_migrations ar_internal_metadata].freeze
15
+
16
+ def execute(detail: "all")
17
+ models = discover_ar_models
18
+
19
+ result = {
20
+ summary: {
21
+ model_count: models.size,
22
+ table_count: models.map { |m| m.table_name }.uniq.size,
23
+ association_count: models.sum { |m| m.reflect_on_all_associations.size }
24
+ },
25
+ models: %w[all models].include?(detail) ? build_model_details(models, detail) : nil,
26
+ associations: %w[all associations].include?(detail) ? build_association_graph(models) : nil,
27
+ tables: %w[all tables].include?(detail) ? build_table_details(detail) : nil
28
+ }
29
+
30
+ result
31
+ end
32
+
33
+ private
34
+
35
+ def discover_ar_models
36
+ if defined?(::Rails::Application) && ::Rails.application
37
+ ::Rails.application.eager_load! rescue nil
38
+ end
39
+ ActiveRecord::Base.descendants.reject do |klass|
40
+ klass.abstract_class? ||
41
+ klass.name.nil? ||
42
+ TABLE_EXCLUSIONS.include?(klass.table_name) ||
43
+ klass.name.start_with?("ActiveRecord::", "ActiveStorage::", "ActionText::")
44
+ end.sort_by(&:name)
45
+ rescue StandardError
46
+ []
47
+ end
48
+
49
+ def build_model_details(models, detail)
50
+ models.filter_map do |klass|
51
+ name = klass.name rescue nil
52
+ next nil unless name
53
+
54
+ entry = {
55
+ name: name,
56
+ table_name: klass.table_name,
57
+ primary_key: safe_primary_key(klass)
58
+ }
59
+
60
+ if klass.respond_to?(:columns)
61
+ entry[:columns] = safe_columns(klass)
62
+ end
63
+
64
+ if klass.respond_to?(:reflect_on_all_associations)
65
+ entry[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
66
+ refs.map { |a|
67
+ assoc = { name: a.name, class_name: a.class_name }
68
+ assoc[:through] = a.options[:through] if a.options[:through]
69
+ assoc[:source] = a.options[:source] if a.options[:source]
70
+ assoc[:foreign_key] = a.foreign_key
71
+ assoc[:polymorphic] = true if a.polymorphic?
72
+ assoc[:as] = a.options[:as] if a.options[:as]
73
+ assoc[:dependent] = a.options[:dependent] if a.options[:dependent]
74
+ assoc
75
+ }
76
+ }
77
+ end
78
+
79
+ if klass.respond_to?(:validators)
80
+ entry[:validators] = klass.validators.map { |v|
81
+ {
82
+ attribute: v.attributes.first&.to_s,
83
+ kind: v.kind,
84
+ options: v.options.reject { |k, _| k == :if }
85
+ }
86
+ }.reject { |v| v[:attribute].nil? }
87
+ end
88
+
89
+ entry
90
+ end
91
+ end
92
+
93
+ def build_association_graph(models)
94
+ edges = []
95
+
96
+ models.each do |klass|
97
+ klass.reflect_on_all_associations.each do |a|
98
+ next if a.macro == :has_many && a.options[:through] # Skip through associations (derived)
99
+
100
+ target_model = find_model_for_association(models, a)
101
+ next unless target_model
102
+
103
+ edges << {
104
+ from: klass.name,
105
+ to: target_model.name,
106
+ type: a.macro,
107
+ via: a.name,
108
+ foreign_key: a.foreign_key,
109
+ polymorphic: a.polymorphic? || false
110
+ }
111
+ end
112
+ end
113
+
114
+ edges
115
+ end
116
+
117
+ def find_model_for_association(models, association)
118
+ class_name = association.class_name
119
+ # Try exact match first, then match by class name suffix (handles namespace issues)
120
+ models.find { |m| m.name == class_name } ||
121
+ models.find { |m| m.name.end_with?("::#{class_name}") } ||
122
+ models.find { |m| class_name.end_with?("::#{m.name}") }
123
+ end
124
+
125
+ def build_table_details(detail)
126
+ models = discover_ar_models
127
+ tables = {}
128
+
129
+ models.each do |klass|
130
+ table = klass.table_name
131
+ next if TABLE_EXCLUSIONS.include?(table)
132
+
133
+ tables[table] = {
134
+ model: klass.name,
135
+ columns: safe_columns(klass),
136
+ indexes: fetch_indexes_for(table)
137
+ }
138
+ end
139
+
140
+ tables
141
+ end
142
+
143
+ def safe_primary_key(klass)
144
+ klass.primary_key
145
+ rescue StandardError
146
+ nil
147
+ end
148
+
149
+ def safe_columns(klass)
150
+ klass.columns.map { |c|
151
+ col = { name: c.name, type: c.type, null: c.null }
152
+ col[:default] = c.default unless c.default.nil?
153
+ col[:primary_key] = true if c.name == klass.primary_key
154
+ col
155
+ }
156
+ rescue StandardError
157
+ []
158
+ end
159
+
160
+ def fetch_indexes_for(table_name)
161
+ ActiveRecord::Base.connection.indexes(table_name).map do |idx|
162
+ {
163
+ name: idx.name,
164
+ columns: idx.columns,
165
+ unique: idx.unique
166
+ }
167
+ end
168
+ rescue StandardError
169
+ []
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -6,15 +6,14 @@ module Ask
6
6
  class SearchCodebase < Ask::Rails::Tool
7
7
  description "Search the Rails codebase with grep."
8
8
  param :pattern, type: :string, desc: "Search pattern", required: true
9
- param :path, type: :string, desc: "Subdirectory to search (optional)"
9
+ param :path, type: :string, desc: "Subdirectory to search (optional)", required: false
10
10
 
11
11
  def execute(pattern:, path: nil)
12
- search_path = path ? rails_root.join(path) : rails_root
13
- results = `cd #{rails_root} && grep -rn '#{pattern}' #{search_path} 2>&1 | head -50`
14
- Ask::Result.success(
15
- data: { results: results, count: results.lines.count },
16
- metadata: { pattern: pattern, count: results.lines.count }
17
- )
12
+ search_path = (path ? rails_root.join(path) : rails_root).to_s
13
+ escaped_pattern = pattern.gsub("'", "'\\\\''")
14
+ escaped_path = search_path.gsub("'", "'\\\\''")
15
+ results = `cd #{rails_root} && grep -rn '#{escaped_pattern}' #{escaped_path} 2>&1 | head -50`
16
+ { results: results.lines.map(&:chomp), count: results.lines.count }
18
17
  end
19
18
  end
20
19
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Rails
5
- VERSION = "0.3.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end
data/lib/ask/rails.rb CHANGED
@@ -16,21 +16,41 @@ module Ask
16
16
  end
17
17
 
18
18
  def agent_session(**extra)
19
+ # Auto-prune if configured
20
+ cleanup! if configuration.max_session_age || configuration.max_sessions
21
+
19
22
  tools = configuration.tools.map { |t| t.is_a?(Class) ? t.new : t }
20
23
  prompt = extra.delete(:system_prompt) || configuration.system_prompt || default_system_prompt
21
24
 
25
+ # Resolve environment-specific permissions and wire into agent hooks
26
+ hooks = build_environment_hooks
27
+
22
28
  Ask::Agent::Session.new(
23
29
  model: configuration.default_model,
24
30
  max_turns: configuration.max_turns,
25
31
  system_prompt: prompt,
26
32
  tools: tools,
27
- persistence: configuration.persistence_adapter,
33
+ state: configuration.persistence_adapter,
34
+ hooks: hooks,
28
35
  **extra
29
36
  )
30
37
  end
31
38
 
32
39
  def discover_tools!
33
- self.configuration.tools = Ask::Tools::Shell::TOOLS.map(&:new) + discovered_rails_tools
40
+ self.configuration.tools = Ask::Tools::Shell::TOOLS.map(&:new) + core_rails_tools + discovered_user_tools
41
+ end
42
+
43
+ # Prune old sessions and audit logs based on configuration limits.
44
+ #
45
+ # Removes sessions older than +max_session_age+ seconds, and limits the
46
+ # total number of sessions to +max_sessions+ (deleting the oldest first).
47
+ # Audit log entries older than the oldest kept session are also removed.
48
+ #
49
+ # Call manually via rake task or cron, or configure limits to auto-prune
50
+ # on agent_session creation.
51
+ def cleanup!
52
+ prune_old_sessions
53
+ limit_session_count
34
54
  end
35
55
 
36
56
  def root
@@ -39,7 +59,76 @@ module Ask
39
59
 
40
60
  private
41
61
 
42
- def discovered_rails_tools
62
+ def build_environment_hooks
63
+ env_mode = configuration.effective_mode
64
+ return {} unless env_mode
65
+
66
+ perms = Ask::Agent::Extensions::Permissions.new(mode: env_mode)
67
+ { before_tool: [perms.method(:before_tool_call)] }
68
+ rescue ArgumentError => e
69
+ warn "[ask-rails] Invalid environment mode: #{e.message}"
70
+ {}
71
+ end
72
+
73
+ def prune_old_sessions
74
+ age = configuration.max_session_age
75
+ return unless age&.> 0
76
+ return unless persistence_available?
77
+
78
+ cutoff = age.seconds.ago
79
+ count = 0
80
+
81
+ configuration.persistence_adapter.list.each do |id|
82
+ data = configuration.persistence_adapter.load(id)
83
+ created = data&.dig(:metadata, :created_at)
84
+ if created && Time.parse(created) < cutoff
85
+ configuration.persistence_adapter.delete(id)
86
+ count += 1
87
+ end
88
+ end
89
+
90
+ count
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ def limit_session_count
96
+ max = configuration.max_sessions
97
+ return unless max&.> 0
98
+ return unless persistence_available?
99
+
100
+ sessions = configuration.persistence_adapter.list
101
+ excess = sessions.size - max
102
+ return unless excess > 0
103
+
104
+ # Delete oldest sessions first
105
+ with_timestamps = sessions.map { |id|
106
+ data = configuration.persistence_adapter.load(id)
107
+ created = data&.dig(:metadata, :created_at)
108
+ [id, created ? Time.parse(created) : Time.at(0)]
109
+ }.sort_by(&:last)
110
+
111
+ with_timestamps.first(excess).each do |id, _|
112
+ configuration.persistence_adapter.delete(id)
113
+ end
114
+
115
+ excess
116
+ rescue StandardError
117
+ nil
118
+ end
119
+
120
+ def persistence_available?
121
+ defined?(ActiveRecord::Base) &&
122
+ ActiveRecord::Base.connection.data_source_exists?("ask_sessions")
123
+ rescue StandardError
124
+ false
125
+ end
126
+
127
+ def core_rails_tools
128
+ CORE_RAILS_TOOLS.map(&:new)
129
+ end
130
+
131
+ def discovered_user_tools
43
132
  tools = []
44
133
  files = Dir[::Rails.root.join("app", "tools", "*.rb")]
45
134
  files.each do |f|
@@ -67,6 +156,8 @@ end
67
156
  require_relative "rails/version"
68
157
  require_relative "rails/engine"
69
158
  require_relative "rails/configuration"
159
+ require_relative "rails/audit_log"
160
+ require_relative "rails/environment_permissions"
70
161
  require_relative "rails/auth"
71
162
  require_relative "rails/persistence"
72
163
  require_relative "rails/service_discovery"
@@ -78,8 +169,19 @@ require_relative "rails/tools/read_routes"
78
169
  require_relative "rails/tools/query_database"
79
170
  require_relative "rails/tools/read_model"
80
171
  require_relative "rails/tools/read_log"
172
+ require_relative "rails/tools/schema_graph"
173
+ require_relative "rails/tools/route_inspector"
81
174
 
82
175
  # Railtie is loaded only when Rails is fully available
83
176
  if defined?(::Rails::Railtie)
84
177
  require_relative "rails/railtie"
85
178
  end
179
+
180
+ # Define after all tool files are loaded so the constants resolve
181
+ Ask::Rails::CORE_RAILS_TOOLS = [
182
+ Ask::Rails::Tools::ReadFile, Ask::Rails::Tools::RunCommand,
183
+ Ask::Rails::Tools::SearchCodebase, Ask::Rails::Tools::ReadRoutes,
184
+ Ask::Rails::Tools::QueryDatabase, Ask::Rails::Tools::ReadModel,
185
+ Ask::Rails::Tools::ReadLog, Ask::Rails::Tools::SchemaGraph,
186
+ Ask::Rails::Tools::RouteInspector
187
+ ].freeze
@@ -19,6 +19,11 @@ module Ask
19
19
  template "migration.rb", "db/migrate/#{timestamp}_create_ask_sessions.rb"
20
20
  end
21
21
 
22
+ def create_audit_log_migration
23
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
24
+ template "audit_log_migration.rb", "db/migrate/#{timestamp}_create_ask_audit_logs.rb"
25
+ end
26
+
22
27
  def create_tools_directory
23
28
  empty_directory "app/tools"
24
29
  create_file "app/tools/.keep", ""
@@ -0,0 +1,22 @@
1
+ class CreateAskAuditLogs < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :ask_audit_logs do |t|
4
+ t.string :session_id, null: false
5
+ t.string :tool_name, null: false
6
+ t.jsonb :params
7
+ t.jsonb :result_summary
8
+ t.string :status, null: false, default: "success"
9
+ t.text :error_message
10
+ t.integer :duration_ms
11
+ t.jsonb :user_context
12
+ t.string :environment
13
+ t.datetime :recorded_at, null: false
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :ask_audit_logs, :session_id
19
+ add_index :ask_audit_logs, [:recorded_at, :tool_name]
20
+ add_index :ask_audit_logs, :status
21
+ end
22
+ end
@@ -1,5 +1,5 @@
1
1
  Ask::Rails.configure do |config|
2
2
  config.default_model = ENV.fetch("ASK_DEFAULT_MODEL", "gpt-4o")
3
3
  config.max_turns = ENV.fetch("ASK_MAX_TURNS", 25).to_i
4
- # config.persistence_adapter = Ask::Rails::Persistence.new
4
+ config.persistence_adapter = Ask::Rails::Persistence.new
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -147,14 +147,17 @@ files:
147
147
  - LICENSE
148
148
  - README.md
149
149
  - app/controllers/ask/rails/chat_controller.rb
150
+ - app/models/ask/rails/session.rb
150
151
  - app/views/ask/rails/chat/index.html.erb
151
152
  - app/views/layouts/ask/rails/application.html.erb
152
153
  - config/routes.rb
153
154
  - lib/ask-rails.rb
154
155
  - lib/ask/rails.rb
156
+ - lib/ask/rails/audit_log.rb
155
157
  - lib/ask/rails/auth.rb
156
158
  - lib/ask/rails/configuration.rb
157
159
  - lib/ask/rails/engine.rb
160
+ - lib/ask/rails/environment_permissions.rb
158
161
  - lib/ask/rails/persistence.rb
159
162
  - lib/ask/rails/railtie.rb
160
163
  - lib/ask/rails/service_discovery.rb
@@ -164,13 +167,16 @@ files:
164
167
  - lib/ask/rails/tools/read_log.rb
165
168
  - lib/ask/rails/tools/read_model.rb
166
169
  - lib/ask/rails/tools/read_routes.rb
170
+ - lib/ask/rails/tools/route_inspector.rb
167
171
  - lib/ask/rails/tools/run_command.rb
172
+ - lib/ask/rails/tools/schema_graph.rb
168
173
  - lib/ask/rails/tools/search_codebase.rb
169
174
  - lib/ask/rails/version.rb
170
175
  - lib/ask/skills/rails.db_debug/SKILL.md
171
176
  - lib/ask/skills/rails.deploy_pipeline/SKILL.md
172
177
  - lib/ask/skills/rails.route_trouble/SKILL.md
173
178
  - lib/generators/ask/rails/install/install_generator.rb
179
+ - lib/generators/ask/rails/install/templates/audit_log_migration.rb
174
180
  - lib/generators/ask/rails/install/templates/initializer.rb
175
181
  - lib/generators/ask/rails/install/templates/migration.rb
176
182
  homepage: https://github.com/ask-rb/ask-rails