brainiac 0.0.15 → 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: 677c29e7ddcf5d9718eda91175193e9ffa448be65fac5e698982009327ee3576
4
- data.tar.gz: ec7c044b3661f5fdca2f462a305a53ab47c6cfd76299462fa5e3df0f83f28a3a
3
+ metadata.gz: 123ee48772d662b7c3326b4c90584f3aa85cfeecf0c481f564cb3f79efb4355a
4
+ data.tar.gz: d81be4471f1665ca426d9c02cc278f04e0fc67a272b6f9c5a82ce7ba05ef00ae
5
5
  SHA512:
6
- metadata.gz: e4f3c2af97935afe5666ae722629f2a6a15af09fef73cc8ef969a11eccbf9c315cf50831e293a381cc6d2f4b2a383bce03a3fb673c4f9294ab17cb11f3656270
7
- data.tar.gz: f70117f73d55be6914223bca9cbe331e0e9ecdece704349b71eb0c40f35fdcc8da8d2fdcf9cf297b7d9fa7b91b45350a09de12271332fcac7a28cda0d984c91c
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.15)
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.15)
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
@@ -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"]
@@ -38,17 +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
43
- - If the message starts with or addresses someone else by name (e.g., "Adam, ...", "Hey Sarah", "Effie, ...") no (it's directed at that person, not {{AGENT_NAME}})
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
44
48
  - If the message is humans talking to each other and {{AGENT_NAME}} is not being addressed → no
45
- - If the message is a simple acknowledgment (like "thanks", "ok", "got it") directed at {{AGENT_NAME}}'s previous work → no
46
- - 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
47
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
48
53
  - If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
49
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
+
50
57
  Respond with ONLY "yes" or "no" — nothing else.
51
58
 
59
+ Context:
60
+ {{CONTEXT}}
61
+
52
62
  Latest message:
53
63
  {{MESSAGE}}
54
64
  PROMPT
@@ -69,33 +79,114 @@ PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
69
79
  {{MESSAGE}}
70
80
  PROMPT
71
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
+
72
103
  # Load intent config from brainiac.json, merging with defaults.
73
104
  def intent_config
74
105
  raw = BRAINIAC_CONFIG["intent"] || {}
75
106
  INTENT_CONFIG_DEFAULTS.merge(raw)
76
107
  end
77
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
+
78
139
  # Check whether a message requires an agent to respond.
79
140
  #
80
141
  # @param message [String] The message text to classify
81
142
  # @param agent_name [String] The agent being addressed
82
143
  # @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
144
+ # @param context [String, nil] Recent conversation history for flow detection
83
145
  # @return [Boolean] true if the agent should respond, false if it can be skipped
84
- def check_intent(message, agent_name:, channel: "conversation")
146
+ def check_intent(message, agent_name:, channel: "conversation", context: nil)
85
147
  config = intent_config
86
148
  return true unless config["enabled"]
87
149
  return true if message.nil? || message.strip.empty?
88
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
+
89
171
  prompt = INTENT_PROMPT_TEMPLATE
90
172
  .gsub("{{AGENT_NAME}}", agent_name)
91
173
  .gsub("{{CHANNEL}}", channel)
174
+ .gsub("{{AGENT_ROSTER}}", roster_block)
175
+ .gsub("{{CONTEXT}}", context_block)
92
176
  .gsub("{{MESSAGE}}", message.strip)
93
177
 
94
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?
95
180
  response = query_local_llm(prompt, config)
96
181
  result = positive_intent?(response)
97
182
  LOG.info "[Intent] Result: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
98
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
99
190
  rescue StandardError => e
100
191
  LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
101
192
  true
@@ -123,12 +214,17 @@ def query_local_llm(prompt, config)
123
214
  request.body = JSON.generate(payload)
124
215
 
125
216
  response = http.request(request)
126
- 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
127
223
 
128
224
  body = JSON.parse(response.body)
129
225
  body["response"] || ""
130
226
  rescue Errno::ECONNREFUSED
131
- raise "Ollama not running at #{config["endpoint"]}"
227
+ raise "Ollama not running at #{config["endpoint"]}. Start it with: ollama serve"
132
228
  rescue Net::OpenTimeout, Net::ReadTimeout
133
229
  raise "Ollama timed out after #{config["timeout"]}s"
134
230
  end
@@ -179,3 +275,102 @@ def pending_work_detected?(response)
179
275
  cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
180
276
  cleaned == "yes"
181
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.15"
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.15
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis