brainiac 0.0.18 → 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: 3a5e88ff667021341d3ff4bd4da63212b3918b8c210d276c3c9d0f00ff97e121
4
- data.tar.gz: ba02b2ab6f6e0e519ef00887da6f53fb6be2dd234fb8db30bc5bc7c9896433b5
3
+ metadata.gz: 619fac25bcbc560564a318e78421b8c826811abe7ba92e74de0b3b7104d8a712
4
+ data.tar.gz: acc359990201eaabfb5d755c17102867bce0a5a1bc68407c09ef3c4fcca94e7c
5
5
  SHA512:
6
- metadata.gz: 013f765fe110ad69fa3522ba45d46f3200f8f34bad4da62cc3df78b751246d52056b226005ecfce2cda99fbfb77e82230c1e6b679a5525d49b471770776c3249
7
- data.tar.gz: b206d36e38e5fd284a8e276109e1a5656986f954f87e393cefa7ef77a0b1f0226b96fc50c80027e00ac66e60dc64107560fbe64d77d3a84198adb673cd1ca50c
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.18)
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.18)
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
data/bin/brainiac CHANGED
@@ -2392,7 +2392,10 @@ when "update", "upgrade"
2392
2392
  puts "✓ #{gem_name} is already at the latest version (#{current_version})"
2393
2393
  end
2394
2394
  else
2395
- # Update brainiac itself: brainiac update
2395
+ # Update brainiac itself + all installed plugins: brainiac update
2396
+ any_updated = false
2397
+
2398
+ # --- Update brainiac core ---
2396
2399
  current_version = BRAINIAC_VERSION
2397
2400
 
2398
2401
  puts "Updating brainiac gem..."
@@ -2404,22 +2407,73 @@ when "update", "upgrade"
2404
2407
  exit 1
2405
2408
  end
2406
2409
 
2407
- # Parse installed version from gem install output
2408
2410
  new_version = output[/Successfully installed brainiac-(\S+)/, 1]
2409
2411
 
2410
2412
  if new_version
2411
2413
  puts "✓ Updated brainiac: #{current_version} → #{new_version}"
2412
-
2413
- if find_server_pid
2414
- puts " Restarting server to apply..."
2415
- wait_for_agents_to_finish
2416
- stop_server
2417
- sleep 2
2418
- start_server(daemon: true)
2419
- end
2414
+ any_updated = true
2420
2415
  else
2421
2416
  puts "✓ brainiac is already at the latest version (#{current_version})"
2422
2417
  end
2418
+
2419
+ # --- Update all installed plugins ---
2420
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
2421
+ plugins_config = if File.exist?(plugins_file)
2422
+ JSON.parse(File.read(plugins_file))
2423
+ else
2424
+ { "plugins" => [] }
2425
+ end
2426
+
2427
+ plugins = (plugins_config["plugins"] || []).reject do |p|
2428
+ entry = p.is_a?(Hash) ? p : { "name" => p.to_s }
2429
+ # Skip local-path plugins — they're updated via git
2430
+ entry["path"]
2431
+ end
2432
+
2433
+ if plugins.any?
2434
+ puts ""
2435
+ puts "Checking #{plugins.size} installed plugin(s) for updates..."
2436
+ puts ""
2437
+
2438
+ plugins.each do |p|
2439
+ entry = p.is_a?(Hash) ? p : { "name" => p.to_s }
2440
+ plugin_name = entry["name"]
2441
+ gem_name = entry["gem"] || "brainiac-#{plugin_name}"
2442
+
2443
+ plugin_current = begin
2444
+ spec = Gem::Specification.find_by_name(gem_name)
2445
+ spec.version.to_s
2446
+ rescue Gem::MissingSpecError
2447
+ nil
2448
+ end
2449
+
2450
+ plugin_output, plugin_status = Open3.capture2e("gem install #{gem_name}")
2451
+
2452
+ unless plugin_status.success?
2453
+ puts " ✗ #{gem_name}: failed to update"
2454
+ next
2455
+ end
2456
+
2457
+ plugin_new = plugin_output[/Successfully installed #{Regexp.escape(gem_name)}-(\S+)/, 1]
2458
+
2459
+ if plugin_new
2460
+ puts " ✓ #{gem_name}: #{plugin_current || "unknown"} → #{plugin_new}"
2461
+ any_updated = true
2462
+ else
2463
+ puts " ✓ #{gem_name}: already at latest (#{plugin_current})"
2464
+ end
2465
+ end
2466
+ end
2467
+
2468
+ # --- Restart server if anything was updated ---
2469
+ if any_updated && find_server_pid
2470
+ puts ""
2471
+ puts "Restarting server to apply updates..."
2472
+ wait_for_agents_to_finish
2473
+ stop_server
2474
+ sleep 2
2475
+ start_server(daemon: true)
2476
+ end
2423
2477
  end
2424
2478
 
2425
2479
  when "install"
@@ -3150,8 +3204,8 @@ when "help", "--help", "-h", nil
3150
3204
  brainiac projects default <key> Set the default project
3151
3205
  brainiac show <key> Show project configuration
3152
3206
  brainiac plugin new <name> Generate a new plugin gem skeleton
3153
- brainiac update Update brainiac to the latest version
3154
- brainiac update <plugin> Update an installed plugin to the latest version
3207
+ brainiac update Update brainiac and all installed plugins
3208
+ brainiac update <plugin> Update a specific installed plugin
3155
3209
  brainiac install <plugin> Install a Brainiac plugin (gem-based handler)
3156
3210
  brainiac uninstall <plugin> Remove an installed plugin
3157
3211
  brainiac plugins List installed plugins
@@ -3172,7 +3226,7 @@ when "help", "--help", "-h", nil
3172
3226
  plugin new <name> Generate a new plugin gem skeleton
3173
3227
  install <name> Install a plugin (gem install brainiac-<name>)
3174
3228
  uninstall <name> Remove a plugin
3175
- update [name] Update brainiac or a specific plugin
3229
+ update [name] Update brainiac + all plugins, or a specific plugin
3176
3230
  plugins List installed plugins
3177
3231
 
3178
3232
  Card Map Commands:
@@ -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.18"
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.18
4
+ version: 0.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis