brainiac 0.0.14 → 0.0.16

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: '04080c41c24921ed739b0d28ad15ce61b8eb5b5c12935d0cb0b1d72c6b0deeab'
4
- data.tar.gz: bbb1dceb84ecaba924ada9189e8b63320828c035da4a6ff466ba8b47c4c30708
3
+ metadata.gz: 123ee48772d662b7c3326b4c90584f3aa85cfeecf0c481f564cb3f79efb4355a
4
+ data.tar.gz: d81be4471f1665ca426d9c02cc278f04e0fc67a272b6f9c5a82ce7ba05ef00ae
5
5
  SHA512:
6
- metadata.gz: '0699a2277c78aa2c33e57b6ee4394ac7be8d27bc483a9f3866dd27e644e40fbdea4b85168b3452c6cbbd4fce42da6e282a9609e93541802b2c751f9845c55ea2'
7
- data.tar.gz: adcd7eab6269a33b278499e3b873b072f488f7b06b18378507fba456f883b1c525b7dd8d8ce82b13e6464ab5184007e0887dfa9a82a69910f8f8d0164660ff9b
6
+ metadata.gz: 76b8df7eefa48bb122f7d75ba0730f0b8ac328ad284f45e137297dd650b7f6781bcc6ad14bd1a0a4f1a31a9c24392bd183a4b5b236f086a19bc504257f2cf180
7
+ data.tar.gz: 7f089331037cc192f3cb284471b497e688d0831a38088f968cf4c5eabd175539a8b8f8e1986ad38739ee7a1efdb1beb81d3777d9f63e99c131fd74d47629dcd8
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.14)
4
+ brainiac (0.0.16)
5
5
  puma (~> 7.2)
6
6
  rackup (~> 2.3)
7
7
  sinatra (~> 4.1)
@@ -84,7 +84,7 @@ DEPENDENCIES
84
84
  CHECKSUMS
85
85
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
86
86
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
87
- brainiac (0.0.14)
87
+ brainiac (0.0.16)
88
88
  json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
89
89
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
90
90
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
data/bin/brainiac CHANGED
@@ -120,6 +120,65 @@ rescue StandardError
120
120
  []
121
121
  end
122
122
 
123
+ # Notify the running server to reload configs (triggers agent lifecycle hooks).
124
+ # Silently does nothing if the server isn't running.
125
+ def notify_server_reload
126
+ config = load_config
127
+ server_url = config["server_url"] || "http://localhost:4567"
128
+ uri = URI("#{server_url}/api/reload")
129
+ http = Net::HTTP.new(uri.host, uri.port)
130
+ http.open_timeout = 2
131
+ http.read_timeout = 5
132
+ request = Net::HTTP::Post.new(uri.path)
133
+ http.request(request)
134
+ rescue StandardError
135
+ nil
136
+ end
137
+
138
+ # Run CLI-time plugin hooks for agent lifecycle events.
139
+ # Loads each installed plugin's CLI module and calls the hook method if defined.
140
+ # This runs synchronously and can prompt for interactive input.
141
+ def notify_plugins_agent_created(agent_key, entry)
142
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
143
+ return unless File.exist?(plugins_file)
144
+
145
+ plugins_config = JSON.parse(File.read(plugins_file))
146
+ (plugins_config["plugins"] || []).each do |pentry|
147
+ pname = pentry.is_a?(Hash) ? pentry["name"] : pentry.to_s
148
+ try_require_metadata(pname, pentry)
149
+ try_require_plugin_cli(pname, pentry)
150
+ mod = resolve_plugin_module_for(pname)
151
+ next unless mod.respond_to?(:on_agent_created)
152
+
153
+ mod.on_agent_created(agent_key, entry)
154
+ rescue StandardError => e
155
+ puts " ⚠ Plugin '#{pname}' hook failed: #{e.message}"
156
+ end
157
+ rescue JSON::ParserError
158
+ nil
159
+ end
160
+
161
+ # Run CLI-time plugin hooks for agent removal.
162
+ def notify_plugins_agent_removed(agent_key, display_name)
163
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
164
+ return unless File.exist?(plugins_file)
165
+
166
+ plugins_config = JSON.parse(File.read(plugins_file))
167
+ (plugins_config["plugins"] || []).each do |pentry|
168
+ pname = pentry.is_a?(Hash) ? pentry["name"] : pentry.to_s
169
+ try_require_metadata(pname, pentry)
170
+ try_require_plugin_cli(pname, pentry)
171
+ mod = resolve_plugin_module_for(pname)
172
+ next unless mod.respond_to?(:on_agent_removed)
173
+
174
+ mod.on_agent_removed(agent_key, display_name)
175
+ rescue StandardError => e
176
+ puts " ⚠ Plugin '#{pname}' hook failed: #{e.message}"
177
+ end
178
+ rescue JSON::ParserError
179
+ nil
180
+ end
181
+
123
182
  def wait_for_agents_to_finish
124
183
  sessions = fetch_active_sessions
125
184
  return true if sessions.empty?
@@ -1873,6 +1932,12 @@ when "agent"
1873
1932
  brain_init(name)
1874
1933
  end
1875
1934
 
1935
+ # Let plugins prompt for additional config (e.g. Discord user ID)
1936
+ notify_plugins_agent_created(agent_key, entry)
1937
+
1938
+ # Notify running server to reload and trigger :agent_added hook
1939
+ notify_server_reload
1940
+
1876
1941
  when "remove", "delete", "rm"
1877
1942
  name = ARGV.shift
1878
1943
  unless name
@@ -1894,6 +1959,12 @@ when "agent"
1894
1959
  puts "✓ Removed agent '#{display}' (#{agent_key}) from registry"
1895
1960
  puts " Note: Persona files at ~/.brainiac/brain/persona/#{agent_key}/ were not deleted."
1896
1961
 
1962
+ # Let plugins clean up their config
1963
+ notify_plugins_agent_removed(agent_key, display)
1964
+
1965
+ # Notify running server to reload and trigger :agent_removed hook
1966
+ notify_server_reload
1967
+
1897
1968
  when nil
1898
1969
  puts <<~HELP
1899
1970
  Usage: brainiac agent <name> <command> [args]
@@ -50,8 +50,26 @@ AGENT_REGISTRY = load_agent_registry
50
50
  def reload_agent_registry!(force: false)
51
51
  return unless file_changed?(AGENT_REGISTRY_FILE, force: force)
52
52
 
53
+ old_keys = AGENT_REGISTRY.keys.to_set
53
54
  AGENT_REGISTRY.replace(load_agent_registry)
55
+ new_keys = AGENT_REGISTRY.keys.to_set
54
56
  LOG.info "Reloaded agent registry: #{AGENT_REGISTRY.keys.join(", ")}"
57
+
58
+ # Emit lifecycle hooks for added/removed agents
59
+ added = new_keys - old_keys
60
+ removed = old_keys - new_keys
61
+
62
+ added.each do |key|
63
+ entry = AGENT_REGISTRY[key]
64
+ display_name = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
65
+ LOG.info "Agent added: #{display_name} (#{key})"
66
+ Brainiac.emit(:agent_added, agent_key: key, display_name: display_name, entry: entry)
67
+ end
68
+
69
+ removed.each do |key|
70
+ LOG.info "Agent removed: #{key}"
71
+ Brainiac.emit(:agent_removed, agent_key: key)
72
+ end
55
73
  end
56
74
 
57
75
  # Get the env hash for an agent. Returns {} if none configured.
@@ -402,11 +402,11 @@ end
402
402
  # Check if intent detection says to skip dispatching the agent.
403
403
  # Returns true if the message should be skipped, false otherwise.
404
404
  # Only runs if a raw message is provided, an agent is named, and intent is enabled.
405
- def intent_skip?(message, agent_name:, source: nil, channel: nil)
405
+ def intent_skip?(message, agent_name:, source: nil, channel: nil, context: nil)
406
406
  return false unless message && agent_name && intent_config["enabled"]
407
407
 
408
408
  intent_channel = channel || source&.to_s || "conversation"
409
- unless check_intent(message, agent_name: agent_name, channel: intent_channel)
409
+ unless check_intent(message, agent_name: agent_name, channel: intent_channel, context: context)
410
410
  LOG.info "[Intent] Skipping dispatch for #{agent_name} — message classified as not requiring response"
411
411
  return true
412
412
  end
@@ -416,9 +416,9 @@ end
416
416
 
417
417
  def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
418
418
  source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
419
- message: nil, channel: nil)
419
+ message: nil, channel: nil, context: nil)
420
420
  # Intent gate: if a raw message is provided, check whether the agent should respond.
421
- return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel)
421
+ return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel, context: context)
422
422
 
423
423
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
424
424
  chdir ||= resolved["repo_path"]
@@ -9,6 +9,8 @@
9
9
  #
10
10
  # Events:
11
11
  # :agent_completed — After an agent session finishes (success or failure)
12
+ # :agent_added — When a new agent appears in the registry (after reload)
13
+ # :agent_removed — When an agent is removed from the registry (after reload)
12
14
  # :pr_merged — After a GitHub PR is merged
13
15
  # :pr_opened — After a GitHub PR is opened
14
16
  # :pr_reviewed — After a PR review is submitted
@@ -38,15 +38,27 @@ INTENT_CONFIG_DEFAULTS = {
38
38
  INTENT_PROMPT_TEMPLATE = <<~PROMPT
39
39
  You are a message router for a {{CHANNEL}}. An AI agent named {{AGENT_NAME}} is participating in this conversation. Your job: determine if the latest message requires {{AGENT_NAME}} to take action or respond.
40
40
 
41
+ {{AGENT_ROSTER}}
42
+
41
43
  Rules:
42
- - If the message is giving {{AGENT_NAME}} instructions, asking {{AGENT_NAME}} a question, or continuing a conversation WITH {{AGENT_NAME}} → yes
44
+ - If the message addresses {{AGENT_NAME}} by name, gives {{AGENT_NAME}} instructions, or asks {{AGENT_NAME}} a question → yes
45
+ - If the message continues a conversation directed at {{AGENT_NAME}} (e.g. {{AGENT_NAME}} was the last to respond and the human follows up) → yes
46
+ - If the message starts with or addresses a DIFFERENT person/agent (not {{AGENT_NAME}}) → no
47
+ - If the message contains another agent's name (e.g. "Effie, ...", "Another one Effie", "Hey Galen") and does NOT contain "{{AGENT_NAME}}" → no
43
48
  - If the message is humans talking to each other and {{AGENT_NAME}} is not being addressed → no
44
- - If the message is a simple acknowledgment (like "thanks", "ok", "got it") directed at {{AGENT_NAME}}'s previous work → no
45
- - If the message is asking a question to another person or agent → no
49
+ - If the message is a simple acknowledgment ("thanks", "ok", "got it") directed at {{AGENT_NAME}}'s previous work → no
50
+ - If the message is asking a question to another person or agent (not {{AGENT_NAME}}) → no
51
+ - If the message is responding to or commenting on what someone OTHER than {{AGENT_NAME}} just said → no
52
+ - If the conversation context shows a DIFFERENT agent was the last to respond, and the human's follow-up does not mention {{AGENT_NAME}} by name → no
46
53
  - If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
47
54
 
55
+ Critical: "addresses someone else" means someone whose name is NOT {{AGENT_NAME}}. If the message says "{{AGENT_NAME}}, ..." that IS addressed to {{AGENT_NAME}} → yes.
56
+
48
57
  Respond with ONLY "yes" or "no" — nothing else.
49
58
 
59
+ Context:
60
+ {{CONTEXT}}
61
+
50
62
  Latest message:
51
63
  {{MESSAGE}}
52
64
  PROMPT
@@ -67,33 +79,114 @@ PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
67
79
  {{MESSAGE}}
68
80
  PROMPT
69
81
 
82
+ # Custom error for missing Ollama models — provides actionable install instructions.
83
+ class OllamaModelNotFoundError < RuntimeError
84
+ attr_reader :model_name, :endpoint
85
+
86
+ def initialize(model_name, endpoint)
87
+ @model_name = model_name
88
+ @endpoint = endpoint
89
+ super(<<~MSG.strip)
90
+ Ollama model '#{model_name}' is not installed.
91
+
92
+ To install it, run:
93
+ ollama pull #{model_name}
94
+
95
+ Or change the model in ~/.brainiac/brainiac.json → intent.model
96
+
97
+ Available models can be listed with:
98
+ ollama list
99
+ MSG
100
+ end
101
+ end
102
+
70
103
  # Load intent config from brainiac.json, merging with defaults.
71
104
  def intent_config
72
105
  raw = BRAINIAC_CONFIG["intent"] || {}
73
106
  INTENT_CONFIG_DEFAULTS.merge(raw)
74
107
  end
75
108
 
109
+ # Validate that the configured intent model is available in Ollama.
110
+ # Called at server startup — aborts with a helpful error if the model is missing.
111
+ #
112
+ # @param config [Hash] Intent configuration
113
+ # @raise [OllamaModelNotFoundError] if the model isn't installed
114
+ def validate_intent_model!(config)
115
+ base_uri = URI(config["endpoint"])
116
+ show_uri = URI("#{base_uri.scheme}://#{base_uri.host}:#{base_uri.port}/api/show")
117
+
118
+ http = Net::HTTP.new(show_uri.host, show_uri.port)
119
+ http.open_timeout = 5
120
+ http.read_timeout = 5
121
+
122
+ request = Net::HTTP::Post.new(show_uri.path, "Content-Type" => "application/json")
123
+ request.body = JSON.generate({ model: config["model"] })
124
+
125
+ response = http.request(request)
126
+
127
+ if response.code == "404" || (!response.is_a?(Net::HTTPSuccess) && response.body&.include?("not found"))
128
+ raise OllamaModelNotFoundError.new(config["model"], config["endpoint"])
129
+ end
130
+
131
+ true
132
+ rescue Errno::ECONNREFUSED
133
+ raise "Ollama is not running at #{config["endpoint"]}. Start it with: ollama serve"
134
+ rescue Net::OpenTimeout, Net::ReadTimeout
135
+ LOG.warn "[Intent] Could not validate model (Ollama timed out) — will check at first use"
136
+ true
137
+ end
138
+
76
139
  # Check whether a message requires an agent to respond.
77
140
  #
78
141
  # @param message [String] The message text to classify
79
142
  # @param agent_name [String] The agent being addressed
80
143
  # @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
144
+ # @param context [String, nil] Recent conversation history for flow detection
81
145
  # @return [Boolean] true if the agent should respond, false if it can be skipped
82
- def check_intent(message, agent_name:, channel: "conversation")
146
+ def check_intent(message, agent_name:, channel: "conversation", context: nil)
83
147
  config = intent_config
84
148
  return true unless config["enabled"]
85
149
  return true if message.nil? || message.strip.empty?
86
150
 
151
+ # Deterministic pre-check: if the message explicitly names another agent
152
+ # and does NOT name this agent, skip without consulting the LLM.
153
+ # This handles the common case of "Effie, tell me another" when checking Galen.
154
+ if intent_names_other_agent?(message, agent_name)
155
+ LOG.info "[Intent] Deterministic skip for #{agent_name} — message addresses another agent"
156
+ return false
157
+ end
158
+
159
+ context_block = if context && !context.strip.empty?
160
+ # Keep only last 5 messages for the small local model — enough to determine
161
+ # conversational flow without overwhelming the context window.
162
+ recent = context.strip.lines.last(5).join
163
+ "Recent conversation (most recent last):\n#{recent}\n\n"
164
+ else
165
+ ""
166
+ end
167
+
168
+ # Build agent roster for the prompt so the model knows which names are agents
169
+ roster_block = build_intent_agent_roster(agent_name)
170
+
87
171
  prompt = INTENT_PROMPT_TEMPLATE
88
172
  .gsub("{{AGENT_NAME}}", agent_name)
89
173
  .gsub("{{CHANNEL}}", channel)
174
+ .gsub("{{AGENT_ROSTER}}", roster_block)
175
+ .gsub("{{CONTEXT}}", context_block)
90
176
  .gsub("{{MESSAGE}}", message.strip)
91
177
 
92
178
  LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
179
+ LOG.debug "[Intent] Full prompt:\n#{prompt}" if LOG.debug?
93
180
  response = query_local_llm(prompt, config)
94
181
  result = positive_intent?(response)
95
182
  LOG.info "[Intent] Result: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
96
183
  result
184
+ rescue OllamaModelNotFoundError => e
185
+ LOG.error "[Intent] #{e.message}"
186
+ LOG.error "[Intent] Disabling intent classification until model is installed."
187
+ BRAINIAC_CONFIG["intent"] ||= {}
188
+ BRAINIAC_CONFIG["intent"]["enabled"] = false
189
+ true
97
190
  rescue StandardError => e
98
191
  LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
99
192
  true
@@ -121,12 +214,17 @@ def query_local_llm(prompt, config)
121
214
  request.body = JSON.generate(payload)
122
215
 
123
216
  response = http.request(request)
124
- raise "Ollama returned #{response.code}: #{response.body&.slice(0, 200)}" unless response.is_a?(Net::HTTPSuccess)
217
+
218
+ unless response.is_a?(Net::HTTPSuccess)
219
+ raise OllamaModelNotFoundError.new(config["model"], config["endpoint"]) if response.code == "404" && response.body&.include?("not found")
220
+
221
+ raise "Ollama returned #{response.code}: #{response.body&.slice(0, 200)}"
222
+ end
125
223
 
126
224
  body = JSON.parse(response.body)
127
225
  body["response"] || ""
128
226
  rescue Errno::ECONNREFUSED
129
- raise "Ollama not running at #{config["endpoint"]}"
227
+ raise "Ollama not running at #{config["endpoint"]}. Start it with: ollama serve"
130
228
  rescue Net::OpenTimeout, Net::ReadTimeout
131
229
  raise "Ollama timed out after #{config["timeout"]}s"
132
230
  end
@@ -177,3 +275,102 @@ def pending_work_detected?(response)
177
275
  cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
178
276
  cleaned == "yes"
179
277
  end
278
+
279
+ # Deterministic check: does the message explicitly name another known agent
280
+ # without mentioning this agent? If so, the message is clearly directed elsewhere.
281
+ #
282
+ # Only triggers when:
283
+ # 1. The message contains another agent's display name (case-insensitive word boundary match)
284
+ # 2. The message does NOT contain this agent's name
285
+ #
286
+ # This avoids the LLM entirely for obvious cases like "Effie, tell me another"
287
+ # when evaluating whether Galen should respond.
288
+ #
289
+ # Determines if the message is DIRECTLY ADDRESSED to another agent (not just mentioning them).
290
+ # Only triggers on vocative patterns — where the name is used to call someone, not refer to them.
291
+ #
292
+ # Patterns that indicate direct address (skip this agent):
293
+ # - "Effie, tell me a joke" (name + comma at start)
294
+ # - "hey Effie one more" (greeting + name)
295
+ # - "Another one Effie" (name at end, imperative)
296
+ # - "Effie tell me more" (name at start, imperative — no comma but first word)
297
+ #
298
+ # Patterns that are merely MENTIONING (do NOT skip):
299
+ # - "What do you think about Effie's answer?" (talking about Effie)
300
+ # - "Was Effie nice to you?" (asking about Effie)
301
+ # - "I liked what Effie said" (referencing Effie)
302
+ #
303
+ # @param message [String] The message text
304
+ # @param agent_name [String] The agent being evaluated
305
+ # @return [Boolean] true if another agent is directly addressed and this one isn't
306
+ def intent_names_other_agent?(message, agent_name)
307
+ return false unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
308
+
309
+ msg_lower = message.downcase.strip
310
+ agent_lower = agent_name.downcase
311
+
312
+ # If this agent IS named in the message, never deterministic skip
313
+ return false if msg_lower.match?(/\b#{Regexp.escape(agent_lower)}\b/)
314
+
315
+ # Check if any OTHER agent is directly addressed (not merely mentioned)
316
+ AGENT_REGISTRY.each do |key, entry|
317
+ display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
318
+ other_lower = display.downcase
319
+ next if other_lower == agent_lower
320
+ next unless msg_lower.match?(/\b#{Regexp.escape(other_lower)}\b/)
321
+
322
+ # Agent name is present — now determine if it's direct address or mere mention
323
+ return true if directly_addressed_to?(msg_lower, other_lower)
324
+ end
325
+
326
+ false
327
+ end
328
+
329
+ # Heuristics for detecting direct address (vocative use of a name).
330
+ # Returns true when the name is used to CALL someone, not talk ABOUT them.
331
+ #
332
+ # @param msg [String] Lowercased, stripped message
333
+ # @param name [String] Lowercased agent name to check
334
+ # @return [Boolean]
335
+ def directly_addressed_to?(msg, name)
336
+ escaped = Regexp.escape(name)
337
+
338
+ # Pattern 1: Name at the very start (with or without comma/colon)
339
+ # "Effie, tell me a joke" / "Effie tell me more" / "Effie: do the thing"
340
+ return true if msg.match?(/\A#{escaped}[\s,:!]/i)
341
+
342
+ # Pattern 2: Name at the very end (imperative directed at them)
343
+ # "Another one Effie" / "one more Effie"
344
+ # BUT NOT when preceded by a preposition — "agree with Effie?" is about Effie, not to her
345
+ if msg.match?(/\s#{escaped}\s*[.!?]?\z/i)
346
+ prepositions = /\b(?:with|about|from|to|for|of|by|like|than|as|at|on|against|toward|towards)\s+#{escaped}\s*[.!?]?\z/i
347
+ return true unless msg.match?(prepositions)
348
+ end
349
+
350
+ # Pattern 3: Greeting/vocative prefix + name
351
+ # "hey Effie" / "yo Effie" / "ok Effie" / "thanks Effie"
352
+ return true if msg.match?(/\b(?:hey|hi|yo|ok|okay|thanks|thank you|please)\s+#{escaped}\b/i)
353
+
354
+ # Pattern 4: Name followed by comma mid-sentence (vocative comma)
355
+ # "so Effie, what do you think?"
356
+ return true if msg.match?(/\b#{escaped}\s*,/i)
357
+
358
+ false
359
+ end
360
+
361
+ # Build a compact agent roster string for the intent prompt.
362
+ # Helps the LLM identify which names in the conversation are agents.
363
+ #
364
+ # @param agent_name [String] The current agent (highlighted)
365
+ # @return [String] Formatted roster for prompt injection
366
+ def build_intent_agent_roster(agent_name)
367
+ return "" unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
368
+
369
+ names = AGENT_REGISTRY.map do |key, entry|
370
+ entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
371
+ end.uniq
372
+
373
+ return "" if names.size <= 1
374
+
375
+ "Known agents in this system: #{names.join(", ")}. You are routing for #{agent_name}."
376
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.14"
5
+ VERSION = "0.0.16"
6
6
  end
data/receiver.rb CHANGED
@@ -101,6 +101,15 @@ end
101
101
  LOG.info "[Brainiac] Starting v#{BRAINIAC_VERSION} on port #{settings.port} (#{settings.environment})"
102
102
  if intent_config["enabled"]
103
103
  LOG.info "[Intent] Enabled — model: #{intent_config["model"]}, endpoint: #{intent_config["endpoint"]}"
104
+ begin
105
+ validate_intent_model!(intent_config)
106
+ LOG.info "[Intent] Model '#{intent_config["model"]}' verified ✓"
107
+ rescue OllamaModelNotFoundError => e
108
+ LOG.fatal "[Intent] FATAL: #{e.message}"
109
+ abort "\n#{e.message}\n"
110
+ rescue RuntimeError => e
111
+ LOG.warn "[Intent] Startup validation skipped: #{e.message}"
112
+ end
104
113
  else
105
114
  LOG.info "[Intent] Disabled (enable in brainiac.json → intent.enabled: true)"
106
115
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis