brainiac 0.0.13 → 0.0.15

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: 677c29e7ddcf5d9718eda91175193e9ffa448be65fac5e698982009327ee3576
4
+ data.tar.gz: ec7c044b3661f5fdca2f462a305a53ab47c6cfd76299462fa5e3df0f83f28a3a
5
5
  SHA512:
6
- metadata.gz: 8955a6fb660975dfac37f1772494e8d6d0aec2d09f4dd3a94715dd2357df111824557a2d475adeae2ff11d68ae6fedb1556c5bb3b8c397b46fafe75a5993c5c1
7
- data.tar.gz: 620f1e0d10401ba5892d019d491c2fd2eda8d34098f9ab1729e9ba00bf434a6e0f1bb185b12b8ab329aad4d2745f0beb4fed7cffd5151bdb3d0c7cab090021c6
6
+ metadata.gz: e4f3c2af97935afe5666ae722629f2a6a15af09fef73cc8ef969a11eccbf9c315cf50831e293a381cc6d2f4b2a383bce03a3fb673c4f9294ab17cb11f3656270
7
+ data.tar.gz: f70117f73d55be6914223bca9cbe331e0e9ecdece704349b71eb0c40f35fdcc8da8d2fdcf9cf297b7d9fa7b91b45350a09de12271332fcac7a28cda0d984c91c
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.15)
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.15)
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?
@@ -655,7 +714,8 @@ def list_projects
655
714
  default_marker = config["default"] ? " (default)" : ""
656
715
  puts " #{key}#{default_marker}"
657
716
  puts " Path: #{config["repo_path"]}"
658
- puts " Tags: #{config["tags"] || config["project_tags"].join(", ")}"
717
+ tags = config["tags"] || config["project_tags"]&.join(", ")
718
+ puts " Tags: #{tags}" if tags
659
719
  puts " GitHub: #{config["github_repo"]}" if config["github_repo"]
660
720
  puts " Agent: #{config["agent_cli"]} (model: #{config["agent_model"]})"
661
721
  puts
@@ -1872,6 +1932,12 @@ when "agent"
1872
1932
  brain_init(name)
1873
1933
  end
1874
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
+
1875
1941
  when "remove", "delete", "rm"
1876
1942
  name = ARGV.shift
1877
1943
  unless name
@@ -1893,6 +1959,12 @@ when "agent"
1893
1959
  puts "✓ Removed agent '#{display}' (#{agent_key}) from registry"
1894
1960
  puts " Note: Persona files at ~/.brainiac/brain/persona/#{agent_key}/ were not deleted."
1895
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
+
1896
1968
  when nil
1897
1969
  puts <<~HELP
1898
1970
  Usage: brainiac agent <name> <command> [args]
@@ -2295,54 +2367,44 @@ when "update", "upgrade"
2295
2367
  end
2296
2368
 
2297
2369
  puts "Updating #{gem_name}..."
2298
- system("gem install #{gem_name}")
2299
- unless $CHILD_STATUS.success?
2370
+ output, status = Open3.capture2e("gem install #{gem_name}")
2371
+ puts output
2372
+ unless status.success?
2300
2373
  puts ""
2301
2374
  puts "Failed to update #{gem_name}."
2302
2375
  exit 1
2303
2376
  end
2304
2377
 
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
2378
+ # Parse installed version from gem install output
2379
+ new_version = output[/Successfully installed #{Regexp.escape(gem_name)}-(\S+)/, 1]
2313
2380
 
2314
- if current_version && current_version == new_version
2315
- puts "✓ #{gem_name} is already at the latest version (#{new_version})"
2316
- else
2381
+ if new_version
2317
2382
  puts "✓ Updated #{gem_name}: #{current_version || "new"} → #{new_version}"
2318
2383
  puts " Restart the server to activate: brainiac restart"
2384
+ else
2385
+ puts "✓ #{gem_name} is already at the latest version (#{current_version})"
2319
2386
  end
2320
2387
  else
2321
2388
  # Update brainiac itself: brainiac update
2322
2389
  current_version = BRAINIAC_VERSION
2323
2390
 
2324
2391
  puts "Updating brainiac gem..."
2325
- system("gem install brainiac")
2326
- unless $CHILD_STATUS.success?
2392
+ output, status = Open3.capture2e("gem install brainiac")
2393
+ puts output
2394
+ unless status.success?
2327
2395
  puts ""
2328
2396
  puts "Failed to update brainiac."
2329
2397
  exit 1
2330
2398
  end
2331
2399
 
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
2400
+ # Parse installed version from gem install output
2401
+ new_version = output[/Successfully installed brainiac-(\S+)/, 1]
2340
2402
 
2341
- if current_version == new_version
2342
- puts "✓ brainiac is already at the latest version (#{new_version})"
2343
- else
2403
+ if new_version
2344
2404
  puts "✓ Updated brainiac: #{current_version} → #{new_version}"
2345
2405
  puts " Restart the server to apply: brainiac restart"
2406
+ else
2407
+ puts "✓ brainiac is already at the latest version (#{current_version})"
2346
2408
  end
2347
2409
  end
2348
2410
 
@@ -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.
@@ -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"]
@@ -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
@@ -0,0 +1,181 @@
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 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 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
47
+ - If the message is responding to or commenting on what someone OTHER than {{AGENT_NAME}} just said → no
48
+ - If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
49
+
50
+ Respond with ONLY "yes" or "no" — nothing else.
51
+
52
+ Latest message:
53
+ {{MESSAGE}}
54
+ PROMPT
55
+
56
+ PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
57
+ 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.
58
+
59
+ Rules:
60
+ - If the agent says it will do something, is about to start work, or promises a follow-up action → yes
61
+ - If the agent is reporting completed work, summarizing what was done, or delivering a final answer → no
62
+ - If the agent is asking a clarifying question and waiting for a human response → no
63
+ - If the agent says things like "give me a sec", "I'll implement this", "let me do that", "working on it" → yes
64
+ - If uncertain, lean toward no (avoid unnecessary re-dispatches)
65
+
66
+ Respond with ONLY "yes" or "no" — nothing else.
67
+
68
+ Agent's message:
69
+ {{MESSAGE}}
70
+ PROMPT
71
+
72
+ # Load intent config from brainiac.json, merging with defaults.
73
+ def intent_config
74
+ raw = BRAINIAC_CONFIG["intent"] || {}
75
+ INTENT_CONFIG_DEFAULTS.merge(raw)
76
+ end
77
+
78
+ # Check whether a message requires an agent to respond.
79
+ #
80
+ # @param message [String] The message text to classify
81
+ # @param agent_name [String] The agent being addressed
82
+ # @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
83
+ # @return [Boolean] true if the agent should respond, false if it can be skipped
84
+ def check_intent(message, agent_name:, channel: "conversation")
85
+ config = intent_config
86
+ return true unless config["enabled"]
87
+ return true if message.nil? || message.strip.empty?
88
+
89
+ prompt = INTENT_PROMPT_TEMPLATE
90
+ .gsub("{{AGENT_NAME}}", agent_name)
91
+ .gsub("{{CHANNEL}}", channel)
92
+ .gsub("{{MESSAGE}}", message.strip)
93
+
94
+ LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
95
+ response = query_local_llm(prompt, config)
96
+ result = positive_intent?(response)
97
+ LOG.info "[Intent] Result: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
98
+ result
99
+ rescue StandardError => e
100
+ LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
101
+ true
102
+ end
103
+
104
+ # Query the local LLM via Ollama's HTTP API.
105
+ #
106
+ # @param prompt [String] The classification prompt
107
+ # @param config [Hash] Intent configuration
108
+ # @return [String] Raw response text from the model
109
+ def query_local_llm(prompt, config)
110
+ uri = URI(config["endpoint"])
111
+ payload = {
112
+ model: config["model"],
113
+ prompt: prompt,
114
+ stream: false,
115
+ options: { temperature: config["temperature"] }
116
+ }
117
+
118
+ http = Net::HTTP.new(uri.host, uri.port)
119
+ http.open_timeout = config["timeout"]
120
+ http.read_timeout = config["timeout"]
121
+
122
+ request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
123
+ request.body = JSON.generate(payload)
124
+
125
+ response = http.request(request)
126
+ raise "Ollama returned #{response.code}: #{response.body&.slice(0, 200)}" unless response.is_a?(Net::HTTPSuccess)
127
+
128
+ body = JSON.parse(response.body)
129
+ body["response"] || ""
130
+ rescue Errno::ECONNREFUSED
131
+ raise "Ollama not running at #{config["endpoint"]}"
132
+ rescue Net::OpenTimeout, Net::ReadTimeout
133
+ raise "Ollama timed out after #{config["timeout"]}s"
134
+ end
135
+
136
+ # Parse the LLM's yes/no response into a boolean.
137
+ # Fail-open: anything that isn't clearly "no" returns true.
138
+ #
139
+ # @param response [String] Raw response from the model
140
+ # @return [Boolean]
141
+ def positive_intent?(response)
142
+ cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
143
+ cleaned != "no"
144
+ end
145
+
146
+ # Check whether an agent's own message implies pending work that wasn't completed.
147
+ # Used to detect when an agent posted "I'll do X" but the session ended without
148
+ # actually doing the work — signaling the agent should be re-dispatched.
149
+ #
150
+ # Fail-closed: returns false on any error (don't re-dispatch on classification failure).
151
+ #
152
+ # @param message [String] The agent's posted message
153
+ # @param agent_name [String] The agent who posted it
154
+ # @return [Boolean] true if the message implies uncommitted work
155
+ def check_pending_work(message, agent_name:)
156
+ config = intent_config
157
+ return false unless config["enabled"]
158
+ return false if message.nil? || message.strip.empty?
159
+
160
+ prompt = PENDING_WORK_PROMPT_TEMPLATE
161
+ .gsub("{{AGENT_NAME}}", agent_name)
162
+ .gsub("{{MESSAGE}}", message.strip)
163
+
164
+ response = query_local_llm(prompt, config)
165
+ result = pending_work_detected?(response)
166
+ LOG.info "[Intent] Pending work detected in #{agent_name}'s message" if result
167
+ result
168
+ rescue StandardError => e
169
+ LOG.warn "[Intent] Pending work check failed (fail-closed): #{e.message}"
170
+ false
171
+ end
172
+
173
+ # Parse the LLM's yes/no response for pending work detection.
174
+ # Fail-closed: anything that isn't clearly "yes" returns false.
175
+ #
176
+ # @param response [String] Raw response from the model
177
+ # @return [Boolean]
178
+ def pending_work_detected?(response)
179
+ cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
180
+ cleaned == "yes"
181
+ 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.15"
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.15
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