aia 1.1.1 → 2.0.0.0.pre.beta2

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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +9 -1
  3. data/.loki +11 -0
  4. data/.reek.yml +160 -0
  5. data/.rubocop.yml +116 -0
  6. data/.rubocop_strict.yml +15 -0
  7. data/.version +1 -1
  8. data/CHANGELOG.md +269 -55
  9. data/IMPLEMENTATION_PLAN.md +506 -0
  10. data/README.md +267 -239
  11. data/Rakefile +5 -5
  12. data/_typos.toml +10 -0
  13. data/aia.gemspec +92 -0
  14. data/architecture_review.md +314 -0
  15. data/bin/aia +16 -0
  16. data/config/aia.yml +13 -0
  17. data/docs/AGENTS.md +40 -0
  18. data/docs/advanced-prompting.md +67 -3
  19. data/docs/cli-reference.md +312 -56
  20. data/docs/configuration.md +130 -19
  21. data/docs/contributing.md +56 -2
  22. data/docs/directives-reference.md +593 -78
  23. data/docs/faq.md +85 -3
  24. data/docs/guides/available-models.md +1 -1
  25. data/docs/guides/basic-usage.md +6 -6
  26. data/docs/guides/chat.md +40 -16
  27. data/docs/guides/crew.md +239 -0
  28. data/docs/guides/executable-prompts.md +1 -1
  29. data/docs/guides/index.md +1 -0
  30. data/docs/guides/models.md +15 -0
  31. data/docs/index.md +29 -2
  32. data/docs/installation.md +44 -17
  33. data/docs/mcp-integration.md +40 -0
  34. data/docs/prompt_management.md +85 -86
  35. data/docs/security.md +47 -0
  36. data/docs/special_projects_guide.md +386 -0
  37. data/docs/tools-and-mcp-examples.md +23 -0
  38. data/docs/workflows-and-pipelines.md +84 -7
  39. data/examples/.gitignore +1 -0
  40. data/examples/00_setup_aia.sh +27 -44
  41. data/examples/11_multi_model.sh +4 -14
  42. data/examples/12_token_usage.sh +3 -12
  43. data/examples/18_tools.sh +10 -2
  44. data/examples/22_chat_mode.sh +0 -10
  45. data/examples/23_verify.sh +139 -0
  46. data/examples/24_decompose.sh +139 -0
  47. data/examples/25_spawn.sh +139 -0
  48. data/examples/26_debate.sh +97 -0
  49. data/examples/27_mention_routing.sh +157 -0
  50. data/examples/28_model_switching.sh +106 -0
  51. data/examples/29_agent_harness.sh +177 -0
  52. data/examples/README.md +65 -0
  53. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  54. data/examples/aia_config.yml +1 -1
  55. data/examples/aia_config_orchestrator.yml +45 -0
  56. data/examples/common.sh +18 -6
  57. data/examples/context/tech_stack.md +2 -2
  58. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  59. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  60. data/examples/rules/01_classify_ruby.rb +16 -0
  61. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  62. data/examples/rules/03_gate_prompt_length.rb +19 -0
  63. data/examples/rules/04_tool_selection.rb +41 -0
  64. data/examples/rules/README.md +30 -0
  65. data/examples/run_all.sh +48 -15
  66. data/examples/tools/word_count_tool.rb +1 -1
  67. data/lib/AGENTS.md +57 -0
  68. data/lib/aia/chat_loop.rb +263 -167
  69. data/lib/aia/config/cli_parser.rb +217 -145
  70. data/lib/aia/config/defaults.yml +62 -33
  71. data/lib/aia/config/mcp_parser.rb +52 -51
  72. data/lib/aia/config/model_spec.rb +34 -2
  73. data/lib/aia/config/validator.rb +171 -216
  74. data/lib/aia/config.rb +111 -145
  75. data/lib/aia/content_extractor.rb +155 -0
  76. data/lib/aia/cost_calculator.rb +39 -0
  77. data/lib/aia/crew.rb +164 -0
  78. data/lib/aia/debate_handler.rb +174 -0
  79. data/lib/aia/delegate_handler.rb +116 -0
  80. data/lib/aia/directive.rb +43 -26
  81. data/lib/aia/directive_processor.rb +16 -7
  82. data/lib/aia/directives/configuration_directives.rb +214 -60
  83. data/lib/aia/directives/context_directives.rb +67 -52
  84. data/lib/aia/directives/execution_directives.rb +141 -4
  85. data/lib/aia/directives/model_directives.rb +163 -141
  86. data/lib/aia/directives/trakflow_directives.rb +62 -0
  87. data/lib/aia/directives/utility_directives.rb +227 -30
  88. data/lib/aia/directives/web_and_file_directives.rb +126 -77
  89. data/lib/aia/errors.rb +15 -0
  90. data/lib/aia/fact_asserter.rb +27 -0
  91. data/lib/aia/fzf.rb +9 -31
  92. data/lib/aia/handler_context.rb +17 -0
  93. data/lib/aia/handler_protocol.rb +19 -0
  94. data/lib/aia/history_transfer.rb +55 -0
  95. data/lib/aia/input_collector.rb +3 -3
  96. data/lib/aia/layered_orchestrator.rb +471 -0
  97. data/lib/aia/logger.rb +45 -25
  98. data/lib/aia/mcp_config_normalizer.rb +35 -0
  99. data/lib/aia/mcp_connection_manager.rb +315 -0
  100. data/lib/aia/mcp_discovery.rb +44 -0
  101. data/lib/aia/mcp_grouper.rb +33 -0
  102. data/lib/aia/mcp_server_config.rb +30 -0
  103. data/lib/aia/mcp_utility.rb +60 -0
  104. data/lib/aia/mention_router.rb +217 -0
  105. data/lib/aia/model_alias_registry.rb +97 -0
  106. data/lib/aia/model_switch_handler.rb +100 -0
  107. data/lib/aia/network_builder.rb +160 -0
  108. data/lib/aia/network_memory_manager.rb +55 -0
  109. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  110. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  111. data/lib/aia/pipeline_orchestrator.rb +272 -0
  112. data/lib/aia/plugin_loader.rb +170 -0
  113. data/lib/aia/plugin_monitor.rb +211 -0
  114. data/lib/aia/prompt_decomposer.rb +159 -0
  115. data/lib/aia/prompt_handler.rb +56 -82
  116. data/lib/aia/robot_builder.rb +51 -0
  117. data/lib/aia/robot_factory.rb +338 -0
  118. data/lib/aia/robot_namer.rb +110 -0
  119. data/lib/aia/session.rb +87 -17
  120. data/lib/aia/session_tracker.rb +207 -0
  121. data/lib/aia/similarity_scorer.rb +41 -0
  122. data/lib/aia/skill_utils.rb +105 -1
  123. data/lib/aia/spawn_handler.rb +129 -0
  124. data/lib/aia/spawn_spec_parser.rb +65 -0
  125. data/lib/aia/special_mode_handler.rb +322 -0
  126. data/lib/aia/speech.rb +67 -0
  127. data/lib/aia/startup_coordinator.rb +151 -0
  128. data/lib/aia/streaming_runner.rb +172 -0
  129. data/lib/aia/system_prompt_assembler.rb +92 -0
  130. data/lib/aia/task_coordinator.rb +207 -0
  131. data/lib/aia/task_decomposer.rb +57 -0
  132. data/lib/aia/task_executor.rb +51 -0
  133. data/lib/aia/tfidf_math.rb +27 -0
  134. data/lib/aia/timing.rb +15 -0
  135. data/lib/aia/tool_filter/tfidf.rb +116 -0
  136. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  137. data/lib/aia/tool_filter.rb +83 -0
  138. data/lib/aia/tool_filter_registry.rb +30 -0
  139. data/lib/aia/tool_filter_strategy.rb +146 -0
  140. data/lib/aia/tool_introspection.rb +17 -0
  141. data/lib/aia/tool_loader.rb +216 -0
  142. data/lib/aia/tool_utility.rb +30 -0
  143. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  144. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  145. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  146. data/lib/aia/tools/task_board_tool.rb +115 -0
  147. data/lib/aia/trakflow_bridge.rb +175 -0
  148. data/lib/aia/turn_state.rb +95 -0
  149. data/lib/aia/ui_presenter.rb +182 -206
  150. data/lib/aia/utility.rb +136 -87
  151. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
  152. data/lib/aia/verification_network.rb +57 -0
  153. data/lib/aia.rb +124 -63
  154. data/mkdocs.yml +1 -0
  155. metadata +187 -58
  156. data/justfile +0 -215
  157. data/lib/aia/adapter/chat_execution.rb +0 -242
  158. data/lib/aia/adapter/error_handler.rb +0 -68
  159. data/lib/aia/adapter/gem_activator.rb +0 -57
  160. data/lib/aia/adapter/mcp_connector.rb +0 -274
  161. data/lib/aia/adapter/modality_handlers.rb +0 -167
  162. data/lib/aia/adapter/model_registry.rb +0 -81
  163. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  164. data/lib/aia/adapter/provider_configurator.rb +0 -59
  165. data/lib/aia/adapter/tool_filter.rb +0 -85
  166. data/lib/aia/adapter/tool_loader.rb +0 -90
  167. data/lib/aia/chat_processor_service.rb +0 -178
  168. data/lib/aia/prompt_pipeline.rb +0 -183
  169. data/lib/aia/ruby_llm_adapter.rb +0 -95
  170. data/lib/extensions/openstruct_merge.rb +0 -48
  171. data/lib/extensions/ruby_llm/.irbrc +0 -56
  172. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  173. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  174. data/lib/refinements/string.rb +0 -16
  175. data/main.just +0 -76
@@ -1,15 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/aia/directives/execution_directives.rb
2
4
 
3
5
  require 'shellwords'
4
6
 
5
7
  module AIA
6
8
  class ExecutionDirectives < Directive
7
- desc "Execute Ruby code"
9
+ state_setting! :concurrent, :conc, :verify, :decompose, :debate,
10
+ :delegate, :del, :spawn, :orchestrate, :orch,
11
+ :add_recruit, :add, :drop_recruit, :drop, :reskill
12
+
13
+ desc "Execute Ruby code (requires allow_ruby_eval: true in config)"
8
14
  def ruby(args, context_manager = nil)
15
+ unless AIA.config.flags.allow_ruby_eval
16
+ puts "ERROR: /ruby is disabled. Set `allow_ruby_eval: true` in " \
17
+ "~/.aia/config.yml or pass --allow-ruby-eval to enable it. " \
18
+ "Note: this executes arbitrary Ruby with full process privileges."
19
+ return ""
20
+ end
21
+
9
22
  ruby_code = args.join(' ')
23
+ $stderr.puts "WARNING: /ruby executing: #{ruby_code}"
10
24
 
11
25
  begin
12
- String(eval(ruby_code))
26
+ String(eval(ruby_code)) # rubocop:disable Security/Eval
13
27
  rescue StandardError => e
14
28
  <<~ERROR
15
29
  This ruby code failed: #{ruby_code}
@@ -17,12 +31,135 @@ module AIA
17
31
  ERROR
18
32
  end
19
33
  end
20
- alias_method :rb, :ruby
34
+ alias rb ruby
21
35
 
22
36
  desc "Use text-to-speech to speak the text"
23
37
  def say(args, context_manager = nil)
24
- system('say', *args)
38
+ audio = AIA.config.audio
39
+ voice = audio.voice
40
+ env = {}
41
+ env['SPEECH_MODEL'] = audio.speech_model if audio.speech_model
42
+ if voice && !voice.strip.empty?
43
+ system(env, 'say', '-v', voice, *args)
44
+ else
45
+ system(env, 'say', *args)
46
+ end
25
47
  ""
26
48
  end
49
+
50
+ desc "Execute next prompt with concurrent MCP server access"
51
+ def concurrent(args, context_manager = nil)
52
+ AIA.turn_state.force_concurrent_mcp = true
53
+ "Concurrent MCP mode enabled for next prompt."
54
+ end
55
+ alias conc concurrent
56
+
57
+ desc "Run next prompt through verification (two independent answers + reconciliation)"
58
+ def verify(args, context_manager = nil)
59
+ AIA.turn_state.force_verify = true
60
+ "Verification mode enabled for next prompt."
61
+ end
62
+
63
+ desc "Decompose next prompt into parallel sub-tasks"
64
+ def decompose(args, context_manager = nil)
65
+ AIA.turn_state.force_decompose = true
66
+ "Decomposition mode enabled for next prompt."
67
+ end
68
+
69
+ desc "Multi-round debate between robots in the network"
70
+ def debate(args, context_manager = nil)
71
+ AIA.turn_state.force_debate = true
72
+ "Debate mode enabled for next prompt."
73
+ end
74
+
75
+ desc "Delegate subtasks to specific robots via TrakFlow plan"
76
+ def delegate(args, context_manager = nil)
77
+ AIA.turn_state.force_delegate = true
78
+ "Delegation mode enabled for next prompt."
79
+ end
80
+ alias del delegate
81
+
82
+ desc "Spawn a specialist robot for the next prompt. " \
83
+ "Usage: /spawn | /spawn <type> | /spawn <name> <provider/model> <system prompt>"
84
+ def spawn(args, context_manager = nil)
85
+ state = AIA.turn_state
86
+ state.force_spawn = true
87
+
88
+ if args.size >= 2
89
+ spec = AIA::SpawnSpecParser.parse(args)
90
+ state.spawn_spec = spec
91
+ state.spawn_type = nil
92
+ "Spawn mode enabled: '#{spec[:name]}' (#{spawn_model_summary(spec)}) for next prompt."
93
+ else
94
+ type = args.first
95
+ state.spawn_spec = nil
96
+ state.spawn_type = type
97
+ type_msg = type ? " (#{type})" : " (auto-detect)"
98
+ "Spawn mode enabled#{type_msg} for next prompt."
99
+ end
100
+ end
101
+
102
+ desc "Recruit a robot into the crew (persists, @mention-able). Usage: " \
103
+ "/add_recruit <name> [provider/model] [skill:<id>...] [system prompt] (alias: /add)"
104
+ def add_recruit(args, context_manager = nil)
105
+ return "Usage: /add_recruit <name> [provider/model] [skill:<id> ...] [system prompt]" if args.empty?
106
+
107
+ spec = args.size >= 2 ? AIA::SpawnSpecParser.parse(args) : { name: args[0] }
108
+ robot = AIA::Crew.recruit(spec)
109
+ "Recruited '#{robot.name}' into the crew (#{recruit_summary(spec)})."
110
+ rescue AIA::CrewError => e
111
+ "Recruit failed: #{e.message}"
112
+ end
113
+ alias add add_recruit
114
+
115
+ desc "Drop a robot from the crew. Usage: /drop_recruit <name> (alias: /drop)"
116
+ def drop_recruit(args, context_manager = nil)
117
+ return "Usage: /drop_recruit <name>" if args.empty?
118
+
119
+ AIA::Crew.drop(args.first)
120
+ "Dropped '#{args.first}' from the crew."
121
+ rescue AIA::CrewError => e
122
+ "Drop failed: #{e.message}"
123
+ end
124
+ alias drop drop_recruit
125
+
126
+ desc "Reset a crew member to a clean slate and re-skill it. " \
127
+ "Usage: /reskill <name> [skill:<id>...] [system prompt]"
128
+ def reskill(args, context_manager = nil)
129
+ return "Usage: /reskill <name> [skill:<id> ...] [system prompt]" if args.empty?
130
+
131
+ skills, rest = AIA::SpawnSpecParser.extract_skills(args[1..].to_a)
132
+ prompt = rest.join(' ').strip
133
+ robot = AIA::Crew.reskill(args.first, skills: skills, system_prompt: (prompt.empty? ? nil : prompt))
134
+ suffix = skills.empty? ? '' : " with #{skills.join(', ')}"
135
+ "Reskilled '#{robot.name}'#{suffix} (clean slate)."
136
+ rescue AIA::CrewError => e
137
+ "Reskill failed: #{e.message}"
138
+ end
139
+
140
+ desc "3-tier layered orchestration: orchestrator → lead agents → specialists"
141
+ def orchestrate(args, context_manager = nil)
142
+ AIA.turn_state.force_orchestrate = true
143
+ "Orchestration mode enabled. Your next prompt is the application requirements."
144
+ end
145
+ alias orch orchestrate
146
+
147
+ private
148
+
149
+ # Human-readable model description for the /spawn confirmation message.
150
+ def spawn_model_summary(spec)
151
+ model = spec[:model]
152
+ return 'inherited model' unless model
153
+
154
+ spec[:provider] ? "#{spec[:provider]}/#{model}" : model
155
+ end
156
+
157
+ # Model plus any assigned skills, for the /add_recruit confirmation message.
158
+ def recruit_summary(spec)
159
+ parts = [spawn_model_summary(spec)]
160
+ skills = Array(spec[:skills])
161
+ parts << "skills: #{skills.join(', ')}" unless skills.empty?
162
+ parts.join('; ')
163
+ end
27
164
  end
28
165
  end
@@ -4,14 +4,14 @@ module AIA
4
4
  class ModelDirectives < Directive
5
5
  desc "List all available AI models"
6
6
  def available_models(args = nil, context_manager = nil)
7
- positive_terms, negative_terms = parse_search_terms(Array(args))
8
-
9
7
  current_models = AIA.config.models
10
8
 
11
9
  model_names = current_models.map do |m|
12
10
  m.respond_to?(:name) ? m.name : m.to_s
13
11
  end
14
12
 
13
+ positive_terms, negative_terms = parse_search_terms(Array(args))
14
+
15
15
  using_local_provider = model_names.any? { |m| m.start_with?('ollama/', 'lms/') }
16
16
 
17
17
  if using_local_provider
@@ -22,16 +22,36 @@ module AIA
22
22
 
23
23
  ""
24
24
  end
25
- alias_method :am, :available_models
26
- alias_method :available, :available_models
27
- alias_method :models, :available_models
28
- alias_method :all_models, :available_models
29
- alias_method :llms, :available_models
25
+ alias am available_models
26
+ alias available available_models
27
+ alias models available_models
28
+ alias all_models available_models
29
+ alias llms available_models
30
30
 
31
31
  desc "Compare responses from multiple models"
32
32
  def compare(args, context_manager = nil)
33
- return 'Error: No prompt provided for comparison' if args.empty?
33
+ prompt, models = parse_compare_args(args)
34
+
35
+ return report_error('Error: No prompt provided for comparison') unless prompt
36
+ return report_error('Error: No models specified. Use --models model1,model2,model3') if models.empty?
37
+
38
+ puts "\nComparing responses for: #{prompt}\n"
39
+ puts '=' * 80
40
+
41
+ models.each { |model_name| compare_with_model(model_name.strip, prompt) }
34
42
 
43
+ puts "\n" + ('=' * 80)
44
+ puts "\nComparison complete!"
45
+
46
+ ''
47
+ end
48
+ alias cmp compare
49
+
50
+ # --- helpers (no desc → not registered) ---
51
+
52
+ # Split a //compare arg list into [prompt, models]; the first non-flag
53
+ # token is the prompt, `--models a,b,c` supplies the model list.
54
+ def parse_compare_args(args)
35
55
  prompt = nil
36
56
  models = []
37
57
 
@@ -46,46 +66,33 @@ module AIA
46
66
  end
47
67
  end
48
68
 
49
- return 'Error: No prompt provided for comparison' unless prompt
50
- return 'Error: No models specified. Use --models model1,model2,model3' if models.empty?
51
-
52
- puts "\nComparing responses for: #{prompt}\n"
53
- puts '=' * 80
54
-
55
- results = {}
56
-
57
- models.each do |model_name|
58
- model_name.strip!
59
- puts "\n🤖 **#{model_name}:**"
60
- puts '-' * 40
61
-
62
- begin
63
- chat = RubyLLM.chat(model: model_name)
64
- response = chat.ask(prompt)
65
- content = response.content
69
+ [prompt, models]
70
+ end
66
71
 
67
- puts content
68
- results[model_name] = content
69
- rescue StandardError => e
70
- error_msg = "Error with #{model_name}: #{e.message}"
71
- puts error_msg
72
- results[model_name] = error_msg
73
- end
74
- end
72
+ def compare_with_model(model_name, prompt)
73
+ puts "\n🤖 **#{model_name}:**"
74
+ puts '-' * 40
75
75
 
76
- puts '\n' + '=' * 80
77
- puts "\nComparison complete!"
78
-
79
- ''
76
+ response = RubyLLM.chat(model: model_name).ask(prompt)
77
+ puts response.content
78
+ rescue StandardError => e
79
+ puts "Error with #{model_name}: #{e.message}"
80
80
  end
81
- alias_method :cmp, :compare
82
81
 
83
- # --- helpers (no desc → not registered) ---
82
+ # Log and print a directive error, then return nil so the chat loop
83
+ # skips forwarding it to the robot (an error is not conversational output).
84
+ def report_error(msg)
85
+ AIA::LoggerManager.aia_logger.error(msg)
86
+ puts msg
87
+ nil
88
+ end
84
89
 
85
- def show_local_models(current_models, positive_terms, negative_terms)
90
+ def show_local_models(current_models, positive_terms = nil, negative_terms = nil)
86
91
  require 'net/http'
87
92
  require 'json'
88
93
 
94
+ positive_terms, negative_terms = normalized_model_search_terms(positive_terms, negative_terms)
95
+
89
96
  puts "\nLocal LLM Models:"
90
97
  puts
91
98
 
@@ -101,144 +108,159 @@ module AIA
101
108
  end
102
109
  end
103
110
 
104
- def show_ollama_models(api_base, positive_terms, negative_terms)
105
- begin
106
- uri = URI("#{api_base}/api/tags")
107
- response = Net::HTTP.get_response(uri)
108
-
109
- unless response.is_a?(Net::HTTPSuccess)
110
- puts "❌ Cannot connect to Ollama at #{api_base}"
111
- return
112
- end
113
-
114
- data = JSON.parse(response.body)
115
- models = data['models'] || []
111
+ # :reek:TooManyStatements -- sequential terminal report: fetch, connection guards, filtered listing
112
+ def show_ollama_models(api_base, positive_terms = nil, negative_terms = nil)
113
+ positive_terms, negative_terms = normalized_model_search_terms(positive_terms, negative_terms)
116
114
 
117
- if models.empty?
118
- puts "No Ollama models found"
119
- return
120
- end
115
+ data = fetch_json("#{api_base}/api/tags")
116
+ if data.nil?
117
+ puts "❌ Cannot connect to Ollama at #{api_base}"
118
+ return
119
+ end
121
120
 
122
- puts "Ollama Models (#{api_base}):"
123
- puts "-" * 60
121
+ models = data['models'] || []
122
+ if models.empty?
123
+ puts "No Ollama models found"
124
+ return
125
+ end
124
126
 
125
- counter = 0
126
- models.each do |model|
127
- name = model['name']
128
- size = model['size'] ? format_bytes(model['size']) : 'unknown'
129
- modified = model['modified_at'] ? Time.parse(model['modified_at']).strftime('%Y-%m-%d') : 'unknown'
127
+ puts "Ollama Models (#{api_base}):"
128
+ puts "-" * 60
130
129
 
131
- entry = "- ollama/#{name} (size: #{size}, modified: #{modified})"
132
- entry_down = entry.downcase
130
+ entries = models.map { |model| format_ollama_entry(model) }
131
+ print_filtered_entries(entries, positive_terms, negative_terms, "Ollama model(s) available")
132
+ rescue StandardError => e
133
+ puts "❌ Error fetching Ollama models: #{e.message}"
134
+ end
133
135
 
134
- show_it = positive_terms.empty? || positive_terms.any? { |q| entry_down.include?(q) }
135
- show_it &&= negative_terms.none? { |q| entry_down.include?(q) }
136
+ def format_ollama_entry(model)
137
+ size = model['size'] ? format_bytes(model['size']) : 'unknown'
138
+ modified = model['modified_at'] ? Time.parse(model['modified_at']).strftime('%Y-%m-%d') : 'unknown'
139
+ "- ollama/#{model['name']} (size: #{size}, modified: #{modified})"
140
+ end
136
141
 
137
- if show_it
138
- puts entry
139
- counter += 1
140
- end
141
- end
142
+ # Fetch and parse JSON from a local model server; nil unless HTTP success.
143
+ def fetch_json(uri_string)
144
+ uri = URI(uri_string)
145
+ http = Net::HTTP.new(uri.host, uri.port)
146
+ http.open_timeout = 5
147
+ http.read_timeout = 5
148
+ response = http.request(Net::HTTP::Get.new(uri))
149
+ return nil unless response.is_a?(Net::HTTPSuccess)
142
150
 
143
- puts
144
- puts "#{counter} Ollama model(s) available"
145
- puts
146
- rescue StandardError => e
147
- puts "❌ Error fetching Ollama models: #{e.message}"
148
- end
151
+ JSON.parse(response.body)
149
152
  end
150
153
 
151
- def show_lms_models(api_base, positive_terms, negative_terms)
152
- begin
153
- uri = URI("#{api_base.gsub(%r{/v1/?$}, '')}/v1/models")
154
- response = Net::HTTP.get_response(uri)
154
+ # The entry is a String; Array#intersect? would raise TypeError
155
+ def entry_matches?(entry_lc, positive_terms, negative_terms)
156
+ (positive_terms.empty? || positive_terms.any? { |q| entry_lc.include?(q) }) &&
157
+ negative_terms.none? { |q| entry_lc.include?(q) }
158
+ end
155
159
 
156
- unless response.is_a?(Net::HTTPSuccess)
157
- puts "❌ Cannot connect to LM Studio at #{api_base}"
158
- return
159
- end
160
+ def print_filtered_entries(entries, positive_terms, negative_terms, summary_label)
161
+ counter = 0
162
+ entries.each do |entry|
163
+ next unless entry_matches?(entry.downcase, positive_terms, negative_terms)
160
164
 
161
- data = JSON.parse(response.body)
162
- models = data['data'] || []
165
+ puts entry
166
+ counter += 1
167
+ end
163
168
 
164
- if models.empty?
165
- puts "No LM Studio models found"
166
- return
167
- end
169
+ puts
170
+ puts "#{counter} #{summary_label}"
171
+ puts
172
+ end
168
173
 
169
- puts "LM Studio Models (#{api_base}):"
170
- puts "-" * 60
174
+ # :reek:TooManyStatements -- sequential terminal report: fetch, connection guards, filtered listing
175
+ def show_lms_models(api_base, positive_terms = nil, negative_terms = nil)
176
+ positive_terms, negative_terms = normalized_model_search_terms(positive_terms, negative_terms)
171
177
 
172
- counter = 0
173
- models.each do |model|
174
- name = model['id']
175
- entry = "- lms/#{name}"
176
- entry_down = entry.downcase
178
+ data = fetch_json("#{api_base.gsub(%r{/v1/?$}, '')}/v1/models")
179
+ if data.nil?
180
+ puts "❌ Cannot connect to LM Studio at #{api_base}"
181
+ return
182
+ end
177
183
 
178
- show_it = positive_terms.empty? || positive_terms.any? { |q| entry_down.include?(q) }
179
- show_it &&= negative_terms.none? { |q| entry_down.include?(q) }
184
+ models = data['data'] || []
185
+ if models.empty?
186
+ puts "No LM Studio models found"
187
+ return
188
+ end
180
189
 
181
- if show_it
182
- puts entry
183
- counter += 1
184
- end
185
- end
190
+ puts "LM Studio Models (#{api_base}):"
191
+ puts "-" * 60
186
192
 
187
- puts
188
- puts "#{counter} LM Studio model(s) available"
189
- puts
190
- rescue StandardError => e
191
- puts "❌ Error fetching LM Studio models: #{e.message}"
192
- end
193
+ entries = models.map { |model| "- lms/#{model['id']}" }
194
+ print_filtered_entries(entries, positive_terms, negative_terms, "LM Studio model(s) available")
195
+ rescue StandardError => e
196
+ puts "❌ Error fetching LM Studio models: #{e.message}"
193
197
  end
194
198
 
195
199
  def format_bytes(bytes)
196
- units = ['B', 'KB', 'MB', 'GB', 'TB']
200
+ units = %w[B KB MB GB TB]
197
201
  return "0 B" if bytes.zero?
198
202
 
199
203
  exp = (Math.log(bytes) / Math.log(1024)).to_i
200
204
  exp = [exp, units.length - 1].min
201
205
 
202
- "%.1f %s" % [bytes.to_f / (1024 ** exp), units[exp]]
206
+ "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
203
207
  end
204
208
 
205
- def show_rubyllm_models(positive_terms, negative_terms)
206
- modality_terms = positive_terms.select { |q| q.include?('_to_') }
207
- text_terms = positive_terms.reject { |q| q.include?('_to_') }
209
+ # :reek:TooManyStatements -- sequential terminal report: header, filtered model listing, summary
210
+ def show_rubyllm_models(positive_terms = nil, negative_terms = nil)
211
+ positive_terms, negative_terms = normalized_model_search_terms(positive_terms, negative_terms)
208
212
 
209
- header = "\nAvailable LLMs"
210
- header += " for #{positive_terms.join(' and ')}" unless positive_terms.empty?
211
- header += " excluding: #{negative_terms.join(', ')}" unless negative_terms.empty?
213
+ # expand comma-separated terms passed as a single token
214
+ positive_terms = positive_terms.first.split(',') if positive_terms.size == 1
212
215
 
213
- puts header + ':'
216
+ puts rubyllm_header(positive_terms, negative_terms)
214
217
  puts
215
218
 
216
- counter = 0
219
+ # modality terms (e.g. "text_to_text") trigger capability checks; the rest
220
+ # are plain substring filters applied to the formatted entry string
221
+ modality_terms, substring_terms = positive_terms.partition { |q| q.include?('_to_') }
217
222
 
223
+ counter = 0
218
224
  RubyLLM.models.all.each do |llm|
219
- cw = llm.context_window
220
- caps = llm.capabilities.join(',')
221
- inputs = llm.modalities.input.join(',')
222
- outputs = llm.modalities.output.join(',')
223
- mode = "#{inputs} to #{outputs}"
224
- in_1m = llm.pricing.text_tokens.standard.to_h[:input_per_million]
225
- entry = "- #{llm.id} (#{llm.provider}) in: $#{in_1m} cw: #{cw} mode: #{mode} caps: #{caps}"
226
- entry_down = entry.downcase
227
-
228
- show_it = true
229
- modality_terms.each { |q| show_it &&= llm.modalities.send("#{q}?") }
230
- text_terms.each { |q| show_it &&= entry_down.include?(q) }
231
- negative_terms.each { |q| show_it &&= !entry_down.include?(q) }
232
-
233
- if show_it
234
- counter += 1
235
- puts entry
236
- end
225
+ entry = format_rubyllm_entry(llm)
226
+ next unless rubyllm_entry_visible?(llm, entry, modality_terms, substring_terms, negative_terms)
227
+
228
+ counter += 1
229
+ puts entry
237
230
  end
238
231
 
239
- puts if counter > 0
232
+ puts if counter.positive?
240
233
  puts "#{counter} LLMs matching your query"
241
234
  puts
242
235
  end
236
+
237
+ def rubyllm_header(positive_terms, negative_terms)
238
+ header = "\nAvailable LLMs"
239
+ header += " for #{positive_terms.join(' and ')}" if positive_terms.any?
240
+ header += " (excluding: #{negative_terms.join(', ')})" if negative_terms.any?
241
+ header + ':'
242
+ end
243
+
244
+ def format_rubyllm_entry(llm)
245
+ modalities = llm.modalities
246
+ mode = "#{modalities.input.join(',')} to #{modalities.output.join(',')}"
247
+ in_1m = llm.pricing.text_tokens.standard.to_h[:input_per_million]
248
+ "- #{llm.id} (#{llm.provider}) in: $#{in_1m} cw: #{llm.context_window} mode: #{mode} caps: #{llm.capabilities.join(',')}"
249
+ end
250
+
251
+ def rubyllm_entry_visible?(llm, entry, modality_terms, substring_terms, negative_terms)
252
+ modality_terms.all? { |q| llm.modalities.send("#{q}?") } &&
253
+ substring_terms.all? { |q| entry.include?(q) } &&
254
+ negative_terms.none? { |q| entry.downcase.include?(q) }
255
+ end
256
+
257
+ def normalized_model_search_terms(positive_terms, negative_terms = nil)
258
+ return parse_search_terms(Array(positive_terms)) if negative_terms.nil?
259
+
260
+ [
261
+ Array(positive_terms).compact.map { |term| term.to_s.downcase },
262
+ Array(negative_terms).compact.map { |term| term.to_s.downcase }
263
+ ]
264
+ end
243
265
  end
244
266
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/directives/trakflow_directives.rb
4
+ #
5
+ # Chat directives for interacting with TrakFlow task tracking.
6
+
7
+ module AIA
8
+ class TrakFlowDirectives < Directive
9
+ desc "Show TrakFlow ready tasks and project summary"
10
+ def tasks(args, context_manager = nil)
11
+ bridge = TrakFlowBridge.new
12
+ return report_error("TrakFlow not available. Run 'tf init' to initialize a project.") unless bridge.available?
13
+
14
+ if args.first == "summary"
15
+ bridge.project_summary || report_status("No summary available.")
16
+ else
17
+ bridge.check_ready_tasks || report_status("No ready tasks found.")
18
+ end
19
+ end
20
+ alias tf tasks
21
+
22
+ desc "Create a TrakFlow plan from a description"
23
+ def plan(args, context_manager = nil)
24
+ bridge = TrakFlowBridge.new
25
+ return report_error("TrakFlow not available. Run 'tf init' to initialize a project.") unless bridge.available?
26
+
27
+ description = args.join(' ')
28
+ return report_error("Usage: /plan <description>") if description.empty?
29
+
30
+ bridge.create_task(description) || report_error("Failed to create plan.")
31
+ end
32
+
33
+ desc "Create a TrakFlow task"
34
+ def task(args, context_manager = nil)
35
+ bridge = TrakFlowBridge.new
36
+ return report_error("TrakFlow not available. Run 'tf init' to initialize a project.") unless bridge.available?
37
+
38
+ title = args.join(' ')
39
+ return report_error("Usage: /task <title>") if title.empty?
40
+
41
+ bridge.create_task(title) || report_error("Failed to create task.")
42
+ end
43
+
44
+ private
45
+
46
+ # Log and print a directive error, then return nil so the chat loop
47
+ # skips forwarding it to the robot (an error is not conversational output).
48
+ def report_error(msg)
49
+ AIA::LoggerManager.aia_logger.error(msg)
50
+ puts msg
51
+ nil
52
+ end
53
+
54
+ # Same as report_error, but for a non-error empty-result status
55
+ # (e.g. "no ready tasks found") rather than a failure.
56
+ def report_status(msg)
57
+ AIA::LoggerManager.aia_logger.info(msg)
58
+ puts msg
59
+ nil
60
+ end
61
+ end
62
+ end