brainiac 0.0.13 → 0.0.14

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: 900af8a7e0c45dd36e8d3dd0eeebe9f72d5b31da6f97f55d847542aade0e8bec
4
- data.tar.gz: e9c685222d55fb1820062f04b695c6c7233531b2198e4edd12a79c8013cbe3d8
3
+ metadata.gz: '04080c41c24921ed739b0d28ad15ce61b8eb5b5c12935d0cb0b1d72c6b0deeab'
4
+ data.tar.gz: bbb1dceb84ecaba924ada9189e8b63320828c035da4a6ff466ba8b47c4c30708
5
5
  SHA512:
6
- metadata.gz: 8955a6fb660975dfac37f1772494e8d6d0aec2d09f4dd3a94715dd2357df111824557a2d475adeae2ff11d68ae6fedb1556c5bb3b8c397b46fafe75a5993c5c1
7
- data.tar.gz: 620f1e0d10401ba5892d019d491c2fd2eda8d34098f9ab1729e9ba00bf434a6e0f1bb185b12b8ab329aad4d2745f0beb4fed7cffd5151bdb3d0c7cab090021c6
6
+ metadata.gz: '0699a2277c78aa2c33e57b6ee4394ac7be8d27bc483a9f3866dd27e644e40fbdea4b85168b3452c6cbbd4fce42da6e282a9609e93541802b2c751f9845c55ea2'
7
+ data.tar.gz: adcd7eab6269a33b278499e3b873b072f488f7b06b18378507fba456f883b1c525b7dd8d8ce82b13e6464ab5184007e0887dfa9a82a69910f8f8d0164660ff9b
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.13)
4
+ brainiac (0.0.14)
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.13)
87
+ brainiac (0.0.14)
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
@@ -655,7 +655,8 @@ def list_projects
655
655
  default_marker = config["default"] ? " (default)" : ""
656
656
  puts " #{key}#{default_marker}"
657
657
  puts " Path: #{config["repo_path"]}"
658
- puts " Tags: #{config["tags"] || config["project_tags"].join(", ")}"
658
+ tags = config["tags"] || config["project_tags"]&.join(", ")
659
+ puts " Tags: #{tags}" if tags
659
660
  puts " GitHub: #{config["github_repo"]}" if config["github_repo"]
660
661
  puts " Agent: #{config["agent_cli"]} (model: #{config["agent_model"]})"
661
662
  puts
@@ -2295,54 +2296,44 @@ when "update", "upgrade"
2295
2296
  end
2296
2297
 
2297
2298
  puts "Updating #{gem_name}..."
2298
- system("gem install #{gem_name}")
2299
- unless $CHILD_STATUS.success?
2299
+ output, status = Open3.capture2e("gem install #{gem_name}")
2300
+ puts output
2301
+ unless status.success?
2300
2302
  puts ""
2301
2303
  puts "Failed to update #{gem_name}."
2302
2304
  exit 1
2303
2305
  end
2304
2306
 
2305
- # Show version change
2306
- Gem::Specification.reset
2307
- new_version = begin
2308
- spec = Gem::Specification.find_by_name(gem_name)
2309
- spec.version.to_s
2310
- rescue Gem::MissingSpecError
2311
- "unknown"
2312
- end
2307
+ # Parse installed version from gem install output
2308
+ new_version = output[/Successfully installed #{Regexp.escape(gem_name)}-(\S+)/, 1]
2313
2309
 
2314
- if current_version && current_version == new_version
2315
- puts "✓ #{gem_name} is already at the latest version (#{new_version})"
2316
- else
2310
+ if new_version
2317
2311
  puts "✓ Updated #{gem_name}: #{current_version || "new"} → #{new_version}"
2318
2312
  puts " Restart the server to activate: brainiac restart"
2313
+ else
2314
+ puts "✓ #{gem_name} is already at the latest version (#{current_version})"
2319
2315
  end
2320
2316
  else
2321
2317
  # Update brainiac itself: brainiac update
2322
2318
  current_version = BRAINIAC_VERSION
2323
2319
 
2324
2320
  puts "Updating brainiac gem..."
2325
- system("gem install brainiac")
2326
- unless $CHILD_STATUS.success?
2321
+ output, status = Open3.capture2e("gem install brainiac")
2322
+ puts output
2323
+ unless status.success?
2327
2324
  puts ""
2328
2325
  puts "Failed to update brainiac."
2329
2326
  exit 1
2330
2327
  end
2331
2328
 
2332
- # Show version change
2333
- Gem::Specification.reset
2334
- new_version = begin
2335
- spec = Gem::Specification.find_by_name("brainiac")
2336
- spec.version.to_s
2337
- rescue Gem::MissingSpecError
2338
- "unknown"
2339
- end
2329
+ # Parse installed version from gem install output
2330
+ new_version = output[/Successfully installed brainiac-(\S+)/, 1]
2340
2331
 
2341
- if current_version == new_version
2342
- puts "✓ brainiac is already at the latest version (#{new_version})"
2343
- else
2332
+ if new_version
2344
2333
  puts "✓ Updated brainiac: #{current_version} → #{new_version}"
2345
2334
  puts " Restart the server to apply: brainiac restart"
2335
+ else
2336
+ puts "✓ brainiac is already at the latest version (#{current_version})"
2346
2337
  end
2347
2338
  end
2348
2339
 
@@ -38,15 +38,48 @@ end
38
38
  BRAINIAC_CONFIG = load_brainiac_config
39
39
 
40
40
  # --- Default agent name ---
41
- # Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → error.
42
- AI_AGENT_NAME = ENV.fetch("AI_AGENT_NAME", nil) || BRAINIAC_CONFIG["default_agent"] || begin
41
+ # Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → first agent in agents.json → error.
42
+ def resolve_default_agent
43
+ # 1. Env var
44
+ name = ENV.fetch("AI_AGENT_NAME", nil)
45
+ return name if name
46
+
47
+ # 2. brainiac.json
48
+ name = BRAINIAC_CONFIG["default_agent"]
49
+ return name if name
50
+
51
+ # 3. First agent in agents.json
52
+ if File.exist?(AGENT_REGISTRY_FILE)
53
+ agents = begin
54
+ JSON.parse(File.read(AGENT_REGISTRY_FILE))
55
+ rescue StandardError
56
+ {}
57
+ end
58
+ first_agent = agents.values.first
59
+ if first_agent
60
+ agent_name = first_agent["fizzy_name"] || first_agent["display_name"] || agents.keys.first.capitalize
61
+ warn <<~MSG
62
+ [Brainiac] No default agent configured — using "#{agent_name}" (first agent in agents.json).
63
+ To set explicitly, either:
64
+ export AI_AGENT_NAME="#{agent_name}"
65
+ Or add to ~/.brainiac/brainiac.json:
66
+ { "default_agent": "#{agent_name}" }
67
+ MSG
68
+ return agent_name
69
+ end
70
+ end
71
+
72
+ # 4. Nothing found
43
73
  raise <<~MSG
44
- No default agent configured. Set one of:
74
+ No default agent configured and no agents found in #{AGENT_REGISTRY_FILE}.
75
+ Set one of:
45
76
  1. Environment variable: export AI_AGENT_NAME="YourAgent"
46
77
  2. In ~/.brainiac/brainiac.json: { "default_agent": "YourAgent" }
47
78
  MSG
48
79
  end
49
80
 
81
+ AI_AGENT_NAME = resolve_default_agent
82
+
50
83
  LOG_LEVEL = ENV.fetch("LOG_LEVEL", "info").downcase
51
84
  LOG = Logger.new($stdout)
52
85
  LOG.level = case LOG_LEVEL
@@ -399,8 +399,27 @@ def resolve_resume(resume, resolved, chdir)
399
399
  false
400
400
  end
401
401
 
402
+ # Check if intent detection says to skip dispatching the agent.
403
+ # Returns true if the message should be skipped, false otherwise.
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)
406
+ return false unless message && agent_name && intent_config["enabled"]
407
+
408
+ intent_channel = channel || source&.to_s || "conversation"
409
+ unless check_intent(message, agent_name: agent_name, channel: intent_channel)
410
+ LOG.info "[Intent] Skipping dispatch for #{agent_name} — message classified as not requiring response"
411
+ return true
412
+ end
413
+
414
+ false
415
+ end
416
+
402
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,
403
- source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false)
418
+ source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
419
+ message: nil, channel: nil)
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)
422
+
404
423
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
405
424
  chdir ||= resolved["repo_path"]
406
425
  model ||= resolved["agent_model"]
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Local LLM-based intent detection.
4
+ #
5
+ # Uses a lightweight local model (via Ollama) to classify whether an incoming
6
+ # message requires an agent to respond. This saves expensive API tokens by
7
+ # filtering out messages that are just humans chatting, reactions, or noise.
8
+ #
9
+ # Also detects when an agent's own response implies pending work — if the agent
10
+ # said "I'll do X" but the session ended without completing the work, this
11
+ # signals that the agent should be re-dispatched.
12
+ #
13
+ # Configuration (in ~/.brainiac/brainiac.json):
14
+ #
15
+ # "intent": {
16
+ # "enabled": true,
17
+ # "endpoint": "http://localhost:11434/api/generate",
18
+ # "model": "gemma3:4b",
19
+ # "timeout": 10,
20
+ # "temperature": 0.1
21
+ # }
22
+ #
23
+ # Plugins use this via:
24
+ # requires_response = check_intent(message, agent_name: "Galen", context: "Discord thread")
25
+ # return unless requires_response
26
+ #
27
+ # Returns true (agent should respond), false (skip), or true on any failure
28
+ # (fail-open so we never accidentally ignore a real request).
29
+
30
+ INTENT_CONFIG_DEFAULTS = {
31
+ "enabled" => false,
32
+ "endpoint" => "http://localhost:11434/api/generate",
33
+ "model" => "gemma3:4b",
34
+ "timeout" => 10,
35
+ "temperature" => 0.1
36
+ }.freeze
37
+
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.
40
+
41
+ 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 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
46
+ - If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
47
+
48
+ Respond with ONLY "yes" or "no" — nothing else.
49
+
50
+ Latest message:
51
+ {{MESSAGE}}
52
+ PROMPT
53
+
54
+ PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
55
+ 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.
56
+
57
+ Rules:
58
+ - If the agent says it will do something, is about to start work, or promises a follow-up action → yes
59
+ - If the agent is reporting completed work, summarizing what was done, or delivering a final answer → no
60
+ - If the agent is asking a clarifying question and waiting for a human response → no
61
+ - If the agent says things like "give me a sec", "I'll implement this", "let me do that", "working on it" → yes
62
+ - If uncertain, lean toward no (avoid unnecessary re-dispatches)
63
+
64
+ Respond with ONLY "yes" or "no" — nothing else.
65
+
66
+ Agent's message:
67
+ {{MESSAGE}}
68
+ PROMPT
69
+
70
+ # Load intent config from brainiac.json, merging with defaults.
71
+ def intent_config
72
+ raw = BRAINIAC_CONFIG["intent"] || {}
73
+ INTENT_CONFIG_DEFAULTS.merge(raw)
74
+ end
75
+
76
+ # Check whether a message requires an agent to respond.
77
+ #
78
+ # @param message [String] The message text to classify
79
+ # @param agent_name [String] The agent being addressed
80
+ # @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
81
+ # @return [Boolean] true if the agent should respond, false if it can be skipped
82
+ def check_intent(message, agent_name:, channel: "conversation")
83
+ config = intent_config
84
+ return true unless config["enabled"]
85
+ return true if message.nil? || message.strip.empty?
86
+
87
+ prompt = INTENT_PROMPT_TEMPLATE
88
+ .gsub("{{AGENT_NAME}}", agent_name)
89
+ .gsub("{{CHANNEL}}", channel)
90
+ .gsub("{{MESSAGE}}", message.strip)
91
+
92
+ LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
93
+ response = query_local_llm(prompt, config)
94
+ result = positive_intent?(response)
95
+ LOG.info "[Intent] Result: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
96
+ result
97
+ rescue StandardError => e
98
+ LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
99
+ true
100
+ end
101
+
102
+ # Query the local LLM via Ollama's HTTP API.
103
+ #
104
+ # @param prompt [String] The classification prompt
105
+ # @param config [Hash] Intent configuration
106
+ # @return [String] Raw response text from the model
107
+ def query_local_llm(prompt, config)
108
+ uri = URI(config["endpoint"])
109
+ payload = {
110
+ model: config["model"],
111
+ prompt: prompt,
112
+ stream: false,
113
+ options: { temperature: config["temperature"] }
114
+ }
115
+
116
+ http = Net::HTTP.new(uri.host, uri.port)
117
+ http.open_timeout = config["timeout"]
118
+ http.read_timeout = config["timeout"]
119
+
120
+ request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
121
+ request.body = JSON.generate(payload)
122
+
123
+ response = http.request(request)
124
+ raise "Ollama returned #{response.code}: #{response.body&.slice(0, 200)}" unless response.is_a?(Net::HTTPSuccess)
125
+
126
+ body = JSON.parse(response.body)
127
+ body["response"] || ""
128
+ rescue Errno::ECONNREFUSED
129
+ raise "Ollama not running at #{config["endpoint"]}"
130
+ rescue Net::OpenTimeout, Net::ReadTimeout
131
+ raise "Ollama timed out after #{config["timeout"]}s"
132
+ end
133
+
134
+ # Parse the LLM's yes/no response into a boolean.
135
+ # Fail-open: anything that isn't clearly "no" returns true.
136
+ #
137
+ # @param response [String] Raw response from the model
138
+ # @return [Boolean]
139
+ def positive_intent?(response)
140
+ cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
141
+ cleaned != "no"
142
+ end
143
+
144
+ # Check whether an agent's own message implies pending work that wasn't completed.
145
+ # Used to detect when an agent posted "I'll do X" but the session ended without
146
+ # actually doing the work — signaling the agent should be re-dispatched.
147
+ #
148
+ # Fail-closed: returns false on any error (don't re-dispatch on classification failure).
149
+ #
150
+ # @param message [String] The agent's posted message
151
+ # @param agent_name [String] The agent who posted it
152
+ # @return [Boolean] true if the message implies uncommitted work
153
+ def check_pending_work(message, agent_name:)
154
+ config = intent_config
155
+ return false unless config["enabled"]
156
+ return false if message.nil? || message.strip.empty?
157
+
158
+ prompt = PENDING_WORK_PROMPT_TEMPLATE
159
+ .gsub("{{AGENT_NAME}}", agent_name)
160
+ .gsub("{{MESSAGE}}", message.strip)
161
+
162
+ response = query_local_llm(prompt, config)
163
+ result = pending_work_detected?(response)
164
+ LOG.info "[Intent] Pending work detected in #{agent_name}'s message" if result
165
+ result
166
+ rescue StandardError => e
167
+ LOG.warn "[Intent] Pending work check failed (fail-closed): #{e.message}"
168
+ false
169
+ end
170
+
171
+ # Parse the LLM's yes/no response for pending work detection.
172
+ # Fail-closed: anything that isn't clearly "yes" returns false.
173
+ #
174
+ # @param response [String] Raw response from the model
175
+ # @return [Boolean]
176
+ def pending_work_detected?(response)
177
+ cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
178
+ cleaned == "yes"
179
+ end
@@ -354,6 +354,67 @@ post "/api/cron/reload" do
354
354
  { status: "reloaded", jobs: CRON_JOBS.size }.to_json
355
355
  end
356
356
 
357
+ # --- Intent ---
358
+
359
+ get "/api/intent/config" do
360
+ content_type :json
361
+ config = intent_config
362
+ status = if config["enabled"]
363
+ begin
364
+ uri = URI(config["endpoint"].sub("/api/generate", "/api/tags"))
365
+ http = Net::HTTP.new(uri.host, uri.port)
366
+ http.open_timeout = 3
367
+ http.read_timeout = 3
368
+ response = http.get(uri.path)
369
+ if response.is_a?(Net::HTTPSuccess)
370
+ models = JSON.parse(response.body).fetch("models", []).map { |m| m["name"] }
371
+ model_available = models.any? { |m| m.start_with?(config["model"].split(":").first) }
372
+ model_available ? "ok" : "model_not_found"
373
+ else
374
+ "ollama_error"
375
+ end
376
+ rescue Errno::ECONNREFUSED
377
+ "ollama_not_running"
378
+ rescue StandardError => e
379
+ "error: #{e.message}"
380
+ end
381
+ else
382
+ "disabled"
383
+ end
384
+ config.merge("status" => status).to_json
385
+ end
386
+
387
+ post "/api/intent/check" do
388
+ content_type :json
389
+ request.body.rewind
390
+ payload = JSON.parse(request.body.read)
391
+
392
+ message = payload["message"]
393
+ agent = payload["agent"] || AI_AGENT_NAME
394
+ channel = payload["channel"] || "conversation"
395
+
396
+ halt 400, { error: "Missing 'message' field" }.to_json unless message && !message.empty?
397
+
398
+ result = check_intent(message, agent_name: agent, channel: channel)
399
+ { should_respond: result, agent: agent, channel: channel }.to_json
400
+ end
401
+
402
+ post "/api/intent/pending-work" do
403
+ content_type :json
404
+ request.body.rewind
405
+ payload = JSON.parse(request.body.read)
406
+
407
+ message = payload["message"]
408
+ agent = payload["agent"] || AI_AGENT_NAME
409
+
410
+ halt 400, { error: "Missing 'message' field" }.to_json unless message && !message.empty?
411
+
412
+ result = check_pending_work(message, agent_name: agent)
413
+ { pending_work: result, agent: agent }.to_json
414
+ end
415
+
416
+ # --- Cron Logs ---
417
+
357
418
  get "/api/cron/logs" do
358
419
  content_type :json
359
420
  job_id = params["id"]
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.13"
5
+ VERSION = "0.0.14"
6
6
  end
data/lib/brainiac.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "brainiac/skills"
7
7
  require_relative "brainiac/sessions"
8
8
  require_relative "brainiac/prompts"
9
9
  require_relative "brainiac/helpers"
10
+ require_relative "brainiac/intent"
10
11
  require_relative "brainiac/cron"
11
12
  require_relative "brainiac/plugins"
12
13
 
data/receiver.rb CHANGED
@@ -24,6 +24,7 @@ require_relative "lib/brainiac/helpers"
24
24
  require_relative "lib/brainiac/notifications"
25
25
  require_relative "lib/brainiac/cron"
26
26
  require_relative "lib/brainiac/restart"
27
+ require_relative "lib/brainiac/intent"
27
28
  require_relative "lib/brainiac/plugins"
28
29
  require_relative "lib/brainiac/handlers/shared/git"
29
30
  require_relative "lib/brainiac/handlers/shared/inline_tags"
@@ -98,6 +99,11 @@ configure do
98
99
  end
99
100
 
100
101
  LOG.info "[Brainiac] Starting v#{BRAINIAC_VERSION} on port #{settings.port} (#{settings.environment})"
102
+ if intent_config["enabled"]
103
+ LOG.info "[Intent] Enabled — model: #{intent_config["model"]}, endpoint: #{intent_config["endpoint"]}"
104
+ else
105
+ LOG.info "[Intent] Disabled (enable in brainiac.json → intent.enabled: true)"
106
+ end
101
107
 
102
108
  # --- Dashboard authentication ---
103
109
 
@@ -1,3 +1,10 @@
1
1
  {
2
- "default_agent": "Sherlock"
2
+ "default_agent": "Sherlock",
3
+ "intent": {
4
+ "enabled": false,
5
+ "endpoint": "http://localhost:11434/api/generate",
6
+ "model": "gemma3:4b",
7
+ "timeout": 10,
8
+ "temperature": 0.1
9
+ }
3
10
  }
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.13
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -133,6 +133,7 @@ files:
133
133
  - lib/brainiac/handlers/shared/inline_tags.rb
134
134
  - lib/brainiac/helpers.rb
135
135
  - lib/brainiac/hooks.rb
136
+ - lib/brainiac/intent.rb
136
137
  - lib/brainiac/notifications.rb
137
138
  - lib/brainiac/plugins.rb
138
139
  - lib/brainiac/prompts.rb