rails_agent_console 0.1.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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +414 -0
  5. data/exe/rails-agent +7 -0
  6. data/lib/generators/rails_agent_console/install_generator.rb +17 -0
  7. data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
  8. data/lib/rails_agent_console/agent.rb +277 -0
  9. data/lib/rails_agent_console/cli.rb +114 -0
  10. data/lib/rails_agent_console/configuration.rb +170 -0
  11. data/lib/rails_agent_console/console_installer.rb +49 -0
  12. data/lib/rails_agent_console/console_methods.rb +54 -0
  13. data/lib/rails_agent_console/console_session.rb +41 -0
  14. data/lib/rails_agent_console/credentials.rb +66 -0
  15. data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
  16. data/lib/rails_agent_console/diagnosis.rb +149 -0
  17. data/lib/rails_agent_console/errors.rb +20 -0
  18. data/lib/rails_agent_console/executor.rb +190 -0
  19. data/lib/rails_agent_console/history.rb +65 -0
  20. data/lib/rails_agent_console/model_picker.rb +166 -0
  21. data/lib/rails_agent_console/prompt/dates.rb +34 -0
  22. data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
  23. data/lib/rails_agent_console/prompt.rb +122 -0
  24. data/lib/rails_agent_console/proposal.rb +208 -0
  25. data/lib/rails_agent_console/providers/anthropic.rb +48 -0
  26. data/lib/rails_agent_console/providers/base.rb +114 -0
  27. data/lib/rails_agent_console/providers/callable.rb +28 -0
  28. data/lib/rails_agent_console/providers/gemini.rb +45 -0
  29. data/lib/rails_agent_console/providers/ollama.rb +84 -0
  30. data/lib/rails_agent_console/providers/openai.rb +51 -0
  31. data/lib/rails_agent_console/providers.rb +28 -0
  32. data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
  33. data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
  34. data/lib/rails_agent_console/query_validator/result.rb +55 -0
  35. data/lib/rails_agent_console/query_validator/rules.rb +108 -0
  36. data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
  37. data/lib/rails_agent_console/query_validator.rb +305 -0
  38. data/lib/rails_agent_console/railtie.rb +26 -0
  39. data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
  40. data/lib/rails_agent_console/rewriter/chain.rb +126 -0
  41. data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
  42. data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
  43. data/lib/rails_agent_console/rewriter/joined.rb +42 -0
  44. data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
  45. data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
  46. data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
  47. data/lib/rails_agent_console/rewriter.rb +183 -0
  48. data/lib/rails_agent_console/schema.rb +275 -0
  49. data/lib/rails_agent_console/setup.rb +40 -0
  50. data/lib/rails_agent_console/ui.rb +125 -0
  51. data/lib/rails_agent_console/value_check.rb +85 -0
  52. data/lib/rails_agent_console/version.rb +5 -0
  53. data/lib/rails_agent_console/wizard.rb +235 -0
  54. data/lib/rails_agent_console.rb +58 -0
  55. metadata +145 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module RailsAgentConsole
7
+ # ~/.rails_agent_console/config (0600, inside a 0700 directory):
8
+ #
9
+ # default_provider: openai
10
+ # openai:
11
+ # api_key: sk-...
12
+ # model: gpt-4o-mini
13
+ module Credentials
14
+ DIRECTORY = File.join(Dir.home, ".rails_agent_console")
15
+ PATH = File.join(DIRECTORY, "config")
16
+
17
+ class << self
18
+ def path
19
+ ENV.fetch("RAILS_AGENT_CONSOLE_CONFIG", PATH)
20
+ end
21
+
22
+ def exist?
23
+ File.exist?(path)
24
+ end
25
+
26
+ def read
27
+ return {} unless exist?
28
+
29
+ data = YAML.safe_load_file(path) || {}
30
+ data.is_a?(Hash) ? data : {}
31
+ rescue StandardError
32
+ {}
33
+ end
34
+
35
+ def store(provider:, api_key: nil, model: nil, base_url: :keep)
36
+ data = read
37
+ data["default_provider"] = provider.to_s
38
+ entry = data[provider.to_s].is_a?(Hash) ? data[provider.to_s] : {}
39
+ entry["api_key"] = api_key if api_key
40
+ entry["model"] = model if model
41
+ store_base_url(entry, base_url)
42
+ data[provider.to_s] = entry
43
+ write(data)
44
+ end
45
+
46
+ def store_base_url(entry, base_url)
47
+ return if base_url == :keep
48
+
49
+ base_url.to_s.strip.empty? ? entry.delete("base_url") : entry["base_url"] = base_url
50
+ end
51
+
52
+ def write(data)
53
+ directory = File.dirname(path)
54
+ FileUtils.mkdir_p(directory)
55
+ File.chmod(0o700, directory) if File.owned?(directory) && directory == DIRECTORY
56
+ File.write(path, YAML.dump(data))
57
+ File.chmod(0o600, path)
58
+ path
59
+ end
60
+
61
+ def delete!
62
+ FileUtils.rm_f(path)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Diagnosis
5
+ module Extra
6
+ QUALIFIED_MISSING = /column (\w+)\.(\w+) does not exist/
7
+ BARE_MISSING = /column "(\w+)" does not exist/
8
+ FIND_BY_NAME = /Couldn't find (\w+) with '(\w+)'=([a-z_]\w*)\b/
9
+ WINDOW_IN_AGGREGATE = /aggregate function calls cannot contain window function calls/
10
+
11
+ module_function
12
+
13
+ def hint(code, message)
14
+ missing_column_hint(code, message) || find_hint(message) || window_hint(message)
15
+ end
16
+
17
+ def missing_column_hint(code, message)
18
+ table, column = message.match(QUALIFIED_MISSING)&.captures
19
+ column ||= message[BARE_MISSING, 1]
20
+ model = table ? Schema.models.find { |candidate| candidate.table == table } : receiver(code)
21
+ return unless column && model
22
+
23
+ "#{model.table} has no #{column} column. Its columns are " \
24
+ "#{model.column_names.first(30).join(", ")}; use one of them or leave the code empty."
25
+ end
26
+
27
+ def shared_column(owner, name)
28
+ source = Schema.find(owner)
29
+ target = Schema.models.find { |model| [name, name.pluralize].include?(model.table) }
30
+ return unless source && target
31
+ return if source.inbound.any? { |reference| reference[:table] == target.table }
32
+
33
+ pair = matching_columns(source, target)
34
+ return unless pair
35
+
36
+ "#{source.name} and #{target.name} are not associated, but both hold #{pair.last}: " \
37
+ "#{source.name}.where(#{pair.first}: #{target.name}.select(:#{pair.last}))."
38
+ end
39
+
40
+ def matching_columns(source, target)
41
+ ignored = %w[id created_at updated_at]
42
+ source.column_names.product(target.column_names).find do |left, right|
43
+ next false if ignored.include?(left) || ignored.include?(right)
44
+
45
+ left == right || left.end_with?("_#{right}") || right.end_with?("_#{left}")
46
+ end
47
+ end
48
+
49
+ def receiver(code)
50
+ Schema.find(code.to_s[/\A\s*([A-Z]\w*(?:::[A-Z]\w*)*)/, 1].to_s)
51
+ end
52
+
53
+ def find_hint(message)
54
+ model, key, value = message.match(FIND_BY_NAME)&.captures
55
+ return unless model
56
+
57
+ "find(:#{value}) looks up the literal value #{value.inspect} in #{model}.#{key}. Comparing " \
58
+ "with a column of another table needs a join and SQL, such as " \
59
+ "joins(:association).where(\"table_a.column <> table_b.column\")."
60
+ end
61
+
62
+ def window_hint(message)
63
+ return unless WINDOW_IN_AGGREGATE.match?(message)
64
+
65
+ "A window function (LAG, ROW_NUMBER) cannot sit inside an aggregate. Compute it in a " \
66
+ "subquery and aggregate the outer query: " \
67
+ "Model.from(Model.select(\"model_table.*, LAG(created_at) OVER (PARTITION BY customer_id " \
68
+ "ORDER BY created_at) AS previous_at\"), :model_table).average(...)."
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Diagnosis
5
+ MISSING_COLUMN = /column "([^"]+)" does not exist/
6
+ NESTED_AGGREGATE = /aggregate function calls cannot be nested/
7
+ MISSING_GROUP = /column "([^"]+)" must appear in the GROUP BY clause/
8
+ MISSING_ASSOCIATION = /Can't join '([^']+)' to association named '([^']+)'/
9
+ MISSING_TABLE = /missing FROM-clause entry for table "([^"]+)"/
10
+ ALIAS_IN_CLAUSE = /syntax error at or near "AS"/
11
+ HASH_CHAIN = /undefined method [`']([^`']+)['`] for .*Hash/m
12
+ NOT_A_TIME = /invalid input syntax for type (timestamp|date)[^:]*: "([^"]*)"/
13
+ TYPE_MISMATCH = /operator does not exist: (\w[\w ]*) = (\w[\w ]*)/
14
+
15
+ class << self
16
+ def hint(code, message)
17
+ text = message.to_s
18
+ alias_hint(code, text) || nested_aggregate_hint(text) || group_by_hint(text) ||
19
+ association_hint(text) || missing_table_hint(text) || type_mismatch_hint(text) ||
20
+ alias_in_clause_hint(text) || hash_chain_hint(text) || not_a_time_hint(text) ||
21
+ Extra.hint(code, text)
22
+ end
23
+
24
+ private
25
+
26
+ def alias_hint(code, message)
27
+ name = message[MISSING_COLUMN, 1]
28
+ return unless name && aliased?(code, name)
29
+
30
+ "`#{name}` is an alias from select(...). SQL cannot see it anywhere else in the " \
31
+ "same query, so repeat the expression instead of naming the alias."
32
+ end
33
+
34
+ def aliased?(code, name)
35
+ code.to_s.match?(/\bAS\s+"?#{Regexp.escape(name)}"?\b/i)
36
+ end
37
+
38
+ def nested_aggregate_hint(message)
39
+ return unless NESTED_AGGREGATE.match?(message)
40
+
41
+ "One aggregate cannot wrap another. Divide two separate aggregates instead, " \
42
+ "such as Model.count.to_f / Model.distinct.count(:customer_id)."
43
+ end
44
+
45
+ def group_by_hint(message)
46
+ name = message[MISSING_GROUP, 1]
47
+ return unless name
48
+
49
+ "Every column that is selected has to be either grouped by or wrapped in an " \
50
+ "aggregate. Group by `#{name}` as well, or leave it out of the select."
51
+ end
52
+
53
+ def association_hint(message)
54
+ match = MISSING_ASSOCIATION.match(message)
55
+ return unless match
56
+
57
+ owner, name = match.captures
58
+ return column_hint(owner, name) if column?(owner, name)
59
+
60
+ ["There is no `#{name}` association on #{owner}, and there never will be: nothing",
61
+ "declares it. #{ways_around(owner, name)}"].join(" ")
62
+ end
63
+
64
+ def column_hint(owner, name)
65
+ "`#{name}` is a column on #{owner}, not an association. Read it or filter on it " \
66
+ "directly -- #{owner}.where(#{name}: nil) -- with no join at all."
67
+ end
68
+
69
+ def column?(owner, name)
70
+ Schema.find(owner)&.column_names&.include?(name.to_s) || false
71
+ rescue StandardError
72
+ false
73
+ end
74
+
75
+ def not_a_time_hint(message)
76
+ type, value = NOT_A_TIME.match(message)&.captures
77
+ return unless type
78
+
79
+ "A #{type} column was compared with #{value.inspect}, which is not a #{type}. A condition " \
80
+ "on part of a date is evaluated per row in SQL: " \
81
+ "where(\"EXTRACT(DOW FROM created_at) IN (0, 6)\") for weekends, EXTRACT(HOUR FROM ...) for hours."
82
+ end
83
+
84
+ def hash_chain_hint(message)
85
+ name = message[HASH_CHAIN, 1]
86
+ return unless name
87
+
88
+ "count, sum and average on a grouped relation return a Hash of results, so `#{name}` " \
89
+ "has nothing to act on. Chain everything onto the relation first and aggregate last: " \
90
+ "group(:source).order(Arel.sql(\"AVG(LENGTH(query)) DESC\")).average(Arel.sql(\"LENGTH(query)\"))."
91
+ end
92
+
93
+ def ways_around(owner, name)
94
+ shared = Extra.shared_column(owner, name)
95
+ return shared if shared
96
+
97
+ other = referring_model(owner, name)
98
+ return "Answer it from the model that declares the belongs_to instead." unless other
99
+
100
+ "Either start from #{other[:model]} and join back -- " \
101
+ "#{other[:model]}.joins(:#{other[:association]}) -- or keep #{owner} as the subject and " \
102
+ "filter with a subquery: #{owner}.where(id: #{other[:model]}.select(:#{other[:foreign_key]})), " \
103
+ "or where.not for the ones without."
104
+ end
105
+
106
+ def referring_model(owner, name)
107
+ inbound = Schema.find(owner)&.inbound
108
+ return if inbound.nil? || inbound.empty?
109
+
110
+ inbound.find { |reference| same_model?(reference[:model], name) } || inbound.first
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ def missing_table_hint(message)
116
+ table = message[MISSING_TABLE, 1]
117
+ return unless table
118
+
119
+ "The query reads columns from `#{table}` without that table being in the query. " \
120
+ "Move the condition into a subquery on #{table} -- counting rows per owner is " \
121
+ "where(id: Other.group(:owner_id).having(Arel.sql(\"COUNT(*) > 10\")).select(:owner_id)) -- " \
122
+ "or drop it: the column you want may well be on the table you already select from."
123
+ end
124
+
125
+ def alias_in_clause_hint(message)
126
+ return unless ALIAS_IN_CLAUSE.match?(message)
127
+
128
+ "Only a select list may name an alias with AS. Pass group(...) and order(...) the " \
129
+ "expression itself, as a separate argument per column: " \
130
+ "group(Arel.sql(\"EXTRACT(YEAR FROM created_at)\"), :source).count."
131
+ end
132
+
133
+ def type_mismatch_hint(message)
134
+ left, right = TYPE_MISMATCH.match(message)&.captures
135
+ return unless left
136
+
137
+ "A #{left.strip} column is being compared with a #{right.strip} one, which is what " \
138
+ "where(id: Model.select(:some_string)) does. Compare columns that hold the same kind " \
139
+ "of value: if one table refers to another by email rather than by id, join the emails."
140
+ end
141
+
142
+ def same_model?(model, name)
143
+ model.to_s.split("::").last.downcase == name.to_s.delete("_").sub(/s\z/, "")
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ require_relative "diagnosis/extra"
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class InvalidResponseError < Error; end
9
+
10
+ class ProviderError < Error; end
11
+
12
+ class UnsafeCodeError < Error
13
+ attr_reader :violations
14
+
15
+ def initialize(message, violations: [])
16
+ super(message)
17
+ @violations = violations
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module RailsAgentConsole
6
+ module Executor
7
+ AGGREGATES = %w[count sum average minimum maximum size length pluck ids exists? any? empty? tally].freeze
8
+ DESTRUCTIVE_TAIL = /\.\s*(delete_all|destroy_all|update_all|delete|destroy)\s*(\([^()]*\))?\s*\z/m
9
+ ASSIGNMENT = /(?<![=!<>])=(?![=~>])/
10
+ NO_AGGREGATE_NOTE = "The query had no aggregate after `group`, so the groups were counted."
11
+ GROUPED_ROWS_NOTE = "Grouped rows have no id, so each one is shown as its selected values."
12
+ COMPUTED_NOTE = "The rows carry computed columns a record would hide, so each one is shown as its values."
13
+
14
+ Outcome = Struct.new(:value, :error, :duration, :note, keyword_init: true) do
15
+ def ok?
16
+ error.nil?
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def run(code)
22
+ started = now
23
+ raw = with_timeout { evaluate(code) }
24
+ value, note = resolve(raw)
25
+ duration = now - started
26
+ notes = [note, *value_notes(code, raw)].compact
27
+ Outcome.new(value: value, error: nil, duration: duration, note: notes.empty? ? nil : notes.join("\n"))
28
+ rescue Exception => e # rubocop:disable Lint/RescueException
29
+ Outcome.new(value: nil, error: e, duration: now - started)
30
+ end
31
+
32
+ def preflight_count(code)
33
+ return unless RailsAgentConsole.configuration.preflight_count
34
+ return if aggregate?(code)
35
+
36
+ count_rows(code)
37
+ end
38
+
39
+ def impact_estimate(code)
40
+ stripped = code.sub(DESTRUCTIVE_TAIL, "")
41
+ return if stripped == code || stripped.strip.empty?
42
+
43
+ count_rows(stripped)
44
+ end
45
+
46
+ def describe(value)
47
+ limit = RailsAgentConsole.configuration.inspect_limit
48
+
49
+ case value
50
+ when ->(v) { relation?(v) } then describe_relation(value)
51
+ when Array then pluralize(value.size, "item")
52
+ when Hash then pluralize(value.size, "group")
53
+ when nil then "nil"
54
+ else truncate(value.inspect, limit * 20)
55
+ end
56
+ rescue StandardError
57
+ value.class.name.to_s
58
+ end
59
+
60
+ def relation?(value)
61
+ defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)
62
+ end
63
+
64
+ def sandbox_binding
65
+ @sandbox_binding ||= Object.new.instance_eval { binding }
66
+ end
67
+
68
+ def reset!
69
+ @sandbox_binding = nil
70
+ end
71
+
72
+ private
73
+
74
+ def count_rows(code)
75
+ counting = "(#{code}).count(:all)"
76
+ return unless QueryValidator.validate(counting, write_mode: false).valid?
77
+
78
+ value = with_timeout { evaluate(counting) }
79
+ value if value.is_a?(Integer)
80
+ rescue Exception # rubocop:disable Lint/RescueException
81
+ nil
82
+ end
83
+
84
+ def resolve(value)
85
+ return [readable_keys(value), nil] if value.is_a?(Hash)
86
+ return [value, nil] unless relation?(value) && !value.loaded?
87
+ return [readable_keys(value.count), NO_AGGREGATE_NOTE] if bare_group?(value)
88
+ return [value.load, nil] unless grouped_select?(value)
89
+
90
+ rows = value.to_a
91
+ return [value, nil] if rows.empty?
92
+ return [rows.map { |row| grouped_row(row) }, GROUPED_ROWS_NOTE] if rows.all? { |row| row.id.nil? }
93
+ return [rows.map { |row| grouped_row(row) }, COMPUTED_NOTE] if computed_columns?(value, rows)
94
+
95
+ [value, nil]
96
+ end
97
+
98
+ def computed_columns?(relation, rows)
99
+ (rows.first.attributes.keys - relation.klass.column_names).any?
100
+ end
101
+
102
+ def value_notes(code, raw)
103
+ relation = raw if relation?(raw)
104
+ base = Prompt::FollowUp.base_of(code)
105
+ relation ||= evaluate(base) if base && base != code.strip && !assigns?(code)
106
+ relation?(relation) ? ValueCheck.notes(relation) : []
107
+ rescue StandardError
108
+ []
109
+ end
110
+
111
+ def assigns?(code)
112
+ code.gsub(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "''").match?(ASSIGNMENT)
113
+ end
114
+
115
+ def grouped_row(row)
116
+ row.attributes.compact.transform_values do |value|
117
+ value.is_a?(BigDecimal) && !value.frac.zero? ? value.to_f : whole(value)
118
+ end
119
+ end
120
+
121
+ def readable_keys(hash)
122
+ return hash unless hash.keys.flatten.any?(BigDecimal)
123
+
124
+ hash.transform_keys { |key| key.is_a?(Array) ? key.map { |part| whole(part) } : whole(key) }
125
+ end
126
+
127
+ def whole(value)
128
+ value.is_a?(BigDecimal) && value.frac.zero? ? value.to_i : value
129
+ end
130
+
131
+ def bare_group?(value)
132
+ value.group_values.any? && value.select_values.empty?
133
+ end
134
+
135
+ def grouped_select?(value)
136
+ value.group_values.any? && value.select_values.any?
137
+ end
138
+
139
+ def describe_relation(relation)
140
+ size = relation.size
141
+ size.is_a?(Hash) ? pluralize(size.size, "group") : pluralize(size, "record")
142
+ end
143
+
144
+ def evaluate(code)
145
+ eval(code, sandbox_binding, "(rails-agent)", 1) # rubocop:disable Security/Eval
146
+ end
147
+
148
+ def aggregate?(code)
149
+ AGGREGATES.include?(trailing_method(code).to_s)
150
+ end
151
+
152
+ def trailing_method(code)
153
+ source = code.strip
154
+ source = strip_trailing_arguments(source) if source.end_with?(")")
155
+ source[/([a-z_]\w*[?!]?)\s*\z/, 1]
156
+ end
157
+
158
+ def strip_trailing_arguments(source)
159
+ depth = 0
160
+ source.length.downto(1) do |index|
161
+ case source[index - 1]
162
+ when ")" then depth += 1
163
+ when "(" then depth -= 1
164
+ end
165
+ return source[0, index - 1] if depth.zero?
166
+ end
167
+ source
168
+ end
169
+
170
+ def with_timeout(&block)
171
+ seconds = RailsAgentConsole.configuration.execution_timeout
172
+ return block.call if seconds.nil? || seconds.to_i <= 0
173
+
174
+ Timeout.timeout(seconds.to_i, &block)
175
+ end
176
+
177
+ def pluralize(count, noun)
178
+ "#{count} #{count == 1 ? noun : "#{noun}s"}"
179
+ end
180
+
181
+ def truncate(string, length)
182
+ string.length > length ? "#{string[0, length]}..." : string
183
+ end
184
+
185
+ def now
186
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsAgentConsole
6
+ class History
7
+ Turn = Struct.new(:prompt, :code, :explanation, :outcome, keyword_init: true)
8
+
9
+ def initialize(limit: nil)
10
+ @limit = limit
11
+ @turns = []
12
+ end
13
+
14
+ def limit
15
+ @limit || RailsAgentConsole.configuration.max_history
16
+ end
17
+
18
+ def add(prompt:, code:, explanation: nil, outcome: nil)
19
+ @turns << Turn.new(prompt: prompt, code: code, explanation: explanation, outcome: outcome)
20
+ @turns.shift while @turns.size > limit
21
+ self
22
+ end
23
+
24
+ def turns
25
+ @turns.dup
26
+ end
27
+
28
+ def last
29
+ @turns.last
30
+ end
31
+
32
+ def empty?
33
+ @turns.empty?
34
+ end
35
+
36
+ def clear
37
+ @turns.clear
38
+ self
39
+ end
40
+
41
+ def messages
42
+ @turns.flat_map do |turn|
43
+ [
44
+ { role: "user", content: turn.prompt },
45
+ { role: "assistant", content: assistant_content(turn) }
46
+ ]
47
+ end
48
+ end
49
+
50
+ def to_s
51
+ return "(no queries yet)" if empty?
52
+
53
+ @turns.map.with_index(1) { |turn, i| "#{i}. #{turn.prompt}\n #{turn.code}" }.join("\n")
54
+ end
55
+
56
+ private
57
+
58
+ def assistant_content(turn)
59
+ payload = { "code" => turn.code }
60
+ payload["explanation"] = turn.explanation if turn.explanation
61
+ payload["result"] = turn.outcome if turn.outcome
62
+ JSON.generate(payload)
63
+ end
64
+ end
65
+ end