legion-tty 0.4.39 → 0.4.40

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a46889a85e5da8000964e9ac91d0c8e8fba67591fc12ca4bc8a4057339ed09b
4
- data.tar.gz: d769157e7b76054bc01148650474b0e621a22173428628ce2edf41e2c58aafc8
3
+ metadata.gz: 60e92790f0f6281b48346af3972e2631f1952b3b68f3d51922aa52da8682cfc9
4
+ data.tar.gz: 85491c70182ced9142b483c4edd903f3e5948bac1544c01cba69a86f565d2483
5
5
  SHA512:
6
- metadata.gz: cfb3a079a43835daf8cb922627ccb2f19e36c9fcfd0577f9cfa9a468f38d0ff1f658eaaeeda82ff202b6213013442e798cca11e9b61ca544ce171173ada7ce8e
7
- data.tar.gz: 4bc9d5d5e2b7173740d4d1a72193e31d77c7bd22bfa745cd39861704804d2f78489dafc915c5a35b916252a554b80df685b4e0bb6136f0cc1bef356b24e57a0f
6
+ metadata.gz: 757d74c3e7e0d7711cc9bb0cb410c145e7e8ca31d484d62ad53160df64c34205be19d6eb0fa91d7aede125af63ff1d005f596ea10c285fcbdc97adb5c74eee19
7
+ data.tar.gz: df6acc61f03cfc4631ccd28eaea40813314aadc8211497e857507ac7453ab700952bf6588a4d34016d3d5196651680583a293ff1bfba204101408b62a5c7fd4d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.40] - 2026-03-28
4
+
5
+ ### Changed
6
+ - All LLM calls in the chat screen now route exclusively through the LegionIO daemon API (`POST /api/llm/inference`) via `Legion::TTY::DaemonClient.inference`
7
+ - Removed soft fallback to raw RubyLLM (`send_via_direct` / `@llm_chat.ask`) — if the daemon is not running, a clear error is displayed: "LegionIO daemon is not running. Start it with: legionio start"
8
+ - Fixed `send_via_daemon`: was calling `Legion::LLM.ask` (which never returns `{status: :done}`) and falling through to direct on every call; now calls `DaemonClient.inference` with the full conversation history
9
+ - Fixed `daemon_available?`: was checking `Legion::LLM::DaemonClient` (an unrelated module); now calls `Legion::TTY::DaemonClient.available?` directly
10
+ - `try_settings_llm` in `App` no longer creates a raw `Legion::LLM.chat` session; daemon availability is logged instead
11
+ - System prompt is now injected per-request as part of the messages array sent to `/api/llm/inference`, not pre-set on a session object
12
+ - Added `DaemonClient.inference` method: `POST /api/llm/inference` with messages array, tools, model, provider; returns `{status: :ok/:error/:unavailable, data:}`
13
+ - Added `build_inference_messages`: assembles system + conversation history + current message for the inference call
14
+ - Added `track_inference_tokens`: tracks input/output tokens from the `data` hash returned by `/api/llm/inference`
15
+
3
16
  ## [0.4.39] - 2026-03-28
4
17
 
5
18
  ### Fixed
@@ -468,15 +468,16 @@ module Legion
468
468
  end
469
469
 
470
470
  def try_settings_llm
471
- return nil unless defined?(Legion::LLM)
472
-
473
- Legion::LLM.start unless Legion::LLM.started?
474
- return nil unless Legion::LLM.started?
475
-
476
- provider = Legion::LLM.settings[:default_provider]
477
- return nil unless provider
478
-
479
- Legion::LLM.chat(provider: provider, caller: { source: 'tty', screen: 'chat' })
471
+ # All LLM calls route through the LegionIO daemon API.
472
+ # No raw RubyLLM session is created here — nil signals "use daemon path".
473
+ if Legion::TTY::DaemonClient.available?
474
+ Legion::Logging.debug('TTY: daemon available, LLM routed through daemon') if defined?(Legion::Logging)
475
+ elsif defined?(Legion::Logging)
476
+ if defined?(Legion::Logging)
477
+ Legion::Logging.warn('TTY: daemon not running; LLM unavailable until daemon starts')
478
+ end
479
+ end
480
+ nil
480
481
  rescue StandardError => e
481
482
  Legion::Logging.warn("try_settings_llm failed: #{e.message}") if defined?(Legion::Logging)
482
483
  nil
@@ -10,6 +10,7 @@ module Legion
10
10
  module DaemonClient
11
11
  SUCCESS_CODES = [200, 201, 202].freeze
12
12
 
13
+ # rubocop:disable Metrics/ClassLength
13
14
  class << self
14
15
  def configure(daemon_url: 'http://127.0.0.1:4567', cache_file: nil, timeout: 5)
15
16
  @daemon_url = daemon_url
@@ -89,6 +90,19 @@ module Legion
89
90
  nil
90
91
  end
91
92
 
93
+ def inference(messages:, tools: [], model: nil, provider: nil, timeout: 120)
94
+ response = post_inference(messages: messages, tools: tools, model: model,
95
+ provider: provider, timeout: timeout)
96
+ return inference_error_result(response) unless SUCCESS_CODES.include?(response.code.to_i)
97
+
98
+ body = Legion::JSON.load(response.body)
99
+ data = body[:data] || body
100
+ { status: :ok, data: data }
101
+ rescue StandardError => e
102
+ Legion::Logging.warn("inference failed: #{e.message}") if defined?(Legion::Logging)
103
+ { status: :unavailable, error: { message: e.message } }
104
+ end
105
+
92
106
  def reset!
93
107
  @daemon_url = nil
94
108
  @cache_file = nil
@@ -98,6 +112,29 @@ module Legion
98
112
 
99
113
  private
100
114
 
115
+ def post_inference(messages:, tools:, model:, provider:, timeout:)
116
+ uri = URI("#{daemon_url}/api/llm/inference")
117
+ payload = Legion::JSON.dump({ messages: messages, tools: tools,
118
+ model: model, provider: provider }.compact)
119
+ http_timeout = [timeout, @timeout || 5].max
120
+ req = Net::HTTP::Post.new(uri)
121
+ req['Content-Type'] = 'application/json'
122
+ req.body = payload
123
+ Net::HTTP.start(uri.hostname, uri.port,
124
+ open_timeout: @timeout || 5,
125
+ read_timeout: http_timeout) { |h| h.request(req) }
126
+ end
127
+
128
+ def inference_error_result(response)
129
+ body = begin
130
+ Legion::JSON.load(response.body)
131
+ rescue StandardError
132
+ {}
133
+ end
134
+ err = body.dig(:error, :message) || body.dig(:data, :error, :message) || "HTTP #{response.code}"
135
+ { status: :error, error: { message: err } }
136
+ end
137
+
101
138
  def daemon_url
102
139
  @daemon_url || 'http://127.0.0.1:4567'
103
140
  end
@@ -119,6 +156,7 @@ module Legion
119
156
  nil
120
157
  end
121
158
  end
159
+ # rubocop:enable Metrics/ClassLength
122
160
  end
123
161
  end
124
162
  end
@@ -18,33 +18,18 @@ module Legion
18
18
  end
19
19
 
20
20
  def switch_model(name)
21
- unless @llm_chat
22
- @message_stream.add_message(role: :system, content: 'No active LLM session.')
23
- return
24
- end
25
-
26
- apply_model_switch(name)
21
+ @preferred_model = name
22
+ @status_bar.update(model: name)
23
+ @token_tracker.update_model(name)
24
+ @message_stream.add_message(role: :system,
25
+ content: "Model preference set to: #{name} (applied on next daemon request)")
27
26
  rescue StandardError => e
28
27
  Legion::Logging.warn("switch_model failed: #{e.message}") if defined?(Legion::Logging)
29
28
  @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
30
29
  end
31
30
 
32
31
  def apply_model_switch(name)
33
- new_chat = try_provider_switch(name)
34
- if new_chat
35
- @llm_chat = new_chat
36
- @status_bar.update(model: name)
37
- @token_tracker.update_model(name)
38
- @message_stream.add_message(role: :system, content: "Switched to provider: #{name}")
39
- elsif @llm_chat.respond_to?(:with_model)
40
- @llm_chat.with_model(name)
41
- @status_bar.update(model: name)
42
- @token_tracker.update_model(name)
43
- @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
44
- else
45
- @status_bar.update(model: name)
46
- @message_stream.add_message(role: :system, content: "Model set to: #{name}")
47
- end
32
+ switch_model(name)
48
33
  end
49
34
 
50
35
  def try_provider_switch(name)
@@ -75,9 +60,8 @@ module Legion
75
60
  end
76
61
 
77
62
  def show_current_model
78
- model = @llm_chat.respond_to?(:model) ? @llm_chat.model : nil
79
63
  provider = safe_config[:provider] || 'unknown'
80
- info = model ? "#{model} (#{provider})" : provider
64
+ info = @preferred_model ? "#{@preferred_model} (#{provider})" : provider
81
65
  @message_stream.add_message(role: :system, content: "Current model: #{info}")
82
66
  end
83
67
 
@@ -73,10 +73,10 @@ module Legion
73
73
  @output = output
74
74
  @message_stream = Components::MessageStream.new
75
75
  @status_bar = Components::StatusBar.new
76
- @llm_chat = app.respond_to?(:llm_chat) ? app.llm_chat : nil
76
+ @llm_chat = nil
77
77
  @token_tracker = Components::TokenTracker.new(
78
78
  provider: detect_provider,
79
- model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
79
+ model: nil
80
80
  )
81
81
  @session_store = SessionStore.new
82
82
  @session_name = 'default'
@@ -174,16 +174,14 @@ module Legion
174
174
  end
175
175
 
176
176
  def send_to_llm(message)
177
- unless @llm_chat || daemon_available?
178
- @message_stream.append_streaming('LLM not configured. Use /help for commands.')
177
+ unless daemon_available?
178
+ @message_stream.append_streaming(
179
+ 'LegionIO daemon is not running. Start it with: legionio start'
180
+ )
179
181
  return
180
182
  end
181
183
 
182
- if daemon_available?
183
- send_via_daemon(message)
184
- else
185
- send_via_direct(message)
186
- end
184
+ send_via_daemon(message)
187
185
  rescue StandardError => e
188
186
  Legion::Logging.error("send_to_llm failed: #{e.message}") if defined?(Legion::Logging)
189
187
  @status_bar.update(thinking: false)
@@ -239,60 +237,47 @@ module Legion
239
237
  end
240
238
 
241
239
  def setup_system_prompt
242
- cfg = safe_config
243
- return unless @llm_chat && cfg.is_a?(Hash) && !cfg.empty?
244
-
245
- prompt = build_system_prompt(cfg)
246
- @llm_chat.with_instructions(prompt) if @llm_chat.respond_to?(:with_instructions)
240
+ # System prompt is injected per-request in build_inference_messages.
241
+ # Nothing to do at activation time.
247
242
  end
248
243
 
244
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
249
245
  def send_via_daemon(message)
250
- result = Legion::LLM.ask(message: message)
246
+ @status_bar.update(thinking: true)
247
+ @streaming = true
248
+ @app.render_frame if @app.respond_to?(:render_frame)
249
+
250
+ start_time = Time.now
251
+ messages = build_inference_messages(message)
252
+ result = Legion::TTY::DaemonClient.inference(
253
+ messages: messages,
254
+ model: @preferred_model
255
+ )
251
256
 
252
- case result&.dig(:status)
253
- when :done
257
+ case result[:status]
258
+ when :ok
259
+ data = result[:data] || {}
260
+ content = data[:content].to_s
254
261
  parser = build_tool_call_parser
255
- parser.feed(result[:response])
262
+ parser.feed(content)
256
263
  parser.flush
257
- track_daemon_tokens(result)
264
+ record_response_time(Time.now - start_time)
265
+ track_inference_tokens(data)
266
+ speak_response(content) if @speak_mode
258
267
  when :error
259
268
  err = result.dig(:error, :message) || 'Unknown error'
260
269
  @message_stream.append_streaming("\n[Daemon error: #{err}]")
261
- else
262
- send_via_direct(message)
270
+ when :unavailable
271
+ err = result.dig(:error, :message) || 'Daemon unavailable'
272
+ @message_stream.append_streaming(
273
+ "\nLegionIO daemon is not running. Start it with: legionio start\n[#{err}]"
274
+ )
263
275
  end
264
- rescue StandardError => e
265
- Legion::Logging.warn("send_via_daemon failed: #{e.message}") if defined?(Legion::Logging)
266
- send_via_direct(message)
267
- end
268
-
269
- # rubocop:disable Metrics/AbcSize
270
- def send_via_direct(message)
271
- return unless @llm_chat
272
-
273
- @status_bar.update(thinking: true)
274
- @streaming = true
275
- @app.render_frame if @app.respond_to?(:render_frame)
276
- start_time = Time.now
277
- response_text = +''
278
- parser = build_tool_call_parser
279
- response = @llm_chat.ask(message) do |chunk|
280
- @status_bar.update(thinking: false)
281
- if chunk.content
282
- response_text << chunk.content
283
- parser.feed(chunk.content)
284
- end
285
- @app.render_frame if @app.respond_to?(:render_frame)
286
- end
287
- parser.flush
288
- record_response_time(Time.now - start_time)
289
- @status_bar.update(thinking: false)
290
- track_response_tokens(response)
291
- speak_response(response_text) if @speak_mode
292
276
  ensure
277
+ @status_bar.update(thinking: false)
293
278
  @streaming = false
294
279
  end
295
- # rubocop:enable Metrics/AbcSize
280
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
296
281
 
297
282
  def speak_response(text)
298
283
  return unless RUBY_PLATFORM =~ /darwin/
@@ -310,7 +295,47 @@ module Legion
310
295
  end
311
296
 
312
297
  def daemon_available?
313
- !!(defined?(Legion::LLM::DaemonClient) && Legion::LLM::DaemonClient.available?)
298
+ Legion::TTY::DaemonClient.available?
299
+ rescue StandardError => e
300
+ Legion::Logging.debug("daemon_available? check failed: #{e.message}") if defined?(Legion::Logging)
301
+ false
302
+ end
303
+
304
+ def build_inference_messages(current_message)
305
+ msgs = []
306
+ inject_system_message(msgs)
307
+ inject_history_messages(msgs)
308
+ msgs.pop if msgs.last&.dig(:role) == 'user'
309
+ msgs << { role: 'user', content: current_message }
310
+ msgs
311
+ end
312
+
313
+ def inject_system_message(msgs)
314
+ prompt = build_system_prompt(safe_config)
315
+ msgs << { role: 'system', content: prompt } if prompt && !prompt.strip.empty?
316
+ end
317
+
318
+ def inject_history_messages(msgs)
319
+ @message_stream.messages.each do |m|
320
+ next if m[:tool_panel]
321
+ next unless %i[user assistant].include?(m[:role])
322
+
323
+ content = m[:content].to_s
324
+ next if content.strip.empty?
325
+
326
+ msgs << { role: m[:role].to_s, content: content }
327
+ end
328
+ end
329
+
330
+ def track_inference_tokens(data)
331
+ return unless data.is_a?(Hash) && (data[:input_tokens] || data[:output_tokens])
332
+
333
+ @token_tracker.track(
334
+ input_tokens: data[:input_tokens].to_i,
335
+ output_tokens: data[:output_tokens].to_i,
336
+ model: data[:model]&.to_s
337
+ )
338
+ update_status_bar_tokens
314
339
  end
315
340
 
316
341
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -638,18 +663,6 @@ module Legion
638
663
  update_status_bar_tokens
639
664
  end
640
665
 
641
- def track_daemon_tokens(result)
642
- meta = result[:meta]
643
- return unless meta.is_a?(Hash) && (meta[:tokens_in] || meta[:tokens_out])
644
-
645
- @token_tracker.track(
646
- input_tokens: meta[:tokens_in].to_i,
647
- output_tokens: meta[:tokens_out].to_i,
648
- model: meta[:model]&.to_s
649
- )
650
- update_status_bar_tokens
651
- end
652
-
653
666
  def update_status_bar_tokens
654
667
  @status_bar.update(
655
668
  tokens: @token_tracker.total_input_tokens + @token_tracker.total_output_tokens,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.39'
5
+ VERSION = '0.4.40'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-tty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.39
4
+ version: 0.4.40
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity