brainiac 0.0.5 → 0.0.7
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/README.md +136 -6
- data/bin/brainiac +385 -19
- data/bin/brainiac-completion.bash +1 -1
- data/lib/brainiac/agents.rb +15 -38
- data/lib/brainiac/config.rb +40 -11
- data/lib/brainiac/handlers/discord/api.rb +196 -0
- data/lib/brainiac/handlers/discord/config.rb +134 -0
- data/lib/brainiac/handlers/discord/delivery.rb +196 -0
- data/lib/brainiac/handlers/discord/gateway.rb +212 -0
- data/lib/brainiac/handlers/discord/message.rb +933 -0
- data/lib/brainiac/handlers/discord/reactions.rb +215 -0
- data/lib/brainiac/handlers/discord.rb +14 -1892
- data/lib/brainiac/handlers/fizzy/assignment.rb +125 -0
- data/lib/brainiac/handlers/fizzy/comments.rb +730 -0
- data/lib/brainiac/handlers/fizzy/dedup.rb +74 -0
- data/lib/brainiac/handlers/fizzy/deploy.rb +152 -0
- data/lib/brainiac/{deployments.rb → handlers/fizzy/deployments.rb} +4 -2
- data/lib/brainiac/handlers/fizzy.rb +12 -1290
- data/lib/brainiac/handlers/github.rb +149 -212
- data/lib/brainiac/handlers/shared/git.rb +203 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
- data/lib/brainiac/handlers/zoho.rb +79 -76
- data/lib/brainiac/helpers.rb +2 -121
- data/lib/brainiac/planning.rb +2 -2
- data/lib/brainiac/plugins.rb +129 -0
- data/lib/brainiac/restart.rb +94 -0
- data/lib/brainiac/routes/api.rb +427 -0
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac/zoho_mail_api.rb +2 -1
- data/lib/brainiac.rb +7 -0
- data/monitor/daemon.rb +4 -27
- data/monitor/shared.rb +247 -0
- data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +14 -86
- data/monitor/waybar/setup.rb +232 -0
- data/monitor/waybar/status.rb +51 -0
- data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
- data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
- data/monitor/xbar/plugin.rb +155 -0
- data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
- data/receiver.rb +91 -450
- data/templates/brainiac.json.example +9 -0
- data/templates/cli-providers/kiro.json.example +8 -2
- data/templates/plugins.json.example +3 -0
- metadata +30 -20
- data/lib/user_registry.rb +0 -159
- data/monitor/menubar.rb +0 -295
- data/monitor/setup-waybar-deploy-envs.rb +0 -121
- data/monitor/setup-waybar-deployments.rb +0 -96
- data/monitor/setup-waybar-module.rb +0 -113
- data/monitor/setup-xbar-plugin.rb +0 -35
- data/monitor/view-logs.rb +0 -119
- data/monitor/waybar-config-updater.rb +0 -56
- data/monitor/waybar-deployments.rb +0 -239
- data/monitor/waybar.rb +0 -146
- data/monitor/xbar.3s.rb +0 -179
- /data/lib/brainiac/{card_index.rb → handlers/fizzy/card_index.rb} +0 -0
- /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
- /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
data/lib/brainiac/planning.rb
CHANGED
|
@@ -41,7 +41,7 @@ end
|
|
|
41
41
|
def finalize_plan(card_id:, card_number:, agent_name:, project_key:, repo_path:)
|
|
42
42
|
memory_file = File.join(memory_dir_for(agent_name), "card-#{card_id}.md")
|
|
43
43
|
unless File.exist?(memory_file)
|
|
44
|
-
LOG.
|
|
44
|
+
LOG.warn "[Planning] Cannot finalize plan — no memory file found for card #{card_id}"
|
|
45
45
|
return { success: false, error: "No memory file found" }
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -60,7 +60,7 @@ def finalize_plan(card_id:, card_number:, agent_name:, project_key:, repo_path:)
|
|
|
60
60
|
# The agent should have already written the plan to the file during its session.
|
|
61
61
|
# This function just validates it exists and creates Fizzy steps.
|
|
62
62
|
unless File.exist?(plan_file)
|
|
63
|
-
LOG.
|
|
63
|
+
LOG.warn "[Planning] Plan file not found at #{plan_file}"
|
|
64
64
|
return { success: false, error: "Plan file not generated by agent" }
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Plugin system for Brainiac.
|
|
4
|
+
#
|
|
5
|
+
# Plugins are distributed as gems named `brainiac-<name>` (e.g. brainiac-whatsapp).
|
|
6
|
+
# Each gem exposes a module at `Brainiac::Plugins::<Name>` that responds to `.register(app)`
|
|
7
|
+
# where `app` is the Sinatra application instance.
|
|
8
|
+
#
|
|
9
|
+
# Installed plugins are tracked in ~/.brainiac/plugins.json so the receiver
|
|
10
|
+
# knows which gems to require at startup.
|
|
11
|
+
|
|
12
|
+
PLUGINS_FILE = File.join(BRAINIAC_DIR, "plugins.json")
|
|
13
|
+
|
|
14
|
+
def load_plugins_config
|
|
15
|
+
return { "plugins" => [] } unless File.exist?(PLUGINS_FILE)
|
|
16
|
+
|
|
17
|
+
JSON.parse(File.read(PLUGINS_FILE))
|
|
18
|
+
rescue JSON::ParserError => e
|
|
19
|
+
LOG.error "Failed to parse plugins.json: #{e.message}"
|
|
20
|
+
{ "plugins" => [] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def save_plugins_config(config)
|
|
24
|
+
FileUtils.mkdir_p(BRAINIAC_DIR)
|
|
25
|
+
File.write(PLUGINS_FILE, JSON.pretty_generate(config))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
PLUGINS_CONFIG = load_plugins_config
|
|
29
|
+
|
|
30
|
+
# Returns the list of installed plugin names (e.g. ["whatsapp", "slack"])
|
|
31
|
+
def installed_plugins
|
|
32
|
+
(PLUGINS_CONFIG["plugins"] || []).map { |p| p.is_a?(Hash) ? p["name"] : p.to_s }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Load all installed plugin gems and call their register hooks.
|
|
36
|
+
# Called once during server startup, after core handlers are loaded.
|
|
37
|
+
def load_plugins!(app)
|
|
38
|
+
installed_plugins.each do |name|
|
|
39
|
+
gem_name = "brainiac-#{name}"
|
|
40
|
+
begin
|
|
41
|
+
require gem_name
|
|
42
|
+
plugin_module = resolve_plugin_module(name)
|
|
43
|
+
if plugin_module.respond_to?(:register)
|
|
44
|
+
plugin_module.register(app)
|
|
45
|
+
LOG.info "[Plugins] Loaded #{gem_name}"
|
|
46
|
+
else
|
|
47
|
+
LOG.warn "[Plugins] #{gem_name} loaded but no register method found"
|
|
48
|
+
end
|
|
49
|
+
rescue LoadError => e
|
|
50
|
+
LOG.error "[Plugins] Could not load #{gem_name}: #{e.message}"
|
|
51
|
+
LOG.error "[Plugins] Is the gem installed? Run: gem install #{gem_name}"
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
LOG.error "[Plugins] Error registering #{gem_name}: #{e.message}"
|
|
54
|
+
LOG.error "[Plugins] #{e.backtrace.first(3).join("\n ")}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resolve the plugin module for a given name.
|
|
60
|
+
# Tries Brainiac::Plugins::Whatsapp, Brainiac::Plugins::WhatsApp, etc.
|
|
61
|
+
def resolve_plugin_module(name)
|
|
62
|
+
return nil unless defined?(Brainiac::Plugins)
|
|
63
|
+
|
|
64
|
+
# Try PascalCase (e.g. "whatsapp" -> "Whatsapp", "test-widget" -> "TestWidget")
|
|
65
|
+
pascal = name.split(/[-_]/).map(&:capitalize).join
|
|
66
|
+
return Brainiac::Plugins.const_get(pascal) if Brainiac::Plugins.const_defined?(pascal)
|
|
67
|
+
|
|
68
|
+
# Try case-insensitive match (e.g. "WhatsApp" if the gem defines it that way)
|
|
69
|
+
Brainiac::Plugins.constants.each do |const|
|
|
70
|
+
return Brainiac::Plugins.const_get(const) if const.to_s.downcase == name.downcase
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Install a plugin gem and register it in plugins.json.
|
|
77
|
+
# rubocop:disable Naming/PredicateMethod
|
|
78
|
+
def install_plugin(name, version: nil)
|
|
79
|
+
gem_name = "brainiac-#{name}"
|
|
80
|
+
|
|
81
|
+
if installed_plugins.include?(name)
|
|
82
|
+
puts "Plugin '#{name}' is already installed."
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts "Installing #{gem_name}..."
|
|
87
|
+
install_cmd = ["gem", "install", gem_name]
|
|
88
|
+
install_cmd.push("--version", version) if version
|
|
89
|
+
|
|
90
|
+
stdout, stderr, status = Open3.capture3(*install_cmd)
|
|
91
|
+
unless status.success?
|
|
92
|
+
puts "Failed to install #{gem_name}:"
|
|
93
|
+
puts stderr.empty? ? stdout : stderr
|
|
94
|
+
return false
|
|
95
|
+
end
|
|
96
|
+
puts stdout unless stdout.strip.empty?
|
|
97
|
+
|
|
98
|
+
config = load_plugins_config
|
|
99
|
+
config["plugins"] ||= []
|
|
100
|
+
entry = { "name" => name, "gem" => gem_name, "installed_at" => Time.now.iso8601 }
|
|
101
|
+
entry["version"] = version if version
|
|
102
|
+
config["plugins"] << entry
|
|
103
|
+
save_plugins_config(config)
|
|
104
|
+
|
|
105
|
+
puts "✓ Installed plugin '#{name}' (#{gem_name})"
|
|
106
|
+
puts " Restart the server to activate: brainiac restart"
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Uninstall a plugin gem and remove from plugins.json
|
|
111
|
+
def uninstall_plugin(name)
|
|
112
|
+
gem_name = "brainiac-#{name}"
|
|
113
|
+
|
|
114
|
+
unless installed_plugins.include?(name)
|
|
115
|
+
puts "Plugin '#{name}' is not installed."
|
|
116
|
+
return false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
config = load_plugins_config
|
|
120
|
+
config["plugins"].reject! { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == name }
|
|
121
|
+
save_plugins_config(config)
|
|
122
|
+
|
|
123
|
+
puts "Removed plugin '#{name}' from Brainiac."
|
|
124
|
+
puts " The gem #{gem_name} is still installed system-wide."
|
|
125
|
+
puts " To fully remove: gem uninstall #{gem_name}"
|
|
126
|
+
puts " Restart the server to apply: brainiac restart"
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
# rubocop:enable Naming/PredicateMethod
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Brainiac self-restart logic.
|
|
4
|
+
#
|
|
5
|
+
# When an agent works on brainiac itself (modifies code), a restart is queued.
|
|
6
|
+
# A background thread checks every 30s and only restarts when no other agents
|
|
7
|
+
# are running, preventing mid-session kills.
|
|
8
|
+
#
|
|
9
|
+
# This is NOT Discord-specific — it was previously in the Discord handler
|
|
10
|
+
# because Discord agents trigger restarts most often, but any source can queue one.
|
|
11
|
+
|
|
12
|
+
BRAINIAC_RESTART_STATE = { queued: false, triggered_by: nil }
|
|
13
|
+
BRAINIAC_RESTART_MUTEX = Mutex.new
|
|
14
|
+
|
|
15
|
+
def queue_brainiac_restart(agent_name)
|
|
16
|
+
BRAINIAC_RESTART_MUTEX.synchronize do
|
|
17
|
+
unless BRAINIAC_RESTART_STATE[:queued]
|
|
18
|
+
BRAINIAC_RESTART_STATE[:queued] = true
|
|
19
|
+
BRAINIAC_RESTART_STATE[:triggered_by] = agent_name
|
|
20
|
+
LOG.info "[Brainiac] #{agent_name} queued a restart — will execute when all agents finish"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Send a Discord notification about brainiac restart/startup using any available bot token.
|
|
26
|
+
def send_restart_notification(message)
|
|
27
|
+
channel_id = DISCORD_CONFIG["notification_channel_id"]
|
|
28
|
+
return unless channel_id
|
|
29
|
+
|
|
30
|
+
tokens = discord_bot_tokens
|
|
31
|
+
triggered_by = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:triggered_by] }
|
|
32
|
+
token = tokens[triggered_by&.downcase] || tokens.values.first
|
|
33
|
+
return unless token
|
|
34
|
+
|
|
35
|
+
send_discord_message(channel_id, message, token: token)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
LOG.warn "[Brainiac] Failed to send restart notification: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def any_agents_running?
|
|
41
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
42
|
+
ACTIVE_SESSIONS.any? do |_key, info|
|
|
43
|
+
Process.kill(0, info[:pid])
|
|
44
|
+
true
|
|
45
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def execute_restart
|
|
52
|
+
triggered_by = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:triggered_by] }
|
|
53
|
+
LOG.info "[Brainiac] All agents finished, executing restart..."
|
|
54
|
+
BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] = false }
|
|
55
|
+
|
|
56
|
+
send_restart_notification("🔄 Restarting brainiac (triggered by #{triggered_by || "unknown"})...")
|
|
57
|
+
|
|
58
|
+
Thread.new do
|
|
59
|
+
sleep 1
|
|
60
|
+
source_dir = File.expand_path("../../..", __dir__)
|
|
61
|
+
receiver_path = File.join(source_dir, "receiver.rb")
|
|
62
|
+
log_file = File.join(source_dir, "tmp", "brainiac-server.log")
|
|
63
|
+
FileUtils.mkdir_p(File.dirname(log_file))
|
|
64
|
+
|
|
65
|
+
pid = spawn({ "PATH" => ENV.fetch("PATH", nil) }, "ruby", receiver_path,
|
|
66
|
+
chdir: source_dir, out: [log_file, "a"], err: %i[child out])
|
|
67
|
+
Process.detach(pid)
|
|
68
|
+
|
|
69
|
+
File.write(File.join(BRAINIAC_DIR, "brainiac-server.pid"), pid.to_s)
|
|
70
|
+
|
|
71
|
+
sleep 1
|
|
72
|
+
LOG.info "[Brainiac] Stopping server, new instance started (PID: #{pid}) from #{source_dir}"
|
|
73
|
+
Sinatra::Application.quit!
|
|
74
|
+
sleep 0.5
|
|
75
|
+
exit!
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def start_brainiac_restart_monitor
|
|
80
|
+
Thread.new do
|
|
81
|
+
LOG.info "[Brainiac] Restart monitor started, checking every 30s"
|
|
82
|
+
loop do
|
|
83
|
+
sleep 30
|
|
84
|
+
restart_needed = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] }
|
|
85
|
+
|
|
86
|
+
if restart_needed && !any_agents_running?
|
|
87
|
+
execute_restart
|
|
88
|
+
elsif restart_needed
|
|
89
|
+
active_count = ACTIVE_SESSIONS_MUTEX.synchronize { ACTIVE_SESSIONS.size }
|
|
90
|
+
LOG.info "[Brainiac] Restart queued but #{active_count} agent(s) still running, waiting..."
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Admin and data API routes.
|
|
4
|
+
#
|
|
5
|
+
# These are read-only (mostly) endpoints for inspecting system state:
|
|
6
|
+
# projects, agents, users, brain, skills, sessions, logs, etc.
|
|
7
|
+
|
|
8
|
+
# --- Session Helpers ---
|
|
9
|
+
|
|
10
|
+
def reap_dead_sessions
|
|
11
|
+
ACTIVE_SESSIONS.delete_if do |card_key, info|
|
|
12
|
+
Process.kill(0, info[:pid])
|
|
13
|
+
false
|
|
14
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
15
|
+
archive_session(card_key, info)
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def format_active_sessions
|
|
21
|
+
ACTIVE_SESSIONS.map do |card_key, info|
|
|
22
|
+
agent_name = resolve_session_agent_name(card_key, info)
|
|
23
|
+
{
|
|
24
|
+
card_key: card_key, agent: agent_name, pid: info[:pid],
|
|
25
|
+
started_at: info[:started_at].iso8601,
|
|
26
|
+
elapsed_seconds: (Time.now - info[:started_at]).to_i,
|
|
27
|
+
log_file: info[:log_file], alive: true,
|
|
28
|
+
children: child_processes_for(info[:pid])
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolve_session_agent_name(card_key, info)
|
|
34
|
+
return info[:agent_name] if info[:agent_name]
|
|
35
|
+
|
|
36
|
+
parts = card_key.split("-")
|
|
37
|
+
agent_key = if parts[0] == "discord" && parts.size >= 4
|
|
38
|
+
parts[1]
|
|
39
|
+
else
|
|
40
|
+
"Unknown"
|
|
41
|
+
end
|
|
42
|
+
fizzy_display_name(agent_key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_recent_sessions
|
|
46
|
+
RECENT_SESSIONS.map do |s|
|
|
47
|
+
{
|
|
48
|
+
card_key: s[:card_key], agent: s[:agent_name] || "Unknown",
|
|
49
|
+
log_file: s[:log_file], started_at: s[:started_at]&.iso8601,
|
|
50
|
+
finished_at: s[:finished_at]&.iso8601
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def kill_child_process(target_pid)
|
|
56
|
+
Process.kill("TERM", target_pid)
|
|
57
|
+
Thread.new do
|
|
58
|
+
sleep 3
|
|
59
|
+
begin
|
|
60
|
+
Process.kill(0, target_pid)
|
|
61
|
+
Process.kill("KILL", target_pid)
|
|
62
|
+
rescue Errno::ESRCH, Errno::EPERM # rubocop:disable Lint/SuppressedException
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
LOG.info "Killed child process #{target_pid} (SIGTERM)"
|
|
66
|
+
{ killed: target_pid }.to_json
|
|
67
|
+
rescue Errno::ESRCH
|
|
68
|
+
halt 404, { error: "process not found" }.to_json
|
|
69
|
+
rescue Errno::EPERM
|
|
70
|
+
halt 403, { error: "permission denied" }.to_json
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def search_giphy(query, api_key)
|
|
74
|
+
uri = URI("https://api.giphy.com/v1/gifs/search")
|
|
75
|
+
uri.query = URI.encode_www_form(api_key: api_key, q: query, limit: 5, rating: "pg-13")
|
|
76
|
+
response = Net::HTTP.get_response(uri)
|
|
77
|
+
|
|
78
|
+
if response.code.to_i == 200
|
|
79
|
+
results = JSON.parse(response.body)["data"] || []
|
|
80
|
+
results.map { |g| { url: g.dig("images", "original", "url") || g["url"], title: g["title"] } }
|
|
81
|
+
else
|
|
82
|
+
LOG.warn "[GIF] Giphy API returned #{response.code}: #{response.body[0..200]}"
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- Projects ---
|
|
88
|
+
|
|
89
|
+
get "/api/projects" do
|
|
90
|
+
content_type :json
|
|
91
|
+
reload_projects!
|
|
92
|
+
{ projects: PROJECTS }.to_json
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
get "/api/projects/:key" do
|
|
96
|
+
content_type :json
|
|
97
|
+
reload_projects!
|
|
98
|
+
project_key = params["key"]
|
|
99
|
+
if PROJECTS.key?(project_key)
|
|
100
|
+
{ project: PROJECTS[project_key] }.to_json
|
|
101
|
+
else
|
|
102
|
+
halt 404, { error: "Project not found" }.to_json
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
post "/api/reload" do
|
|
107
|
+
content_type :json
|
|
108
|
+
reload_projects!(force: true)
|
|
109
|
+
reload_agent_registry!(force: true)
|
|
110
|
+
reload_user_registry!(force: true)
|
|
111
|
+
reload_github_config!(force: true)
|
|
112
|
+
reload_deployments_config!(force: true) if defined?(DEPLOYMENTS_CONFIG)
|
|
113
|
+
ReloadHooks.run_all!
|
|
114
|
+
{ status: "reloaded", projects: PROJECTS.keys, agents: all_agent_names.to_a, registry: AGENT_REGISTRY.keys,
|
|
115
|
+
users: USER_REGISTRY["users"].size }.to_json
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- Agents & Roles ---
|
|
119
|
+
|
|
120
|
+
get "/api/agents" do
|
|
121
|
+
content_type :json
|
|
122
|
+
{ default: AI_AGENT_NAME, agents: discover_kiro_agents, all_known: all_agent_names.to_a, roster: agent_roster }.to_json
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
get "/api/roles" do
|
|
126
|
+
content_type :json
|
|
127
|
+
roles = []
|
|
128
|
+
if Dir.exist?(ROLES_DIR)
|
|
129
|
+
Dir.glob(File.join(ROLES_DIR, "*.md")).each do |f|
|
|
130
|
+
name = File.basename(f, ".md")
|
|
131
|
+
agents = AGENT_REGISTRY.select { |_, e| e.is_a?(Hash) && Array(e["role"]).include?(name) }.map { |k, e| e["fizzy_name"] || k.capitalize }
|
|
132
|
+
roles << { name: name, agents: agents }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
{ roles: roles, dir: ROLES_DIR }.to_json
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- Users ---
|
|
139
|
+
|
|
140
|
+
get "/api/users" do
|
|
141
|
+
content_type :json
|
|
142
|
+
reload_user_registry!
|
|
143
|
+
|
|
144
|
+
filter = params["filter"]
|
|
145
|
+
users = case filter
|
|
146
|
+
when "humans" then human_users
|
|
147
|
+
when "agents" then ai_agents
|
|
148
|
+
else USER_REGISTRY["users"]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
{ users: users, total: USER_REGISTRY["users"].size, schema_version: USER_REGISTRY["schema_version"] }.to_json
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
get "/api/users/:identifier" do
|
|
155
|
+
content_type :json
|
|
156
|
+
reload_user_registry!
|
|
157
|
+
|
|
158
|
+
identifier = params["identifier"]
|
|
159
|
+
user = find_user(identifier)
|
|
160
|
+
|
|
161
|
+
if user
|
|
162
|
+
{ user: user }.to_json
|
|
163
|
+
else
|
|
164
|
+
halt 404, { error: "User not found", identifier: identifier }.to_json
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- Brain ---
|
|
169
|
+
|
|
170
|
+
get "/api/brain" do
|
|
171
|
+
content_type :json
|
|
172
|
+
agent = params["agent"] || AI_AGENT_NAME
|
|
173
|
+
persona_dir = persona_dir_for(agent)
|
|
174
|
+
persona_col = persona_collection_for(agent)
|
|
175
|
+
|
|
176
|
+
knowledge_files = File.directory?(KNOWLEDGE_DIR) ? Dir.glob(File.join(KNOWLEDGE_DIR, "**", "*.md")).map { |f| f.sub("#{KNOWLEDGE_DIR}/", "") } : []
|
|
177
|
+
persona_files = File.directory?(persona_dir) ? Dir.glob(File.join(persona_dir, "**", "*.md")).map { |f| f.sub("#{persona_dir}/", "") } : []
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
agent: agent,
|
|
181
|
+
knowledge: { dir: KNOWLEDGE_DIR, collection: KNOWLEDGE_COLLECTION, files: knowledge_files },
|
|
182
|
+
persona: { dir: persona_dir, collection: persona_col, files: persona_files }
|
|
183
|
+
}.to_json
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
get "/api/brain/search" do
|
|
187
|
+
content_type :json
|
|
188
|
+
query = params["q"]
|
|
189
|
+
halt 400, { error: "Missing query parameter ?q=" }.to_json unless query && !query.empty?
|
|
190
|
+
|
|
191
|
+
agent = params["agent"] || AI_AGENT_NAME
|
|
192
|
+
scope = (params["scope"] || "knowledge").to_sym
|
|
193
|
+
scope = :knowledge unless %i[knowledge persona].include?(scope)
|
|
194
|
+
results = query_brain(query, agent_name: agent, scope: scope, max_results: (params["n"] || 5).to_i)
|
|
195
|
+
|
|
196
|
+
{ agent: agent, scope: scope, query: query, results: results }.to_json
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- Skills ---
|
|
200
|
+
|
|
201
|
+
get "/api/skills" do
|
|
202
|
+
content_type :json
|
|
203
|
+
skills = build_skill_index
|
|
204
|
+
{ total: skills.size, skills: skills }.to_json
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
post "/api/skills/curate" do
|
|
208
|
+
content_type :json
|
|
209
|
+
result = curate_skills
|
|
210
|
+
result.to_json
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# --- Card Index ---
|
|
214
|
+
|
|
215
|
+
get "/api/card-index" do
|
|
216
|
+
content_type :json
|
|
217
|
+
halt 404, { error: "Card index not enabled (fizzy handler disabled)" }.to_json unless defined?(CARD_INDEX)
|
|
218
|
+
|
|
219
|
+
query = params["q"]
|
|
220
|
+
if query && !query.empty?
|
|
221
|
+
similar = CARD_INDEX.find_similar_cards(query)
|
|
222
|
+
{ query: query, matches: similar, total_indexed: CARD_INDEX.size }.to_json
|
|
223
|
+
else
|
|
224
|
+
{ total: CARD_INDEX.size, cards: CARD_INDEX }.to_json
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# --- Dispatch Depth ---
|
|
229
|
+
|
|
230
|
+
get "/api/dispatch-depth" do
|
|
231
|
+
content_type :json
|
|
232
|
+
{
|
|
233
|
+
max_depth: AGENT_DISPATCH_MAX_DEPTH,
|
|
234
|
+
window_seconds: AGENT_DISPATCH_WINDOW,
|
|
235
|
+
cards: AGENT_DISPATCH_DEPTH.transform_values do |v|
|
|
236
|
+
{ count: v[:count], last_human_at: v[:last_human_at]&.iso8601, blocked: v[:count] >= AGENT_DISPATCH_MAX_DEPTH }
|
|
237
|
+
end
|
|
238
|
+
}.to_json
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- Sessions ---
|
|
242
|
+
|
|
243
|
+
get "/api/status" do
|
|
244
|
+
content_type :json
|
|
245
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
246
|
+
reap_dead_sessions
|
|
247
|
+
|
|
248
|
+
sessions = format_active_sessions
|
|
249
|
+
recent = format_recent_sessions
|
|
250
|
+
|
|
251
|
+
{ sessions: sessions, count: sessions.size, recent: recent, version: BRAINIAC_VERSION,
|
|
252
|
+
server_root: File.expand_path("../../..", __dir__) }.to_json
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
post "/api/sessions/kill/:card_key" do
|
|
257
|
+
content_type :json
|
|
258
|
+
card_key = params[:card_key]
|
|
259
|
+
halt 400, { error: "missing card_key" }.to_json if card_key.to_s.empty?
|
|
260
|
+
|
|
261
|
+
killed = kill_session(card_key)
|
|
262
|
+
halt 404, { error: "session not found" }.to_json unless killed
|
|
263
|
+
|
|
264
|
+
LOG.info "Killed agent session #{card_key} via API"
|
|
265
|
+
{ killed: card_key }.to_json
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
post "/api/sessions/kill-process/:pid" do
|
|
269
|
+
content_type :json
|
|
270
|
+
target_pid = params[:pid].to_i
|
|
271
|
+
halt 400, { error: "invalid pid" }.to_json if target_pid <= 0
|
|
272
|
+
|
|
273
|
+
valid = ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
274
|
+
ACTIVE_SESSIONS.any? do |_, info|
|
|
275
|
+
child_processes_for(info[:pid]).any? { |c| c[:pid] == target_pid }
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
halt 403, { error: "pid is not a child of any active agent session" }.to_json unless valid
|
|
279
|
+
|
|
280
|
+
kill_child_process(target_pid)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# --- Logs ---
|
|
284
|
+
|
|
285
|
+
get "/api/logs" do
|
|
286
|
+
content_type "text/plain"
|
|
287
|
+
log_file = params["file"]
|
|
288
|
+
lines = (params["lines"] || 200).to_i
|
|
289
|
+
|
|
290
|
+
halt 400, "Missing ?file= parameter" unless log_file && !log_file.empty?
|
|
291
|
+
halt 400, "Invalid path" if log_file.include?("..") || !log_file.start_with?("/")
|
|
292
|
+
halt 404, "File not found" unless File.exist?(log_file)
|
|
293
|
+
|
|
294
|
+
allowed = PROJECTS.values.map { |p| File.join(p["repo_path"], "tmp") }
|
|
295
|
+
allowed << File.join(BRAINIAC_DIR, "tmp")
|
|
296
|
+
halt 403, "Forbidden" unless allowed.any? { |dir| log_file.start_with?(dir) }
|
|
297
|
+
|
|
298
|
+
all_lines = File.readlines(log_file).last(lines)
|
|
299
|
+
all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# --- GIF ---
|
|
303
|
+
|
|
304
|
+
get "/api/gif" do
|
|
305
|
+
content_type :json
|
|
306
|
+
query = params[:q].to_s.strip
|
|
307
|
+
halt 400, { error: "Missing ?q= parameter" }.to_json if query.empty?
|
|
308
|
+
|
|
309
|
+
api_key = DISCORD_CONFIG["giphy_api_key"]
|
|
310
|
+
halt 503, { error: "No giphy_api_key configured in discord.json" }.to_json unless api_key
|
|
311
|
+
|
|
312
|
+
gifs = search_giphy(query, api_key)
|
|
313
|
+
halt 502, { error: "Giphy API error" }.to_json unless gifs
|
|
314
|
+
|
|
315
|
+
{ query: query, results: gifs }.to_json
|
|
316
|
+
rescue StandardError => e
|
|
317
|
+
LOG.error "[GIF] Search failed: #{e.message}"
|
|
318
|
+
halt 500, { error: e.message }.to_json
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# --- Cron ---
|
|
322
|
+
|
|
323
|
+
get "/api/cron/script" do
|
|
324
|
+
content_type "text/plain"
|
|
325
|
+
path = params["path"]
|
|
326
|
+
halt 400, "Missing ?path= parameter" unless path && !path.empty?
|
|
327
|
+
halt 400, "Invalid path" if path.include?("..")
|
|
328
|
+
|
|
329
|
+
valid = CRON_JOBS.values.any? do |j|
|
|
330
|
+
j[:script] == path || j["script"] == path ||
|
|
331
|
+
(j[:prompt] || j["prompt"] || "").include?(path)
|
|
332
|
+
end
|
|
333
|
+
halt 403, "Not a registered cron script" unless valid
|
|
334
|
+
halt 404, "File not found" unless File.exist?(path)
|
|
335
|
+
|
|
336
|
+
File.read(path)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
get "/api/cron" do
|
|
340
|
+
content_type :json
|
|
341
|
+
reload_cron_jobs!
|
|
342
|
+
{
|
|
343
|
+
enabled: true,
|
|
344
|
+
jobs: CRON_JOBS,
|
|
345
|
+
thread_alive: CRON_THREAD[:ref]&.alive? || false
|
|
346
|
+
}.to_json
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
post "/api/cron/add" do
|
|
350
|
+
content_type :json
|
|
351
|
+
request.body.rewind
|
|
352
|
+
payload = JSON.parse(request.body.read)
|
|
353
|
+
|
|
354
|
+
result = add_cron_job(
|
|
355
|
+
id: payload["id"],
|
|
356
|
+
schedule: payload["schedule"],
|
|
357
|
+
agent: payload["agent"],
|
|
358
|
+
project: payload["project"],
|
|
359
|
+
prompt: payload["prompt"],
|
|
360
|
+
script: payload["script"],
|
|
361
|
+
model: payload["model"],
|
|
362
|
+
effort: payload["effort"],
|
|
363
|
+
discord_channel_id: payload["discord_channel_id"],
|
|
364
|
+
forum_title: payload["forum_title"],
|
|
365
|
+
forum_reply_to_latest: payload["forum_reply_to_latest"] || false,
|
|
366
|
+
repeat_count: payload["repeat_count"]
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
result.to_json
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
post "/api/cron/remove" do
|
|
373
|
+
content_type :json
|
|
374
|
+
request.body.rewind
|
|
375
|
+
payload = JSON.parse(request.body.read)
|
|
376
|
+
|
|
377
|
+
result = remove_cron_job(payload["id"])
|
|
378
|
+
result.to_json
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
post "/api/cron/toggle" do
|
|
382
|
+
content_type :json
|
|
383
|
+
request.body.rewind
|
|
384
|
+
payload = JSON.parse(request.body.read)
|
|
385
|
+
|
|
386
|
+
result = toggle_cron_job(payload["id"], payload["enabled"])
|
|
387
|
+
result.to_json
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
post "/api/cron/update" do
|
|
391
|
+
content_type :json
|
|
392
|
+
request.body.rewind
|
|
393
|
+
payload = JSON.parse(request.body.read)
|
|
394
|
+
|
|
395
|
+
result = update_cron_job(
|
|
396
|
+
payload["id"],
|
|
397
|
+
schedule: payload["schedule"],
|
|
398
|
+
discord_channel_id: payload["discord_channel_id"],
|
|
399
|
+
forum_title: payload["forum_title"],
|
|
400
|
+
forum_reply_to_latest: payload["forum_reply_to_latest"]
|
|
401
|
+
)
|
|
402
|
+
result.to_json
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
post "/api/cron/reload" do
|
|
406
|
+
content_type :json
|
|
407
|
+
reload_cron_jobs!
|
|
408
|
+
{ status: "reloaded", jobs: CRON_JOBS.size }.to_json
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
get "/api/cron/logs" do
|
|
412
|
+
content_type :json
|
|
413
|
+
job_id = params["id"]
|
|
414
|
+
halt 400, { error: "Missing ?id= parameter" }.to_json unless job_id && !job_id.empty?
|
|
415
|
+
|
|
416
|
+
logs = []
|
|
417
|
+
PROJECTS.each_value do |proj|
|
|
418
|
+
tmp_dir = File.join(proj["repo_path"], "tmp")
|
|
419
|
+
next unless Dir.exist?(tmp_dir)
|
|
420
|
+
|
|
421
|
+
Dir.glob(File.join(tmp_dir, "{agent-cron,cron-script}-#{job_id}-*.log")).each do |f|
|
|
422
|
+
logs << { file: f, size: File.size(f), modified: File.mtime(f).iso8601 }
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
logs.sort_by! { |l| l[:modified] }.reverse!
|
|
426
|
+
logs.first(20).to_json
|
|
427
|
+
end
|
data/lib/brainiac/version.rb
CHANGED
|
@@ -94,7 +94,8 @@ end
|
|
|
94
94
|
def extract_text_from_mime(mime)
|
|
95
95
|
# Try to find text/plain part
|
|
96
96
|
if mime =~ %r{Content-Type: text/plain[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
|
|
97
|
-
return Regexp.last_match(1).gsub("\r\n",
|
|
97
|
+
return Regexp.last_match(1).gsub("\r\n",
|
|
98
|
+
"\n").strip
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
# Fallback: extract text/html part and strip tags
|
data/lib/brainiac.rb
CHANGED
|
@@ -8,3 +8,10 @@ require_relative "brainiac/prompts"
|
|
|
8
8
|
require_relative "brainiac/planning"
|
|
9
9
|
require_relative "brainiac/helpers"
|
|
10
10
|
require_relative "brainiac/cron"
|
|
11
|
+
require_relative "brainiac/plugins"
|
|
12
|
+
|
|
13
|
+
# Namespace for gem-based plugins (brainiac-whatsapp, brainiac-slack, etc.)
|
|
14
|
+
module Brainiac
|
|
15
|
+
module Plugins
|
|
16
|
+
end
|
|
17
|
+
end
|