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 +4 -4
- data/Gemfile.lock +2 -2
- data/bin/brainiac +89 -27
- data/lib/brainiac/agents.rb +18 -0
- data/lib/brainiac/config.rb +36 -3
- data/lib/brainiac/helpers.rb +20 -1
- data/lib/brainiac/hooks.rb +2 -0
- data/lib/brainiac/intent.rb +181 -0
- data/lib/brainiac/routes/api.rb +61 -0
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac.rb +1 -0
- data/receiver.rb +6 -0
- data/templates/brainiac.json.example +8 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 677c29e7ddcf5d9718eda91175193e9ffa448be65fac5e698982009327ee3576
|
|
4
|
+
data.tar.gz: ec7c044b3661f5fdca2f462a305a53ab47c6cfd76299462fa5e3df0f83f28a3a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2299
|
-
|
|
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
|
-
#
|
|
2306
|
-
|
|
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
|
|
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
|
-
|
|
2326
|
-
|
|
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
|
-
#
|
|
2333
|
-
|
|
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
|
|
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
|
|
data/lib/brainiac/agents.rb
CHANGED
|
@@ -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.
|
data/lib/brainiac/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -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"]
|
data/lib/brainiac/hooks.rb
CHANGED
|
@@ -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
|
data/lib/brainiac/routes/api.rb
CHANGED
|
@@ -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"]
|
data/lib/brainiac/version.rb
CHANGED
data/lib/brainiac.rb
CHANGED
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
|
|
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.
|
|
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
|