legion-llm 0.3.1

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,1147 @@
1
+ # Ollama Discovery & System Memory Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add Ollama model discovery (`/api/tags`) and OS memory introspection to legion-llm so the router can skip rules targeting models that aren't pulled or that exceed available RAM.
6
+
7
+ **Architecture:** Two new modules (`Discovery::Ollama`, `Discovery::System`) provide lazy TTL-cached lookups. The Router's `select_candidates` pipeline gains one new filter step between constraint filtering and tier availability. Settings add a `discovery` key.
8
+
9
+ **Tech Stack:** Ruby, Faraday (transitive dep via ruby_llm), macOS `sysctl`/`vm_stat`, Linux `/proc/meminfo`, RSpec + WebMock
10
+
11
+ ---
12
+
13
+ ### Task 1: Add discovery settings defaults
14
+
15
+ **Files:**
16
+ - Modify: `lib/legion/llm/settings.rb:6-14`
17
+
18
+ **Step 1: Write the failing test**
19
+
20
+ Create `spec/legion/llm/discovery/settings_spec.rb`:
21
+
22
+ ```ruby
23
+ # frozen_string_literal: true
24
+
25
+ require 'spec_helper'
26
+
27
+ RSpec.describe 'Discovery settings defaults' do
28
+ it 'includes discovery key in LLM settings' do
29
+ expect(Legion::Settings[:llm][:discovery]).to be_a(Hash)
30
+ end
31
+
32
+ it 'defaults enabled to true' do
33
+ expect(Legion::Settings[:llm][:discovery][:enabled]).to be true
34
+ end
35
+
36
+ it 'defaults refresh_seconds to 60' do
37
+ expect(Legion::Settings[:llm][:discovery][:refresh_seconds]).to eq(60)
38
+ end
39
+
40
+ it 'defaults memory_floor_mb to 2048' do
41
+ expect(Legion::Settings[:llm][:discovery][:memory_floor_mb]).to eq(2048)
42
+ end
43
+ end
44
+ ```
45
+
46
+ **Step 2: Run test to verify it fails**
47
+
48
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/settings_spec.rb -v`
49
+ Expected: FAIL — `discovery` key is nil
50
+
51
+ **Step 3: Write minimal implementation**
52
+
53
+ Edit `lib/legion/llm/settings.rb`. Add `discovery: discovery_defaults` to the `default` hash and add the `discovery_defaults` method:
54
+
55
+ ```ruby
56
+ def self.default
57
+ {
58
+ enabled: true,
59
+ connected: false,
60
+ default_model: nil,
61
+ default_provider: nil,
62
+ providers: providers,
63
+ routing: routing_defaults,
64
+ discovery: discovery_defaults
65
+ }
66
+ end
67
+
68
+ def self.discovery_defaults
69
+ {
70
+ enabled: true,
71
+ refresh_seconds: 60,
72
+ memory_floor_mb: 2048
73
+ }
74
+ end
75
+ ```
76
+
77
+ **Step 4: Run test to verify it passes**
78
+
79
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/settings_spec.rb -v`
80
+ Expected: 4 examples, 0 failures
81
+
82
+ **Step 5: Run full suite to verify no regressions**
83
+
84
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
85
+ Expected: All existing tests pass
86
+
87
+ **Step 6: Commit**
88
+
89
+ ```bash
90
+ cd /Users/miverso2/rubymine/legion/legion-llm
91
+ git add lib/legion/llm/settings.rb spec/legion/llm/discovery/settings_spec.rb
92
+ git commit -m "add discovery settings defaults"
93
+ ```
94
+
95
+ ---
96
+
97
+ ### Task 2: Implement Discovery::System
98
+
99
+ **Files:**
100
+ - Create: `lib/legion/llm/discovery/system.rb`
101
+ - Create: `spec/legion/llm/discovery/system_spec.rb`
102
+
103
+ **Step 1: Write the failing test**
104
+
105
+ Create `spec/legion/llm/discovery/system_spec.rb`:
106
+
107
+ ```ruby
108
+ # frozen_string_literal: true
109
+
110
+ require 'spec_helper'
111
+ require 'legion/llm/discovery/system'
112
+
113
+ RSpec.describe Legion::LLM::Discovery::System do
114
+ before { described_class.reset! }
115
+
116
+ describe '.platform' do
117
+ it 'returns :macos, :linux, or :unknown' do
118
+ expect(%i[macos linux unknown]).to include(described_class.platform)
119
+ end
120
+ end
121
+
122
+ describe '.total_memory_mb' do
123
+ context 'on macOS' do
124
+ before do
125
+ allow(described_class).to receive(:platform).and_return(:macos)
126
+ # 64 GB in bytes
127
+ allow(described_class).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
128
+ end
129
+
130
+ it 'returns total memory in MB' do
131
+ described_class.refresh!
132
+ expect(described_class.total_memory_mb).to eq(65_536)
133
+ end
134
+ end
135
+
136
+ context 'on Linux' do
137
+ before do
138
+ allow(described_class).to receive(:platform).and_return(:linux)
139
+ meminfo = "MemTotal: 65536000 kB\nMemFree: 32000000 kB\nInactive: 8000000 kB\n"
140
+ allow(File).to receive(:read).with('/proc/meminfo').and_return(meminfo)
141
+ end
142
+
143
+ it 'returns total memory in MB' do
144
+ described_class.refresh!
145
+ expect(described_class.total_memory_mb).to eq(64_000)
146
+ end
147
+ end
148
+
149
+ context 'on unknown platform' do
150
+ before { allow(described_class).to receive(:platform).and_return(:unknown) }
151
+
152
+ it 'returns nil' do
153
+ described_class.refresh!
154
+ expect(described_class.total_memory_mb).to be_nil
155
+ end
156
+ end
157
+ end
158
+
159
+ describe '.available_memory_mb' do
160
+ context 'on macOS' do
161
+ before do
162
+ allow(described_class).to receive(:platform).and_return(:macos)
163
+ allow(described_class).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
164
+ vm_stat_output = <<~VMSTAT
165
+ Mach Virtual Memory Statistics: (page size of 16384 bytes)
166
+ Pages free: 500000.
167
+ Pages active: 200000.
168
+ Pages inactive: 300000.
169
+ Pages speculative: 50000.
170
+ Pages throttled: 0.
171
+ Pages wired down: 100000.
172
+ Pages purgeable: 10000.
173
+ VMSTAT
174
+ allow(described_class).to receive(:`).with('vm_stat').and_return(vm_stat_output)
175
+ end
176
+
177
+ it 'returns free + inactive pages in MB (excludes disk cache)' do
178
+ described_class.refresh!
179
+ # (500000 + 300000) pages * 16384 bytes / 1024 / 1024 = 12500 MB
180
+ expect(described_class.available_memory_mb).to eq(12_500)
181
+ end
182
+ end
183
+
184
+ context 'on Linux' do
185
+ before do
186
+ allow(described_class).to receive(:platform).and_return(:linux)
187
+ meminfo = "MemTotal: 65536000 kB\nMemFree: 32000000 kB\nInactive: 8000000 kB\n"
188
+ allow(File).to receive(:read).with('/proc/meminfo').and_return(meminfo)
189
+ end
190
+
191
+ it 'returns MemFree + Inactive in MB' do
192
+ described_class.refresh!
193
+ # (32000000 + 8000000) kB / 1024 = 39062 MB
194
+ expect(described_class.available_memory_mb).to eq(39_062)
195
+ end
196
+ end
197
+ end
198
+
199
+ describe '.memory_pressure?' do
200
+ before do
201
+ allow(described_class).to receive(:platform).and_return(:macos)
202
+ allow(described_class).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
203
+ end
204
+
205
+ context 'when available memory is below floor' do
206
+ before do
207
+ vm_stat_output = <<~VMSTAT
208
+ Mach Virtual Memory Statistics: (page size of 16384 bytes)
209
+ Pages free: 50000.
210
+ Pages active: 200000.
211
+ Pages inactive: 50000.
212
+ Pages speculative: 0.
213
+ VMSTAT
214
+ allow(described_class).to receive(:`).with('vm_stat').and_return(vm_stat_output)
215
+ end
216
+
217
+ it 'returns true' do
218
+ described_class.refresh!
219
+ # (50000 + 50000) * 16384 / 1024 / 1024 = 1562 MB < 2048 default floor
220
+ expect(described_class.memory_pressure?).to be true
221
+ end
222
+ end
223
+
224
+ context 'when available memory is above floor' do
225
+ before do
226
+ vm_stat_output = <<~VMSTAT
227
+ Mach Virtual Memory Statistics: (page size of 16384 bytes)
228
+ Pages free: 500000.
229
+ Pages active: 200000.
230
+ Pages inactive: 300000.
231
+ Pages speculative: 0.
232
+ VMSTAT
233
+ allow(described_class).to receive(:`).with('vm_stat').and_return(vm_stat_output)
234
+ end
235
+
236
+ it 'returns false' do
237
+ described_class.refresh!
238
+ expect(described_class.memory_pressure?).to be false
239
+ end
240
+ end
241
+ end
242
+
243
+ describe '.stale?' do
244
+ it 'returns true when never refreshed' do
245
+ expect(described_class.stale?).to be true
246
+ end
247
+
248
+ it 'returns false immediately after refresh' do
249
+ allow(described_class).to receive(:platform).and_return(:unknown)
250
+ described_class.refresh!
251
+ expect(described_class.stale?).to be false
252
+ end
253
+ end
254
+
255
+ describe '.reset!' do
256
+ it 'clears cached data' do
257
+ allow(described_class).to receive(:platform).and_return(:unknown)
258
+ described_class.refresh!
259
+ described_class.reset!
260
+ expect(described_class.stale?).to be true
261
+ end
262
+ end
263
+ end
264
+ ```
265
+
266
+ **Step 2: Run test to verify it fails**
267
+
268
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/system_spec.rb -v`
269
+ Expected: FAIL — file not found / constant not defined
270
+
271
+ **Step 3: Write minimal implementation**
272
+
273
+ Create `lib/legion/llm/discovery/system.rb`:
274
+
275
+ ```ruby
276
+ # frozen_string_literal: true
277
+
278
+ module Legion
279
+ module LLM
280
+ module Discovery
281
+ module System
282
+ class << self
283
+ def total_memory_mb
284
+ ensure_fresh
285
+ @total_memory_mb
286
+ end
287
+
288
+ def available_memory_mb
289
+ ensure_fresh
290
+ @available_memory_mb
291
+ end
292
+
293
+ def memory_pressure?
294
+ avail = available_memory_mb
295
+ return false if avail.nil?
296
+
297
+ floor = discovery_settings[:memory_floor_mb] || 2048
298
+ avail < floor
299
+ end
300
+
301
+ def platform
302
+ @platform ||= detect_platform
303
+ end
304
+
305
+ def refresh!
306
+ case platform
307
+ when :macos then refresh_macos
308
+ when :linux then refresh_linux
309
+ else
310
+ @total_memory_mb = nil
311
+ @available_memory_mb = nil
312
+ end
313
+ @last_refreshed_at = Time.now
314
+ end
315
+
316
+ def reset!
317
+ @total_memory_mb = nil
318
+ @available_memory_mb = nil
319
+ @last_refreshed_at = nil
320
+ @platform = nil
321
+ end
322
+
323
+ def stale?
324
+ return true if @last_refreshed_at.nil?
325
+
326
+ ttl = discovery_settings[:refresh_seconds] || 60
327
+ Time.now - @last_refreshed_at > ttl
328
+ end
329
+
330
+ private
331
+
332
+ def ensure_fresh
333
+ refresh! if stale?
334
+ end
335
+
336
+ def detect_platform
337
+ case RbConfig::CONFIG['host_os']
338
+ when /darwin/i then :macos
339
+ when /linux/i then :linux
340
+ else :unknown
341
+ end
342
+ end
343
+
344
+ def refresh_macos
345
+ raw_total = `sysctl -n hw.memsize`.strip.to_i
346
+ @total_memory_mb = raw_total / 1024 / 1024
347
+
348
+ vm_output = `vm_stat`
349
+ page_size = vm_output[/page size of (\d+) bytes/, 1]&.to_i || 16_384
350
+ free = vm_output[/Pages free:\s+(\d+)/, 1]&.to_i || 0
351
+ inactive = vm_output[/Pages inactive:\s+(\d+)/, 1]&.to_i || 0
352
+
353
+ @available_memory_mb = (free + inactive) * page_size / 1024 / 1024
354
+ rescue StandardError
355
+ @total_memory_mb = nil
356
+ @available_memory_mb = nil
357
+ end
358
+
359
+ def refresh_linux
360
+ meminfo = File.read('/proc/meminfo')
361
+ total_kb = meminfo[/MemTotal:\s+(\d+)/, 1]&.to_i || 0
362
+ free_kb = meminfo[/MemFree:\s+(\d+)/, 1]&.to_i || 0
363
+ inactive_kb = meminfo[/Inactive:\s+(\d+)/, 1]&.to_i || 0
364
+
365
+ @total_memory_mb = total_kb / 1024
366
+ @available_memory_mb = (free_kb + inactive_kb) / 1024
367
+ rescue StandardError
368
+ @total_memory_mb = nil
369
+ @available_memory_mb = nil
370
+ end
371
+
372
+ def discovery_settings
373
+ return {} unless Legion.const_defined?('Settings')
374
+
375
+ Legion::Settings[:llm][:discovery] || {}
376
+ rescue StandardError
377
+ {}
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
384
+ ```
385
+
386
+ **Step 4: Run test to verify it passes**
387
+
388
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/system_spec.rb -v`
389
+ Expected: All examples pass
390
+
391
+ **Step 5: Run full suite**
392
+
393
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
394
+ Expected: All tests pass
395
+
396
+ **Step 6: Commit**
397
+
398
+ ```bash
399
+ cd /Users/miverso2/rubymine/legion/legion-llm
400
+ git add lib/legion/llm/discovery/system.rb spec/legion/llm/discovery/system_spec.rb
401
+ git commit -m "add Discovery::System for OS memory introspection"
402
+ ```
403
+
404
+ ---
405
+
406
+ ### Task 3: Implement Discovery::Ollama
407
+
408
+ **Files:**
409
+ - Create: `lib/legion/llm/discovery/ollama.rb`
410
+ - Create: `spec/legion/llm/discovery/ollama_spec.rb`
411
+
412
+ **Step 1: Write the failing test**
413
+
414
+ Create `spec/legion/llm/discovery/ollama_spec.rb`:
415
+
416
+ ```ruby
417
+ # frozen_string_literal: true
418
+
419
+ require 'spec_helper'
420
+ require 'legion/llm/discovery/ollama'
421
+
422
+ RSpec.describe Legion::LLM::Discovery::Ollama do
423
+ before { described_class.reset! }
424
+
425
+ let(:tags_response) do
426
+ {
427
+ 'models' => [
428
+ { 'name' => 'llama3.1:8b', 'size' => 4_700_000_000, 'digest' => 'sha256:abc' },
429
+ { 'name' => 'qwen2.5:32b', 'size' => 20_000_000_000, 'digest' => 'sha256:def' },
430
+ { 'name' => 'nomic-embed-text', 'size' => 274_000_000, 'digest' => 'sha256:ghi' }
431
+ ]
432
+ }
433
+ end
434
+
435
+ before do
436
+ stub_request(:get, 'http://localhost:11434/api/tags')
437
+ .to_return(status: 200, body: tags_response.to_json, headers: { 'Content-Type' => 'application/json' })
438
+ end
439
+
440
+ describe '.models' do
441
+ it 'returns array of model hashes from Ollama' do
442
+ expect(described_class.models).to be_an(Array)
443
+ expect(described_class.models.size).to eq(3)
444
+ end
445
+
446
+ it 'includes model name and size' do
447
+ model = described_class.models.first
448
+ expect(model['name']).to eq('llama3.1:8b')
449
+ expect(model['size']).to eq(4_700_000_000)
450
+ end
451
+ end
452
+
453
+ describe '.model_names' do
454
+ it 'returns array of model name strings' do
455
+ expect(described_class.model_names).to eq(['llama3.1:8b', 'qwen2.5:32b', 'nomic-embed-text'])
456
+ end
457
+ end
458
+
459
+ describe '.model_available?' do
460
+ it 'returns true for a pulled model' do
461
+ expect(described_class.model_available?('llama3.1:8b')).to be true
462
+ end
463
+
464
+ it 'returns false for a model not pulled' do
465
+ expect(described_class.model_available?('nonexistent:latest')).to be false
466
+ end
467
+ end
468
+
469
+ describe '.model_size' do
470
+ it 'returns size in bytes for a known model' do
471
+ expect(described_class.model_size('qwen2.5:32b')).to eq(20_000_000_000)
472
+ end
473
+
474
+ it 'returns nil for an unknown model' do
475
+ expect(described_class.model_size('nonexistent:latest')).to be_nil
476
+ end
477
+ end
478
+
479
+ describe 'when Ollama is not running' do
480
+ before do
481
+ described_class.reset!
482
+ stub_request(:get, 'http://localhost:11434/api/tags').to_timeout
483
+ end
484
+
485
+ it 'returns empty array for models' do
486
+ expect(described_class.models).to eq([])
487
+ end
488
+
489
+ it 'returns false for model_available?' do
490
+ expect(described_class.model_available?('llama3.1:8b')).to be false
491
+ end
492
+ end
493
+
494
+ describe 'when Ollama returns non-200' do
495
+ before do
496
+ described_class.reset!
497
+ stub_request(:get, 'http://localhost:11434/api/tags').to_return(status: 500, body: 'error')
498
+ end
499
+
500
+ it 'returns empty array for models' do
501
+ expect(described_class.models).to eq([])
502
+ end
503
+ end
504
+
505
+ describe '.stale?' do
506
+ it 'returns true when never refreshed' do
507
+ expect(described_class.stale?).to be true
508
+ end
509
+
510
+ it 'returns false immediately after refresh' do
511
+ described_class.refresh!
512
+ expect(described_class.stale?).to be false
513
+ end
514
+ end
515
+
516
+ describe '.reset!' do
517
+ it 'clears cached models' do
518
+ described_class.refresh!
519
+ expect(described_class.models.size).to eq(3)
520
+ described_class.reset!
521
+ # After reset, next access triggers fresh fetch
522
+ stub_request(:get, 'http://localhost:11434/api/tags')
523
+ .to_return(status: 200, body: { 'models' => [] }.to_json)
524
+ expect(described_class.models).to eq([])
525
+ end
526
+ end
527
+
528
+ describe 'TTL-based staleness' do
529
+ it 'uses refresh_seconds from settings' do
530
+ Legion::Settings[:llm][:discovery] = { enabled: true, refresh_seconds: 10 }
531
+ described_class.refresh!
532
+ expect(described_class.stale?).to be false
533
+
534
+ # Simulate time passing
535
+ described_class.instance_variable_set(:@last_refreshed_at, Time.now - 11)
536
+ expect(described_class.stale?).to be true
537
+ end
538
+ end
539
+
540
+ describe 'custom base_url' do
541
+ before do
542
+ described_class.reset!
543
+ Legion::Settings[:llm][:providers][:ollama][:base_url] = 'http://gpu-server:11434'
544
+ stub_request(:get, 'http://gpu-server:11434/api/tags')
545
+ .to_return(status: 200, body: tags_response.to_json, headers: { 'Content-Type' => 'application/json' })
546
+ end
547
+
548
+ it 'queries the configured base_url' do
549
+ described_class.refresh!
550
+ expect(described_class.model_names).to eq(['llama3.1:8b', 'qwen2.5:32b', 'nomic-embed-text'])
551
+ end
552
+ end
553
+ end
554
+ ```
555
+
556
+ **Step 2: Run test to verify it fails**
557
+
558
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/ollama_spec.rb -v`
559
+ Expected: FAIL — constant not defined
560
+
561
+ **Step 3: Write minimal implementation**
562
+
563
+ Create `lib/legion/llm/discovery/ollama.rb`:
564
+
565
+ ```ruby
566
+ # frozen_string_literal: true
567
+
568
+ require 'faraday'
569
+ require 'json'
570
+
571
+ module Legion
572
+ module LLM
573
+ module Discovery
574
+ module Ollama
575
+ class << self
576
+ def models
577
+ ensure_fresh
578
+ @models || []
579
+ end
580
+
581
+ def model_names
582
+ models.map { |m| m['name'] }
583
+ end
584
+
585
+ def model_available?(name)
586
+ model_names.include?(name)
587
+ end
588
+
589
+ def model_size(name)
590
+ models.find { |m| m['name'] == name }&.dig('size')
591
+ end
592
+
593
+ def refresh!
594
+ response = connection.get('/api/tags')
595
+ if response.success?
596
+ parsed = ::JSON.parse(response.body)
597
+ @models = parsed['models'] || []
598
+ else
599
+ @models = [] unless @models
600
+ end
601
+ rescue StandardError
602
+ @models ||= []
603
+ ensure
604
+ @last_refreshed_at = Time.now
605
+ end
606
+
607
+ def reset!
608
+ @models = nil
609
+ @last_refreshed_at = nil
610
+ end
611
+
612
+ def stale?
613
+ return true if @last_refreshed_at.nil?
614
+
615
+ ttl = discovery_settings[:refresh_seconds] || 60
616
+ Time.now - @last_refreshed_at > ttl
617
+ end
618
+
619
+ private
620
+
621
+ def ensure_fresh
622
+ refresh! if stale?
623
+ end
624
+
625
+ def connection
626
+ base = ollama_base_url
627
+ Faraday.new(url: base) do |f|
628
+ f.options.timeout = 2
629
+ f.options.open_timeout = 2
630
+ f.adapter Faraday.default_adapter
631
+ end
632
+ end
633
+
634
+ def ollama_base_url
635
+ return 'http://localhost:11434' unless Legion.const_defined?('Settings')
636
+
637
+ Legion::Settings[:llm].dig(:providers, :ollama, :base_url) || 'http://localhost:11434'
638
+ rescue StandardError
639
+ 'http://localhost:11434'
640
+ end
641
+
642
+ def discovery_settings
643
+ return {} unless Legion.const_defined?('Settings')
644
+
645
+ Legion::Settings[:llm][:discovery] || {}
646
+ rescue StandardError
647
+ {}
648
+ end
649
+ end
650
+ end
651
+ end
652
+ end
653
+ end
654
+ ```
655
+
656
+ **Step 4: Run test to verify it passes**
657
+
658
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/ollama_spec.rb -v`
659
+ Expected: All examples pass
660
+
661
+ **Step 5: Run full suite**
662
+
663
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
664
+ Expected: All tests pass
665
+
666
+ **Step 6: Commit**
667
+
668
+ ```bash
669
+ cd /Users/miverso2/rubymine/legion/legion-llm
670
+ git add lib/legion/llm/discovery/ollama.rb spec/legion/llm/discovery/ollama_spec.rb
671
+ git commit -m "add Discovery::Ollama for model tag introspection"
672
+ ```
673
+
674
+ ---
675
+
676
+ ### Task 4: Integrate discovery into Router.select_candidates
677
+
678
+ **Files:**
679
+ - Modify: `lib/legion/llm/router.rb:1-8` (add require) and `lib/legion/llm/router.rb:88-105` (add filter step)
680
+ - Create: `spec/legion/llm/discovery/router_integration_spec.rb`
681
+
682
+ **Step 1: Write the failing test**
683
+
684
+ Create `spec/legion/llm/discovery/router_integration_spec.rb`:
685
+
686
+ ```ruby
687
+ # frozen_string_literal: true
688
+
689
+ require 'spec_helper'
690
+ require 'legion/llm/discovery/ollama'
691
+ require 'legion/llm/discovery/system'
692
+
693
+ RSpec.describe 'Router discovery integration' do
694
+ let(:rules_with_local) do
695
+ [
696
+ {
697
+ name: 'local-small',
698
+ when: { capability: 'basic' },
699
+ then: { tier: 'local', provider: 'ollama', model: 'llama3.1:8b' },
700
+ priority: 80,
701
+ cost_multiplier: 0.1
702
+ },
703
+ {
704
+ name: 'cloud-fallback',
705
+ when: { capability: 'basic' },
706
+ then: { tier: 'cloud', provider: 'bedrock', model: 'claude-sonnet-4-6' },
707
+ priority: 20,
708
+ cost_multiplier: 1.0
709
+ }
710
+ ]
711
+ end
712
+
713
+ before do
714
+ Legion::LLM::Router.reset!
715
+ Legion::LLM::Discovery::Ollama.reset!
716
+ Legion::LLM::Discovery::System.reset!
717
+ allow(Legion::LLM::Router).to receive(:tier_available?).and_return(true)
718
+ end
719
+
720
+ def configure_routing(rules:)
721
+ Legion::Settings[:llm] = Legion::Settings[:llm].merge(
722
+ routing: {
723
+ enabled: true,
724
+ rules: rules,
725
+ default_intent: { privacy: 'normal', capability: 'basic' }
726
+ },
727
+ discovery: { enabled: true, refresh_seconds: 60, memory_floor_mb: 2048 }
728
+ )
729
+ end
730
+
731
+ describe 'when Ollama model is not pulled' do
732
+ before do
733
+ configure_routing(rules: rules_with_local)
734
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(false)
735
+ allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(32_000)
736
+ end
737
+
738
+ it 'skips the local rule and falls through to cloud' do
739
+ result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
740
+ expect(result).not_to be_nil
741
+ expect(result.rule).to eq('cloud-fallback')
742
+ expect(result.tier).to eq(:cloud)
743
+ end
744
+ end
745
+
746
+ describe 'when Ollama model is pulled and fits in memory' do
747
+ before do
748
+ configure_routing(rules: rules_with_local)
749
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(true)
750
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_size).with('llama3.1:8b').and_return(4_700_000_000)
751
+ allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(32_000)
752
+ end
753
+
754
+ it 'selects the local rule' do
755
+ result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
756
+ expect(result).not_to be_nil
757
+ expect(result.rule).to eq('local-small')
758
+ expect(result.tier).to eq(:local)
759
+ end
760
+ end
761
+
762
+ describe 'when model is pulled but does not fit in memory' do
763
+ before do
764
+ configure_routing(rules: rules_with_local)
765
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(true)
766
+ # Model is 4.7 GB, but only 3 GB available after floor
767
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_size).with('llama3.1:8b').and_return(4_700_000_000)
768
+ allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(5_000)
769
+ end
770
+
771
+ it 'skips the local rule (insufficient memory after floor)' do
772
+ # 5000 MB available - 2048 MB floor = 2952 MB usable, model needs ~4482 MB
773
+ result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
774
+ expect(result).not_to be_nil
775
+ expect(result.rule).to eq('cloud-fallback')
776
+ end
777
+ end
778
+
779
+ describe 'when discovery is disabled' do
780
+ before do
781
+ Legion::Settings[:llm] = Legion::Settings[:llm].merge(
782
+ routing: {
783
+ enabled: true,
784
+ rules: rules_with_local,
785
+ default_intent: { privacy: 'normal', capability: 'basic' }
786
+ },
787
+ discovery: { enabled: false }
788
+ )
789
+ end
790
+
791
+ it 'does not filter by discovery — local rule passes through' do
792
+ result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
793
+ expect(result).not_to be_nil
794
+ expect(result.rule).to eq('local-small')
795
+ end
796
+ end
797
+
798
+ describe 'when system memory is nil (unknown platform)' do
799
+ before do
800
+ configure_routing(rules: rules_with_local)
801
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(true)
802
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_size).with('llama3.1:8b').and_return(4_700_000_000)
803
+ allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(nil)
804
+ end
805
+
806
+ it 'bypasses memory check (permissive) and selects local rule' do
807
+ result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
808
+ expect(result).not_to be_nil
809
+ expect(result.rule).to eq('local-small')
810
+ end
811
+ end
812
+
813
+ describe 'non-Ollama rules are unaffected' do
814
+ let(:cloud_only_rules) do
815
+ [
816
+ {
817
+ name: 'cloud-reasoning',
818
+ when: { capability: 'reasoning' },
819
+ then: { tier: 'cloud', provider: 'bedrock', model: 'claude-sonnet-4-6' },
820
+ priority: 50,
821
+ cost_multiplier: 1.0
822
+ }
823
+ ]
824
+ end
825
+
826
+ before { configure_routing(rules: cloud_only_rules) }
827
+
828
+ it 'does not check discovery for cloud rules' do
829
+ expect(Legion::LLM::Discovery::Ollama).not_to receive(:model_available?)
830
+ Legion::LLM::Router.resolve(intent: { capability: 'reasoning' })
831
+ end
832
+ end
833
+ end
834
+ ```
835
+
836
+ **Step 2: Run test to verify it fails**
837
+
838
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/router_integration_spec.rb -v`
839
+ Expected: FAIL — discovery filtering not implemented
840
+
841
+ **Step 3: Write minimal implementation**
842
+
843
+ Edit `lib/legion/llm/router.rb`:
844
+
845
+ At the top, add requires after the existing ones (line 4):
846
+
847
+ ```ruby
848
+ require_relative 'discovery/ollama'
849
+ require_relative 'discovery/system'
850
+ ```
851
+
852
+ In `select_candidates` method (currently lines 88-105), add step 4.5 between step 4 and step 5. Replace the method with:
853
+
854
+ ```ruby
855
+ def select_candidates(rules, intent)
856
+ # 1. Collect constraints from constraint rules that match the intent
857
+ constraints = rules
858
+ .select { |r| r.constraint && r.matches_intent?(intent) }
859
+ .map(&:constraint)
860
+
861
+ # 2. Filter by intent match
862
+ matched = rules.select { |r| r.matches_intent?(intent) }
863
+
864
+ # 3. Filter by schedule
865
+ scheduled = matched.select(&:within_schedule?)
866
+
867
+ # 4. Reject rules excluded by active constraints
868
+ unconstrained = scheduled.reject { |r| excluded_by_constraint?(r, constraints) }
869
+
870
+ # 4.5 Reject Ollama rules where model is not pulled or doesn't fit
871
+ discovered = unconstrained.reject { |r| excluded_by_discovery?(r) }
872
+
873
+ # 5. Filter by tier availability
874
+ discovered.select { |r| tier_available?(r.target[:tier] || r.target['tier']) }
875
+ end
876
+ ```
877
+
878
+ Add the new private method `excluded_by_discovery?`:
879
+
880
+ ```ruby
881
+ def excluded_by_discovery?(rule)
882
+ return false unless discovery_enabled?
883
+
884
+ tier = (rule.target[:tier] || rule.target['tier'])&.to_sym
885
+ provider = (rule.target[:provider] || rule.target['provider'])&.to_sym
886
+ model = rule.target[:model] || rule.target['model']
887
+
888
+ return false unless tier == :local && provider == :ollama && model
889
+
890
+ return true unless Discovery::Ollama.model_available?(model)
891
+
892
+ model_bytes = Discovery::Ollama.model_size(model)
893
+ available = Discovery::System.available_memory_mb
894
+ return false if model_bytes.nil? || available.nil?
895
+
896
+ floor = discovery_settings[:memory_floor_mb] || 2048
897
+ model_mb = model_bytes / 1024 / 1024
898
+ model_mb > (available - floor)
899
+ end
900
+
901
+ def discovery_enabled?
902
+ ds = discovery_settings
903
+ ds.fetch(:enabled, true)
904
+ end
905
+
906
+ def discovery_settings
907
+ llm = Legion::Settings[:llm]
908
+ return {} unless llm.is_a?(Hash)
909
+
910
+ (llm[:discovery] || {}).transform_keys(&:to_sym)
911
+ rescue StandardError
912
+ {}
913
+ end
914
+ ```
915
+
916
+ **Step 4: Run test to verify it passes**
917
+
918
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/router_integration_spec.rb -v`
919
+ Expected: All examples pass
920
+
921
+ **Step 5: Run full suite**
922
+
923
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
924
+ Expected: All tests pass (existing router_spec stubs `tier_available?` but doesn't stub discovery — discovery checks should pass through because the rules in existing tests won't trigger the Ollama check when discovery module returns empty models, causing `model_available?` to return false. **Important:** The existing `router_spec.rb` stubs `tier_available?` but the new discovery filter will now reject local Ollama rules unless we also handle this. Existing specs use `allow(described_class).to receive(:tier_available?).and_return(true)` — we need to also stub discovery for existing specs. Add to `router_spec.rb` `before` block: `allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).and_return(true)` and `allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(65_536)`)
925
+
926
+ **Step 6: Fix existing router_spec.rb**
927
+
928
+ Edit `spec/legion/llm/router_spec.rb` — add to the existing `before` block (line 46-49):
929
+
930
+ ```ruby
931
+ before do
932
+ described_class.reset!
933
+ allow(described_class).to receive(:tier_available?).and_return(true)
934
+ allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).and_return(true)
935
+ allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(65_536)
936
+ end
937
+ ```
938
+
939
+ **Step 7: Run full suite again**
940
+
941
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
942
+ Expected: All tests pass
943
+
944
+ **Step 8: Commit**
945
+
946
+ ```bash
947
+ cd /Users/miverso2/rubymine/legion/legion-llm
948
+ git add lib/legion/llm/router.rb lib/legion/llm/discovery/ollama.rb lib/legion/llm/discovery/system.rb \
949
+ spec/legion/llm/discovery/router_integration_spec.rb spec/legion/llm/router_spec.rb
950
+ git commit -m "integrate discovery filtering into router pipeline"
951
+ ```
952
+
953
+ ---
954
+
955
+ ### Task 5: Add startup discovery logging
956
+
957
+ **Files:**
958
+ - Modify: `lib/legion/llm.rb:15-25` (add discovery warmup to `start`)
959
+
960
+ **Step 1: Write the failing test**
961
+
962
+ Add to `spec/legion/llm_spec.rb` (or create a focused spec — check existing file first to see the pattern, then add a new describe block). Create `spec/legion/llm/discovery/startup_spec.rb`:
963
+
964
+ ```ruby
965
+ # frozen_string_literal: true
966
+
967
+ require 'spec_helper'
968
+ require 'legion/llm/discovery/ollama'
969
+ require 'legion/llm/discovery/system'
970
+
971
+ RSpec.describe 'LLM startup discovery' do
972
+ before do
973
+ Legion::LLM::Discovery::Ollama.reset!
974
+ Legion::LLM::Discovery::System.reset!
975
+ allow(RubyLLM).to receive(:configure)
976
+ allow(RubyLLM).to receive(:chat).and_return(double(ask: 'pong'))
977
+ end
978
+
979
+ context 'when Ollama provider is enabled' do
980
+ before do
981
+ Legion::Settings[:llm][:providers][:ollama][:enabled] = true
982
+ stub_request(:get, 'http://localhost:11434/api/tags')
983
+ .to_return(status: 200, body: { 'models' => [{ 'name' => 'llama3:latest', 'size' => 4_000_000_000 }] }.to_json)
984
+ allow(Legion::LLM::Discovery::System).to receive(:platform).and_return(:macos)
985
+ allow(Legion::LLM::Discovery::System).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
986
+ allow(Legion::LLM::Discovery::System).to receive(:`).with('vm_stat').and_return(
987
+ "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 500000.\nPages inactive: 300000.\n"
988
+ )
989
+ end
990
+
991
+ it 'refreshes discovery caches during start' do
992
+ expect(Legion::LLM::Discovery::Ollama).to receive(:refresh!)
993
+ expect(Legion::LLM::Discovery::System).to receive(:refresh!)
994
+ Legion::LLM.start
995
+ end
996
+
997
+ it 'logs discovered models' do
998
+ expect(Legion::Logging).to receive(:info).with(/Ollama: 1 model/).at_least(:once)
999
+ Legion::LLM.start
1000
+ end
1001
+ end
1002
+
1003
+ context 'when Ollama provider is disabled' do
1004
+ before do
1005
+ Legion::Settings[:llm][:providers][:ollama][:enabled] = false
1006
+ end
1007
+
1008
+ it 'does not refresh discovery caches' do
1009
+ expect(Legion::LLM::Discovery::Ollama).not_to receive(:refresh!)
1010
+ Legion::LLM.start
1011
+ end
1012
+ end
1013
+ end
1014
+ ```
1015
+
1016
+ **Step 2: Run test to verify it fails**
1017
+
1018
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/startup_spec.rb -v`
1019
+ Expected: FAIL — `refresh!` not called during start
1020
+
1021
+ **Step 3: Write minimal implementation**
1022
+
1023
+ Edit `lib/legion/llm.rb`. Add require at top (after line 8):
1024
+
1025
+ ```ruby
1026
+ require 'legion/llm/discovery/ollama'
1027
+ require 'legion/llm/discovery/system'
1028
+ ```
1029
+
1030
+ Modify the `start` method to call discovery between `configure_providers` and `set_defaults`:
1031
+
1032
+ ```ruby
1033
+ def start
1034
+ Legion::Logging.debug 'Legion::LLM is running start'
1035
+
1036
+ configure_providers
1037
+ run_discovery
1038
+ set_defaults
1039
+
1040
+ @started = true
1041
+ Legion::Settings[:llm][:connected] = true
1042
+ Legion::Logging.info 'Legion::LLM started'
1043
+ ping_provider
1044
+ end
1045
+ ```
1046
+
1047
+ Add the private method:
1048
+
1049
+ ```ruby
1050
+ def run_discovery
1051
+ return unless settings.dig(:providers, :ollama, :enabled)
1052
+
1053
+ Discovery::Ollama.refresh!
1054
+ Discovery::System.refresh!
1055
+
1056
+ names = Discovery::Ollama.model_names
1057
+ count = names.size
1058
+ Legion::Logging.info "Ollama: #{count} model#{'s' unless count == 1} available (#{names.join(', ')})"
1059
+ Legion::Logging.info "System: #{Discovery::System.total_memory_mb} MB total, " \
1060
+ "#{Discovery::System.available_memory_mb} MB available"
1061
+ rescue StandardError => e
1062
+ Legion::Logging.warn "Discovery failed: #{e.message}"
1063
+ end
1064
+ ```
1065
+
1066
+ **Step 4: Run test to verify it passes**
1067
+
1068
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/startup_spec.rb -v`
1069
+ Expected: All examples pass
1070
+
1071
+ **Step 5: Run full suite**
1072
+
1073
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
1074
+ Expected: All tests pass
1075
+
1076
+ **Step 6: Commit**
1077
+
1078
+ ```bash
1079
+ cd /Users/miverso2/rubymine/legion/legion-llm
1080
+ git add lib/legion/llm.rb spec/legion/llm/discovery/startup_spec.rb
1081
+ git commit -m "add discovery warmup and logging to LLM startup"
1082
+ ```
1083
+
1084
+ ---
1085
+
1086
+ ### Task 6: Run RuboCop, bump version, update docs
1087
+
1088
+ **Files:**
1089
+ - Modify: `lib/legion/llm/version.rb` (bump 0.2.1 -> 0.2.2)
1090
+ - Modify: `CHANGELOG.md`
1091
+
1092
+ **Step 1: Run RuboCop**
1093
+
1094
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rubocop`
1095
+ Expected: 0 offenses (fix any that appear)
1096
+
1097
+ **Step 2: Bump version**
1098
+
1099
+ Edit `lib/legion/llm/version.rb`:
1100
+
1101
+ ```ruby
1102
+ VERSION = '0.2.2'
1103
+ ```
1104
+
1105
+ **Step 3: Update CHANGELOG.md**
1106
+
1107
+ Add under `## [Unreleased]`:
1108
+
1109
+ ```markdown
1110
+ ## [0.2.2]
1111
+
1112
+ ### Added
1113
+ - `Legion::LLM::Discovery::Ollama` module — queries Ollama `/api/tags` for pulled models with TTL cache
1114
+ - `Legion::LLM::Discovery::System` module — queries OS memory (macOS `vm_stat`/`sysctl`, Linux `/proc/meminfo`) with TTL cache
1115
+ - Router step 4.5: rejects Ollama rules where model is not pulled or exceeds available memory
1116
+ - Discovery settings: `enabled`, `refresh_seconds`, `memory_floor_mb` under `Legion::Settings[:llm][:discovery]`
1117
+ - Startup discovery: logs available Ollama models and system memory when Ollama provider is enabled
1118
+ ```
1119
+
1120
+ **Step 4: Run full suite one final time**
1121
+
1122
+ Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec && bundle exec rubocop`
1123
+ Expected: All tests pass, 0 offenses
1124
+
1125
+ **Step 5: Update CLAUDE.md**
1126
+
1127
+ Add the new files to the File Map table in `CLAUDE.md`:
1128
+
1129
+ ```
1130
+ | `lib/legion/llm/discovery/ollama.rb` | Ollama /api/tags discovery with TTL cache |
1131
+ | `lib/legion/llm/discovery/system.rb` | OS memory introspection (macOS + Linux) with TTL cache |
1132
+ | `spec/legion/llm/discovery/ollama_spec.rb` | Tests: Ollama model discovery |
1133
+ | `spec/legion/llm/discovery/system_spec.rb` | Tests: System memory introspection |
1134
+ | `spec/legion/llm/discovery/router_integration_spec.rb` | Tests: Router discovery filtering |
1135
+ | `spec/legion/llm/discovery/startup_spec.rb` | Tests: Startup discovery warmup |
1136
+ | `spec/legion/llm/discovery/settings_spec.rb` | Tests: Discovery settings defaults |
1137
+ ```
1138
+
1139
+ Update the Module Structure diagram to include Discovery.
1140
+
1141
+ **Step 6: Commit**
1142
+
1143
+ ```bash
1144
+ cd /Users/miverso2/rubymine/legion/legion-llm
1145
+ git add lib/legion/llm/version.rb CHANGELOG.md CLAUDE.md
1146
+ git commit -m "bump to 0.2.2, update changelog and docs for discovery feature"
1147
+ ```