brainiac 0.0.19 → 0.0.20

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: 5f6394c1ba27e48f098ae99072714cfd9cc7ca1c2412b89f62380f5d72e59a7f
4
- data.tar.gz: ad4cdf5660afddfcadd96456da668452ba5d663f98eafedf2bcbefb3ba328aaa
3
+ metadata.gz: 619fac25bcbc560564a318e78421b8c826811abe7ba92e74de0b3b7104d8a712
4
+ data.tar.gz: acc359990201eaabfb5d755c17102867bce0a5a1bc68407c09ef3c4fcca94e7c
5
5
  SHA512:
6
- metadata.gz: 7af17dcf8b530ff9e34bb1338dcbb57006586e0e6fd142ef21268dfb042530db56b5e427a7b7537509c13dfd4fb548674d2509d67c0109e8d3ca240fcca7656e
7
- data.tar.gz: 4bd7ad87450d7c404f55b48c6e556709c0bd7fd4fc45b3f7f4ed50b97f20553ac704327540d099136a57d6928466cf9fc1750d96ac1bb128a641accc32829378
6
+ metadata.gz: e44c64a94143b7eb8ff47312d8b889baf91d654a5341c2a5436329693e6ed700432d6f37428c4e44cddc0369d1c1453a6df69929ac577017b6fe1900c9fa7fe5
7
+ data.tar.gz: 64500e610c6cc4393a640d069c442c1bff5913ca36e61cc0d0f1323891419760aef1b2e9f7717a66326d731d4b2df139e17aa47ff90b5b0f05764b3402176a71
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.19)
4
+ brainiac (0.0.20)
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.19)
87
+ brainiac (0.0.20)
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
@@ -14,8 +14,8 @@
14
14
  #
15
15
  # "intent": {
16
16
  # "enabled": true,
17
- # "endpoint": "http://localhost:11434/api/generate",
18
- # "model": "gemma3:4b",
17
+ # "endpoint": "http://localhost:11434/api/chat",
18
+ # "model": "qwen3:1.7b",
19
19
  # "timeout": 10,
20
20
  # "temperature": 0.1
21
21
  # }
@@ -29,40 +29,24 @@
29
29
 
30
30
  INTENT_CONFIG_DEFAULTS = {
31
31
  "enabled" => false,
32
- "endpoint" => "http://localhost:11434/api/generate",
33
- "model" => "gemma3:4b",
32
+ "endpoint" => "http://localhost:11434/api/chat",
33
+ "model" => "qwen2.5:3b",
34
34
  "timeout" => 10,
35
- "temperature" => 0.1
35
+ "temperature" => 0.0
36
36
  }.freeze
37
37
 
38
38
  INTENT_PROMPT_TEMPLATE = <<~PROMPT
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.
39
+ Agents in this chat: {{AGENT_ROSTER}}
40
+ {{LAST_RESPONDER}}
40
41
 
41
- {{AGENT_ROSTER}}
42
+ Human's message: "{{MESSAGE}}"
42
43
 
43
- Rules:
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
48
- - If the message is humans talking to each other and {{AGENT_NAME}} is not being addressed → 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
53
- - If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
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
-
57
- Respond with ONLY "yes" or "no" — nothing else.
58
-
59
- Context:
60
- {{CONTEXT}}
61
-
62
- Latest message:
63
- {{MESSAGE}}
44
+ Is this message directed at {{AGENT_NAME}}? Answer yes or no.
64
45
  PROMPT
65
46
 
47
+ # Words/phrases that indicate pure acknowledgment — no action needed from the agent.
48
+ ACKNOWLEDGMENT_PATTERN = /\A\s*(thanks|thank you|thx|ty|ok|okay|k|got it|sounds good|cool|nice|👍|🙏|✅)\s*[.!]?\s*\z/i
49
+
66
50
  PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
67
51
  You are analyzing a message posted by an AI agent named {{AGENT_NAME}}. Your job: determine if this message indicates the agent intends to do more work that hasn't been completed yet.
68
52
 
@@ -138,6 +122,13 @@ end
138
122
 
139
123
  # Check whether a message requires an agent to respond.
140
124
  #
125
+ # Uses a layered approach:
126
+ # 1. Deterministic pre-checks (fast, no LLM):
127
+ # - If message names another agent → skip
128
+ # - If this agent was last to speak AND message isn't pure acknowledgment → respond
129
+ # - If a different agent was last to speak AND message doesn't name this agent → skip
130
+ # 2. LLM fallback (only for ambiguous cases — no agent spoke, or mixed signals)
131
+ #
141
132
  # @param message [String] The message text to classify
142
133
  # @param agent_name [String] The agent being addressed
143
134
  # @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
@@ -156,61 +147,97 @@ def check_intent(message, agent_name:, channel: "conversation", context: nil)
156
147
  return false
157
148
  end
158
149
 
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
150
+ # Deterministic conversational-flow check: use last-responder detection to
151
+ # resolve the common case without hitting the LLM at all.
152
+ last_responder = detect_last_responder_name(context)
153
+
154
+ if last_responder
155
+ is_acknowledgment = message.strip.match?(ACKNOWLEDGMENT_PATTERN)
156
+
157
+ if last_responder.downcase == agent_name.downcase
158
+ # This agent was the last to speak — human is continuing with us
159
+ if is_acknowledgment
160
+ LOG.info "[Intent] Deterministic skip for #{agent_name} — pure acknowledgment, no action needed"
161
+ return false
162
+ end
163
+ LOG.info "[Intent] Deterministic respond for #{agent_name} — was last to speak, human continues"
164
+ return true
165
+ else
166
+ # A different agent was the last to speak — this message is probably for them
167
+ # UNLESS it explicitly names this agent (already checked above via intent_names_other_agent? for OTHER agents,
168
+ # but also check if it mentions THIS agent's name directly)
169
+ if message_mentions_agent?(message, agent_name)
170
+ LOG.info "[Intent] Deterministic respond for #{agent_name} — named in message despite #{last_responder} speaking last"
171
+ return true
172
+ end
173
+ LOG.info "[Intent] Deterministic skip for #{agent_name} — #{last_responder} was last to speak"
174
+ return false
175
+ end
176
+ end
177
+
178
+ # No clear last responder detected — fall through to LLM for classification.
179
+ classify_intent_via_llm(message, agent_name:, channel:, config:)
180
+ rescue OllamaModelNotFoundError => e
181
+ LOG.error "[Intent] #{e.message}"
182
+ LOG.error "[Intent] Disabling intent classification until model is installed."
183
+ BRAINIAC_CONFIG["intent"] ||= {}
184
+ BRAINIAC_CONFIG["intent"]["enabled"] = false
185
+ true
186
+ rescue StandardError => e
187
+ LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
188
+ true
189
+ end
167
190
 
168
- # Build agent roster for the prompt so the model knows which names are agents
191
+ # Fall through to LLM for intent classification when deterministic checks are inconclusive.
192
+ # This handles cases like: no agents have spoken yet, or context is unavailable.
193
+ def classify_intent_via_llm(message, agent_name:, channel:, config:)
169
194
  roster_block = build_intent_agent_roster(agent_name)
195
+ last_responder_block = "No agent has spoken recently."
170
196
 
171
197
  prompt = INTENT_PROMPT_TEMPLATE
172
198
  .gsub("{{AGENT_NAME}}", agent_name)
173
- .gsub("{{CHANNEL}}", channel)
174
199
  .gsub("{{AGENT_ROSTER}}", roster_block)
175
- .gsub("{{CONTEXT}}", context_block)
200
+ .gsub("{{LAST_RESPONDER}}", last_responder_block)
176
201
  .gsub("{{MESSAGE}}", message.strip)
177
202
 
178
203
  LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
179
204
  LOG.debug "[Intent] Full prompt:\n#{prompt}" if LOG.debug?
180
- response = query_local_llm(prompt, config)
205
+ response = query_local_llm(prompt, config, system: "Answer yes or no only.")
181
206
  result = positive_intent?(response)
182
207
  LOG.info "[Intent] Result for #{agent_name}: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
183
208
  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
190
- rescue StandardError => e
191
- LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
192
- true
193
209
  end
194
210
 
195
211
  # Query the local LLM via Ollama's HTTP API.
196
212
  #
197
213
  # @param prompt [String] The classification prompt
198
214
  # @param config [Hash] Intent configuration
215
+ # @param system [String, nil] Optional system message for output format control
199
216
  # @return [String] Raw response text from the model
200
- def query_local_llm(prompt, config)
201
- uri = URI(config["endpoint"])
217
+ def query_local_llm(prompt, config, system: nil)
218
+ endpoint = config["endpoint"]
219
+ # Use /api/chat with think:false to disable thinking mode on models like Qwen3.
220
+ # The /api/generate endpoint always uses thinking tokens, which generates hundreds
221
+ # of hidden tokens for a simple yes/no answer (2.5s+ vs 0.2s with think:false).
222
+ chat_uri = URI(endpoint.sub(%r{/api/generate\z}, "/api/chat"))
223
+
224
+ messages = []
225
+ messages << { role: "system", content: system } if system
226
+ messages << { role: "user", content: prompt }
227
+
202
228
  payload = {
203
229
  model: config["model"],
204
- prompt: prompt,
230
+ messages: messages,
205
231
  stream: false,
206
- options: { temperature: config["temperature"] }
232
+ think: false,
233
+ options: { temperature: config["temperature"], num_predict: 5 }
207
234
  }
208
235
 
209
- http = Net::HTTP.new(uri.host, uri.port)
236
+ http = Net::HTTP.new(chat_uri.host, chat_uri.port)
210
237
  http.open_timeout = config["timeout"]
211
238
  http.read_timeout = config["timeout"]
212
239
 
213
- request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
240
+ request = Net::HTTP::Post.new(chat_uri.path, "Content-Type" => "application/json")
214
241
  request.body = JSON.generate(payload)
215
242
 
216
243
  response = http.request(request)
@@ -222,9 +249,9 @@ def query_local_llm(prompt, config)
222
249
  end
223
250
 
224
251
  body = JSON.parse(response.body)
225
- body["response"] || ""
252
+ body.dig("message", "content") || ""
226
253
  rescue Errno::ECONNREFUSED
227
- raise "Ollama not running at #{config["endpoint"]}. Start it with: ollama serve"
254
+ raise "Ollama not running at #{endpoint}. Start it with: ollama serve"
228
255
  rescue Net::OpenTimeout, Net::ReadTimeout
229
256
  raise "Ollama timed out after #{config["timeout"]}s"
230
257
  end
@@ -360,18 +387,94 @@ def directly_addressed_to?(msg, name)
360
387
  end
361
388
 
362
389
  # Build a compact agent roster string for the intent prompt.
363
- # Helps the LLM identify which names in the conversation are agents.
390
+ # Returns a comma-separated list of agent display names.
364
391
  #
365
- # @param agent_name [String] The current agent (highlighted)
366
- # @return [String] Formatted roster for prompt injection
392
+ # @param agent_name [String] The current agent (unused but kept for interface consistency)
393
+ # @return [String] Comma-separated agent names
367
394
  def build_intent_agent_roster(agent_name)
368
- return "" unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
395
+ return agent_name unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
369
396
 
370
397
  names = AGENT_REGISTRY.map do |key, entry|
371
398
  entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
372
399
  end.uniq
373
400
 
374
- return "" if names.size <= 1
401
+ return agent_name if names.empty?
402
+
403
+ names.join(", ")
404
+ end
405
+
406
+ # Detect the last agent to respond in the conversation context.
407
+ # Returns a prompt-friendly string telling the model who was last to respond.
408
+ #
409
+ # The context is formatted as "username: message\n..." lines.
410
+ # We scan backwards to find the most recent line authored by a known agent.
411
+ #
412
+ # @param context [String, nil] Conversation history
413
+ # @param agent_name [String] The current agent being evaluated
414
+ # @return [String] Prompt block describing the last responder
415
+ def detect_last_responder(context, _agent_name = nil)
416
+ return "No agent has spoken yet." if context.nil? || context.strip.empty?
417
+ return "No agent has spoken yet." unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
418
+
419
+ # Build a lookup of agent display names (lowercase) to their proper display name
420
+ agent_names = {}
421
+ AGENT_REGISTRY.each do |key, entry|
422
+ display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
423
+ agent_names[display.downcase] = display
424
+ end
425
+
426
+ # Scan context lines in reverse to find the last agent message
427
+ lines = context.strip.lines.reverse
428
+ lines.each do |line|
429
+ # Lines are formatted as "username: message content"
430
+ match = line.match(/\A(\S+?):\s/)
431
+ next unless match
432
+
433
+ username = match[1].downcase
434
+ next unless agent_names.key?(username)
435
+
436
+ responder = agent_names[username]
437
+ return "#{responder} was the last to speak. The human is continuing the conversation with #{responder}."
438
+ end
439
+
440
+ "No agent has spoken yet."
441
+ end
442
+
443
+ # Detect the last agent to respond — returns just the display name (or nil).
444
+ # Used for deterministic conversational-flow checks without LLM involvement.
445
+ #
446
+ # @param context [String, nil] Conversation history
447
+ # @return [String, nil] Display name of last agent to speak, or nil
448
+ def detect_last_responder_name(context)
449
+ return nil if context.nil? || context.strip.empty?
450
+ return nil unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
451
+
452
+ agent_names = {}
453
+ AGENT_REGISTRY.each do |key, entry|
454
+ display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
455
+ agent_names[display.downcase] = display
456
+ end
457
+
458
+ context.strip.lines.reverse_each do |line|
459
+ match = line.match(/\A(\S+?):\s/)
460
+ next unless match
461
+
462
+ username = match[1].downcase
463
+ return agent_names[username] if agent_names.key?(username)
464
+ end
465
+
466
+ nil
467
+ end
468
+
469
+ # Check if a message contains a direct reference to a specific agent's name.
470
+ # More permissive than intent_names_other_agent? — catches any occurrence of the name.
471
+ #
472
+ # @param message [String] The message text
473
+ # @param agent_name [String] The agent name to look for
474
+ # @return [Boolean] true if the agent is mentioned by name
475
+ def message_mentions_agent?(message, agent_name)
476
+ return false if message.nil? || agent_name.nil?
375
477
 
376
- "Known agents in this system: #{names.join(", ")}. You are routing for #{agent_name}."
478
+ escaped = Regexp.escape(agent_name)
479
+ message.match?(/\b#{escaped}\b/i)
377
480
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.19"
5
+ VERSION = "0.0.20"
6
6
  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.19
4
+ version: 0.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis