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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/legion/tty/app.rb +10 -9
- data/lib/legion/tty/daemon_client.rb +38 -0
- data/lib/legion/tty/screens/chat/model_commands.rb +7 -23
- data/lib/legion/tty/screens/chat.rb +76 -63
- data/lib/legion/tty/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60e92790f0f6281b48346af3972e2631f1952b3b68f3d51922aa52da8682cfc9
|
|
4
|
+
data.tar.gz: 85491c70182ced9142b483c4edd903f3e5948bac1544c01cba69a86f565d2483
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/legion/tty/app.rb
CHANGED
|
@@ -468,15 +468,16 @@ module Legion
|
|
|
468
468
|
end
|
|
469
469
|
|
|
470
470
|
def try_settings_llm
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
76
|
+
@llm_chat = nil
|
|
77
77
|
@token_tracker = Components::TokenTracker.new(
|
|
78
78
|
provider: detect_provider,
|
|
79
|
-
model:
|
|
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
|
|
178
|
-
@message_stream.append_streaming(
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|
253
|
-
when :
|
|
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(
|
|
262
|
+
parser.feed(content)
|
|
256
263
|
parser.flush
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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,
|
data/lib/legion/tty/version.rb
CHANGED