aicli 0.1.1 → 0.2.6

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.
@@ -1,219 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'net/http'
5
- require 'uri'
6
- require 'thread'
7
-
8
3
  module AiCli
9
4
  module Helpers
10
5
  module Completion
11
6
  EXPLAIN_IN_SECOND_REQUEST = true
12
- SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n/i, /```[a-zA-Z]*/i, "\n"].freeze
7
+ SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n?/i, /```[a-zA-Z]*/i, "\n"].freeze
13
8
 
14
9
  module_function
15
10
 
16
- def get_script_and_info(prompt:, key:, api_endpoint:, model: nil)
17
- stream = generate_completion(
18
- prompt: full_prompt(prompt),
19
- key: key,
20
- model: model,
21
- api_endpoint: api_endpoint
22
- )
23
- enumerator = stream.each
24
-
11
+ def get_script_and_info(prompt:, config:)
25
12
  {
26
- read_script: read_data(enumerator, *SHELL_CODE_EXCLUSIONS),
27
- read_info: read_data(enumerator, *SHELL_CODE_EXCLUSIONS)
13
+ read_script: stream_prompt(full_prompt(prompt), config: config, exclusions: SHELL_CODE_EXCLUSIONS),
14
+ read_info: ->(_writer) { '' }
28
15
  }
29
16
  end
30
17
 
31
- def get_explanation(script:, key:, api_endpoint:, model: nil)
32
- stream = generate_completion(
33
- prompt: explanation_prompt(script),
34
- key: key,
35
- model: model,
36
- api_endpoint: api_endpoint
37
- )
38
- { read_explanation: read_data(stream.each) }
18
+ def get_explanation(script:, config:)
19
+ { read_explanation: stream_prompt(explanation_prompt(script), config: config) }
39
20
  end
40
21
 
41
- def get_revision(prompt:, code:, key:, api_endpoint:, model: nil)
42
- stream = generate_completion(
43
- prompt: revision_prompt(prompt, code),
44
- key: key,
45
- model: model,
46
- api_endpoint: api_endpoint
47
- )
48
- { read_script: read_data(stream.each, *SHELL_CODE_EXCLUSIONS) }
22
+ def get_revision(prompt:, code:, config:)
23
+ {
24
+ read_script: stream_prompt(
25
+ revision_prompt(prompt, code),
26
+ config: config,
27
+ exclusions: SHELL_CODE_EXCLUSIONS
28
+ )
29
+ }
49
30
  end
50
31
 
51
- def get_models(key, api_endpoint)
52
- uri = URI.join(ensure_trailing_slash(api_endpoint), 'models')
53
- http = Net::HTTP.new(uri.host, uri.port)
54
- http.use_ssl = uri.scheme == 'https'
55
-
56
- request = Net::HTTP::Get.new(uri)
57
- request['Authorization'] = "Bearer #{key}"
58
- request['Content-Type'] = 'application/json'
59
-
60
- response = http.request(request)
61
- raise KnownError, "Failed to list models: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
62
-
63
- data = JSON.parse(response.body)
64
- data.fetch('data', []).select { |m| m['object'] == 'model' }
32
+ def get_models(provider)
33
+ Llm.list_chat_models(provider)
65
34
  end
66
35
 
67
- # Returns a live Enumerator of SSE "data: ..." lines
68
- def generate_completion(prompt:, key:, api_endpoint:, model: nil, number: 1)
69
- messages = if prompt.is_a?(Array)
70
- prompt
71
- else
72
- [{ 'role' => 'user', 'content' => prompt }]
73
- end
74
-
75
- uri = URI.join(ensure_trailing_slash(api_endpoint), 'chat/completions')
76
- body = {
77
- model: model || 'gpt-4o-mini',
78
- messages: messages,
79
- n: [number, 10].min,
80
- stream: true
81
- }
82
-
83
- queue = Queue.new
84
- error_box = []
85
-
86
- Thread.new do
87
- http = Net::HTTP.new(uri.host, uri.port)
88
- http.use_ssl = uri.scheme == 'https'
89
- http.read_timeout = 120
90
-
91
- request = Net::HTTP::Post.new(uri)
92
- request['Authorization'] = "Bearer #{key}"
93
- request['Content-Type'] = 'application/json'
94
- request['Accept'] = 'text/event-stream'
95
- request.body = JSON.generate(body)
96
-
97
- http.request(request) do |response|
98
- unless response.is_a?(Net::HTTPSuccess)
99
- error_box << [:api, response.code.to_i, response.body]
100
- queue << :done
101
- next
102
- end
103
-
104
- buffer = +''
105
- response.read_body do |chunk|
106
- buffer << chunk
107
- while (eol = buffer.index("\n"))
108
- line = buffer.slice!(0..eol).strip
109
- queue << line if line.start_with?('data: ')
110
- end
111
- end
112
- end
113
- queue << :done
114
- rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
115
- error_box << [:network, uri.host, e.message]
116
- queue << :done
117
- rescue StandardError => e
118
- error_box << [:other, e]
119
- queue << :done
120
- end
121
-
122
- Enumerator.new do |yielder|
123
- loop do
124
- item = queue.pop
125
- break if item == :done
126
-
127
- yielder << item
128
- end
129
-
130
- if (err = error_box.first)
131
- case err[0]
132
- when :api
133
- handle_api_error(err[1], err[2])
134
- when :network
135
- raise KnownError,
136
- "Error connecting to #{err[1]}. Are you connected to the internet? (#{err[2]})"
137
- when :other
138
- raise err[1]
139
- end
140
- end
36
+ # Streams a single-turn completion. Returns a callable that takes a writer.
37
+ def stream_prompt(prompt, config:, exclusions: [])
38
+ lambda do |writer|
39
+ stream_to_writer(prompt, config: config, exclusions: exclusions, writer: writer)
141
40
  end
142
41
  end
143
42
 
144
- def read_data(enumerator, *excluded)
145
- lambda do |writer|
146
- data = +''
147
- data_start = false
148
- buffer = +''
149
- excluded_prefix = excluded.first
150
-
151
- loop do
152
- chunk = enumerator.next
153
- payloads = chunk.to_s.split("\n\n")
154
-
155
- payloads.each do |payload|
156
- return data if payload.include?('[DONE]')
43
+ # Multi-turn chat helper used by `aicli chat`.
44
+ def start_chat(config)
45
+ chat = Llm.build_chat(config)
46
+ chat.with_instructions(chat_system_prompt)
47
+ chat
48
+ end
157
49
 
158
- next unless payload.start_with?('data:')
50
+ def chat_system_prompt
51
+ <<~PROMPT
52
+ You are aicli, a terminal assistant. The user is asking for shell commands.
159
53
 
160
- content = parse_content(payload)
54
+ Target OS: #{OsDetect.operating_system_name}
55
+ Target shell: #{OsDetect.detect_shell}
161
56
 
162
- unless data_start
163
- buffer << content
164
- if excluded_prefix.nil? || buffer.match?(excluded_prefix)
165
- data_start = true
166
- buffer = +''
167
- break if excluded_prefix
168
- end
169
- end
57
+ Whenever you suggest a command the user can run, put the exact runnable command
58
+ alone inside a markdown fenced code block, for example:
170
59
 
171
- next unless data_start && !content.empty?
60
+ ```
61
+ ls -la
62
+ ```
172
63
 
173
- cleaned = StripRegexPatterns.call(content, excluded)
174
- data << cleaned
175
- writer.call(cleaned)
176
- end
177
- rescue StopIteration
178
- break
179
- end
180
-
181
- data
182
- end
64
+ Prefer a single command. You may briefly explain outside the fence.
65
+ Do not wrap non-runnable prose in fences.
66
+ PROMPT
183
67
  end
184
68
 
185
- def parse_content(payload)
186
- raw = payload.sub(/^data:\s*/, '')
187
- delta = JSON.parse(raw.strip)
188
- delta.dig('choices', 0, 'delta', 'content') || ''
189
- rescue JSON::ParserError => e
190
- "Error with JSON.parse and #{payload}.\n#{e}"
191
- end
69
+ def extract_commands(text)
70
+ return [] if text.nil? || text.empty?
192
71
 
193
- def handle_api_error(status, body)
194
- message_string = begin
195
- JSON.pretty_generate(JSON.parse(body))
196
- rescue StandardError
197
- body.to_s
198
- end
72
+ blocks = text.scan(/```(?:[a-zA-Z0-9_+-]*)\s*\n?(.*?)```/m)
73
+ .flatten
74
+ .map { |block| block.strip.gsub(/\A`+|`+\z/, '') }
75
+ .map(&:strip)
76
+ .reject(&:empty?)
199
77
 
200
- if status == 429
201
- raise KnownError, <<~MSG
202
- Request to OpenAI failed with status 429. This is due to incorrect billing setup or excessive quota usage. Please follow this guide to fix it: https://help.openai.com/en/articles/6891831-error-code-429-you-exceeded-your-current-quota-please-check-your-plan-and-billing-details
78
+ # Prefer the last fenced command if several were suggested.
79
+ blocks
80
+ end
203
81
 
204
- You can activate billing here: https://platform.openai.com/account/billing/overview . Make sure to add a payment method if not under an active grant from OpenAI.
82
+ def stream_chat_message(chat, message, writer:)
83
+ data = +''
205
84
 
206
- Full message from OpenAI:
85
+ begin
86
+ chat.ask(message) do |chunk|
87
+ content = chunk.content.to_s
88
+ next if content.empty?
207
89
 
208
- #{message_string}
209
- MSG
90
+ data << content
91
+ writer.call(content)
92
+ end
93
+ rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
94
+ handle_ruby_llm_error(e)
210
95
  end
211
96
 
212
- raise KnownError, <<~MSG
213
- Request to OpenAI failed with status #{status}:
214
-
215
- #{message_string}
216
- MSG
97
+ data
217
98
  end
218
99
 
219
100
  def explanation_prompt(script)
@@ -267,9 +148,84 @@ module AiCli
267
148
  PROMPT
268
149
  end
269
150
 
270
- def ensure_trailing_slash(endpoint)
271
- endpoint.end_with?('/') ? endpoint : "#{endpoint}/"
151
+ def stream_to_writer(prompt, config:, exclusions:, writer:)
152
+ chat = Llm.build_chat(config)
153
+ data = +''
154
+ data_start = exclusions.empty?
155
+ buffer = +''
156
+ excluded_prefix = exclusions.first
157
+
158
+ begin
159
+ chat.ask(prompt) do |chunk|
160
+ content = chunk.content.to_s
161
+ next if content.empty?
162
+
163
+ unless data_start
164
+ buffer << content
165
+ next unless excluded_prefix.nil? || buffer.match?(excluded_prefix)
166
+
167
+ data_start = true
168
+ remainder = StripRegexPatterns.call(buffer, exclusions)
169
+ buffer = +''
170
+ next if remainder.empty?
171
+
172
+ data << remainder
173
+ writer.call(remainder)
174
+ next
175
+ end
176
+
177
+ cleaned = StripRegexPatterns.call(content, exclusions)
178
+ next if cleaned.empty?
179
+
180
+ data << cleaned
181
+ writer.call(cleaned)
182
+ end
183
+ rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
184
+ handle_ruby_llm_error(e)
185
+ end
186
+
187
+ data
188
+ end
189
+
190
+ def handle_ruby_llm_error(error)
191
+ message = error.message.to_s
192
+
193
+ if error.is_a?(RubyLLM::RateLimitError) || error.is_a?(RubyLLM::PaymentRequiredError) ||
194
+ message.match?(/rate.?limit|quota|billing/i)
195
+ raise KnownError, <<~MSG
196
+ Request to the LLM provider failed (rate limit / quota).
197
+
198
+ Check your plan and billing details for the configured provider, then try again.
199
+
200
+ Full message:
201
+
202
+ #{message}
203
+ MSG
204
+ end
205
+
206
+ if message.match?(/model_not_found|does not exist|do not have access/i)
207
+ raise KnownError, <<~MSG
208
+ The configured model is not available for this API key/provider.
209
+
210
+ Fix with:
211
+ #{Constants::COMMAND_NAME} config
212
+ or:
213
+ #{Constants::COMMAND_NAME} config set MODEL=#{Llm.default_model_for('anthropic')}
214
+ #{Constants::COMMAND_NAME} config set PROVIDER=anthropic
215
+
216
+ Full message:
217
+
218
+ #{message}
219
+ MSG
220
+ end
221
+
222
+ raise KnownError, <<~MSG
223
+ Request to the LLM provider failed:
224
+
225
+ #{message}
226
+ MSG
272
227
  end
228
+ private_class_method :stream_to_writer, :handle_ruby_llm_error
273
229
  end
274
230
  end
275
231
  end
@@ -7,19 +7,23 @@ require 'tty-prompt'
7
7
  module AiCli
8
8
  module Helpers
9
9
  module Config
10
- CONFIG_PATH = File.join(Dir.home, '.aicli')
10
+ HOME_DIR = File.join(Dir.home, '.aicli')
11
+ CONFIG_PATH = File.join(HOME_DIR, 'config')
12
+ LEGACY_FLAT_CONFIG_PATH = File.join(Dir.home, '.aicli')
11
13
  LEGACY_CONFIG_PATH = File.join(Dir.home, '.ai-shell')
12
14
 
13
15
  CONFIG_PARSERS = {
16
+ 'PROVIDER' => lambda { |provider|
17
+ Llm.normalize_provider(provider)
18
+ },
14
19
  'OPENAI_KEY' => lambda { |key|
15
- if key.nil? || key.empty?
16
- raise KnownError,
17
- "Please set your OpenAI API key via `#{Constants::COMMAND_NAME} config set OPENAI_KEY=<your token>`"
18
- end
19
- key
20
+ key.to_s
21
+ },
22
+ 'ANTHROPIC_KEY' => lambda { |key|
23
+ key.to_s
20
24
  },
21
25
  'MODEL' => lambda { |model|
22
- model.nil? || model.empty? ? 'gpt-4o-mini' : model
26
+ model.to_s
23
27
  },
24
28
  'SILENT_MODE' => lambda { |mode|
25
29
  mode.to_s.downcase == 'true'
@@ -34,6 +38,32 @@ module AiCli
34
38
 
35
39
  module_function
36
40
 
41
+ def home_dir
42
+ ensure_home!
43
+ HOME_DIR
44
+ end
45
+
46
+ def ensure_home!
47
+ if File.file?(LEGACY_FLAT_CONFIG_PATH) && !File.directory?(LEGACY_FLAT_CONFIG_PATH)
48
+ migrate_flat_config!
49
+ else
50
+ FileUtils.mkdir_p(HOME_DIR)
51
+ end
52
+ HOME_DIR
53
+ end
54
+
55
+ def migrate_flat_config!
56
+ backup = "#{LEGACY_FLAT_CONFIG_PATH}.migrating.#{Process.pid}"
57
+ File.rename(LEGACY_FLAT_CONFIG_PATH, backup)
58
+ FileUtils.mkdir_p(HOME_DIR)
59
+ FileUtils.mv(backup, CONFIG_PATH)
60
+ rescue StandardError
61
+ FileUtils.mkdir_p(HOME_DIR)
62
+ if File.exist?(backup)
63
+ FileUtils.mv(backup, CONFIG_PATH) unless File.exist?(CONFIG_PATH)
64
+ end
65
+ end
66
+
37
67
  def get(cli_config = nil)
38
68
  config = read_config_file
39
69
  parsed = {}
@@ -43,21 +73,49 @@ module AiCli
43
73
  parsed[key] = parser.call(value)
44
74
  end
45
75
 
76
+ raw_model = cli_config&.dig('MODEL') || config['MODEL']
77
+ if raw_model.nil? || raw_model.to_s.empty?
78
+ parsed['MODEL'] = Llm.default_model_for(parsed['PROVIDER'])
79
+ end
80
+
81
+ provider, model = Llm.resolve_provider_and_model(parsed)
82
+ parsed['PROVIDER'] = provider
83
+ parsed['MODEL'] = model
46
84
  parsed
47
85
  end
48
86
 
49
87
  def set(key_values)
50
88
  config = read_config_file
89
+ updates = {}
51
90
 
52
91
  key_values.each do |key, value|
53
92
  unless CONFIG_PARSERS.key?(key)
54
93
  raise KnownError, "#{I18n.t('Invalid config property')}: #{key}"
55
94
  end
56
95
 
57
- parsed = CONFIG_PARSERS[key].call(value)
58
- config[key] = parsed.to_s
96
+ updates[key] = CONFIG_PARSERS[key].call(value).to_s
97
+ end
98
+
99
+ # Keep PROVIDER and MODEL aligned with the RubyLLM registry.
100
+ if updates.key?('MODEL') && !updates['MODEL'].empty?
101
+ model_provider = Llm.provider_for_model(updates['MODEL'])
102
+ if model_provider
103
+ updates['PROVIDER'] = model_provider unless updates.key?('PROVIDER')
104
+ end
105
+ end
106
+
107
+ if updates.key?('PROVIDER')
108
+ provider = Llm.normalize_provider(updates['PROVIDER'])
109
+ updates['PROVIDER'] = provider
110
+ next_model = updates['MODEL'] || config['MODEL']
111
+ known_ids = Completion.get_models(provider).map { |m| m['id'] }
112
+ if next_model.to_s.empty? || !known_ids.include?(next_model)
113
+ updates['MODEL'] = Llm.default_model_for(provider) unless updates.key?('MODEL') && known_ids.include?(updates['MODEL'])
114
+ end
59
115
  end
60
116
 
117
+ updates.each { |key, value| config[key] = value }
118
+ ensure_home!
61
119
  File.write(CONFIG_PATH, stringify_ini(config))
62
120
  end
63
121
 
@@ -72,8 +130,12 @@ module AiCli
72
130
  loop do
73
131
  config = get
74
132
  choice = prompt.select("#{I18n.t('Set config')}:") do |menu|
133
+ menu.choice "#{I18n.t('Provider')} (#{display_hint(config, 'PROVIDER')})",
134
+ 'PROVIDER'
75
135
  menu.choice "#{I18n.t('OpenAI Key')} (#{display_hint(config, 'OPENAI_KEY') { |v| "sk-...#{v[-3..]}" }})",
76
136
  'OPENAI_KEY'
137
+ menu.choice "#{I18n.t('Anthropic Key')} (#{display_hint(config, 'ANTHROPIC_KEY') { |v| "...#{v[-3..]}" }})",
138
+ 'ANTHROPIC_KEY'
77
139
  menu.choice "#{I18n.t('OpenAI API Endpoint')} (#{display_hint(config, 'OPENAI_API_ENDPOINT')})",
78
140
  'OPENAI_API_ENDPOINT'
79
141
  menu.choice "#{I18n.t('Silent Mode')} (#{display_hint(config, 'SILENT_MODE')})",
@@ -86,11 +148,27 @@ module AiCli
86
148
  end
87
149
 
88
150
  case choice
151
+ when 'PROVIDER'
152
+ provider = prompt.select(I18n.t('Pick a provider')) do |menu|
153
+ Llm::PROVIDERS.each { |p| menu.choice p, p }
154
+ end
155
+ updates = [['PROVIDER', provider]]
156
+ current_model = get['MODEL']
157
+ known_ids = Completion.get_models(provider).map { |m| m['id'] }
158
+ unless known_ids.include?(current_model)
159
+ updates << ['MODEL', Llm.default_model_for(provider)]
160
+ end
161
+ set(updates)
89
162
  when 'OPENAI_KEY'
90
163
  key = prompt.ask(I18n.t('Enter your OpenAI API key')) do |q|
91
164
  q.required true
92
165
  end
93
166
  set([['OPENAI_KEY', key]])
167
+ when 'ANTHROPIC_KEY'
168
+ key = prompt.ask(I18n.t('Enter your Anthropic API key')) do |q|
169
+ q.required true
170
+ end
171
+ set([['ANTHROPIC_KEY', key]])
94
172
  when 'OPENAI_API_ENDPOINT'
95
173
  api_endpoint = prompt.ask(I18n.t('Enter your OpenAI API Endpoint'))
96
174
  set([['OPENAI_API_ENDPOINT', api_endpoint]]) if api_endpoint
@@ -99,9 +177,17 @@ module AiCli
99
177
  set([['SILENT_MODE', silent ? 'true' : 'false']])
100
178
  when 'MODEL'
101
179
  cfg = get
102
- models = Completion.get_models(cfg['OPENAI_KEY'], cfg['OPENAI_API_ENDPOINT'])
103
- model = prompt.select('Pick a model.') do |menu|
104
- models.each { |m| menu.choice m['id'], m['id'] }
180
+ models = Completion.get_models(cfg['PROVIDER'])
181
+ if models.empty?
182
+ puts pastel.yellow(I18n.t('No models found for this provider.'))
183
+ next
184
+ end
185
+ model = prompt.select(I18n.t('Pick a model.')) do |menu|
186
+ menu.default cfg['MODEL'] if models.any? { |m| m['id'] == cfg['MODEL'] }
187
+ models.each do |m|
188
+ label = m['name'].empty? || m['name'] == m['id'] ? m['id'] : "#{m['name']} (#{m['id']})"
189
+ menu.choice label, m['id']
190
+ end
105
191
  end
106
192
  set([['MODEL', model]])
107
193
  when 'LANGUAGE'
@@ -121,9 +207,11 @@ module AiCli
121
207
  end
122
208
 
123
209
  def read_config_file
124
- path = if File.exist?(CONFIG_PATH)
210
+ ensure_home!
211
+
212
+ path = if File.file?(CONFIG_PATH)
125
213
  CONFIG_PATH
126
- elsif File.exist?(LEGACY_CONFIG_PATH)
214
+ elsif File.file?(LEGACY_CONFIG_PATH)
127
215
  LEGACY_CONFIG_PATH
128
216
  end
129
217
  return {} unless path
@@ -149,7 +237,7 @@ module AiCli
149
237
  end
150
238
 
151
239
  def display_hint(config, key)
152
- if config.key?(key) && !config[key].nil?
240
+ if config.key?(key) && !config[key].nil? && !(config[key].respond_to?(:empty?) && config[key].empty?)
153
241
  value = config[key]
154
242
  block_given? ? yield(value) : value.to_s
155
243
  else
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module AiCli
8
+ module Helpers
9
+ module Context
10
+ # Keep the last N user/assistant messages across sessions.
11
+ MAX_MESSAGES = 40
12
+ CONTEXT_FILENAME = 'context'
13
+
14
+ module_function
15
+
16
+ def path
17
+ File.join(Config.home_dir, CONTEXT_FILENAME)
18
+ end
19
+
20
+ def load_messages
21
+ Config.ensure_home!
22
+ return [] unless File.file?(path)
23
+
24
+ data = JSON.parse(File.read(path))
25
+ Array(data['messages'])
26
+ .select { |m| m.is_a?(Hash) && %w[user assistant].include?(m['role'].to_s) }
27
+ .map { |m| { 'role' => m['role'].to_s, 'content' => m['content'].to_s } }
28
+ .reject { |m| m['content'].strip.empty? }
29
+ .last(MAX_MESSAGES)
30
+ rescue StandardError
31
+ []
32
+ end
33
+
34
+ def save_messages(messages)
35
+ Config.ensure_home!
36
+ normalized = Array(messages)
37
+ .map { |m| normalize_message(m) }
38
+ .compact
39
+ .last(MAX_MESSAGES)
40
+
41
+ payload = {
42
+ 'version' => 1,
43
+ 'updated_at' => Time.now.utc.iso8601,
44
+ 'messages' => normalized
45
+ }
46
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
47
+ rescue StandardError
48
+ # Ignore persistence failures; chat should still work.
49
+ end
50
+
51
+ def save_from_chat(chat)
52
+ save_messages(chat.messages)
53
+ end
54
+
55
+ def apply_to_chat(chat)
56
+ load_messages.each do |message|
57
+ chat.add_message(role: message['role'].to_sym, content: message['content'])
58
+ end
59
+ end
60
+
61
+ def user_prompts
62
+ load_messages.select { |m| m['role'] == 'user' }.map { |m| m['content'] }
63
+ end
64
+
65
+ def seed_prompt_history(prompt)
66
+ return unless prompt.respond_to?(:reader)
67
+
68
+ user_prompts.each do |line|
69
+ prompt.reader.add_to_history(line)
70
+ end
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def normalize_message(message)
76
+ if message.respond_to?(:role) && message.respond_to?(:content)
77
+ role = message.role.to_s
78
+ content = message.content.to_s
79
+ elsif message.is_a?(Hash)
80
+ role = (message[:role] || message['role']).to_s
81
+ content = (message[:content] || message['content']).to_s
82
+ else
83
+ return nil
84
+ end
85
+
86
+ return nil unless %w[user assistant].include?(role)
87
+ return nil if content.strip.empty?
88
+
89
+ { 'role' => role, 'content' => content }
90
+ end
91
+ private_class_method :normalize_message
92
+ end
93
+ end
94
+ end