brainiac 0.0.6 → 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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +382 -11
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +15 -38
  7. data/lib/brainiac/config.rb +40 -11
  8. data/lib/brainiac/handlers/discord/api.rb +196 -0
  9. data/lib/brainiac/handlers/discord/config.rb +134 -0
  10. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  11. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  12. data/lib/brainiac/handlers/discord/message.rb +933 -0
  13. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  14. data/lib/brainiac/handlers/discord.rb +14 -1892
  15. data/lib/brainiac/handlers/fizzy/assignment.rb +125 -0
  16. data/lib/brainiac/handlers/fizzy/comments.rb +730 -0
  17. data/lib/brainiac/handlers/fizzy/dedup.rb +74 -0
  18. data/lib/brainiac/handlers/fizzy/deploy.rb +152 -0
  19. data/lib/brainiac/{deployments.rb → handlers/fizzy/deployments.rb} +4 -2
  20. data/lib/brainiac/handlers/fizzy.rb +12 -1290
  21. data/lib/brainiac/handlers/github.rb +149 -212
  22. data/lib/brainiac/handlers/shared/git.rb +203 -0
  23. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  24. data/lib/brainiac/handlers/zoho.rb +79 -76
  25. data/lib/brainiac/helpers.rb +2 -121
  26. data/lib/brainiac/planning.rb +2 -2
  27. data/lib/brainiac/plugins.rb +129 -0
  28. data/lib/brainiac/restart.rb +94 -0
  29. data/lib/brainiac/routes/api.rb +427 -0
  30. data/lib/brainiac/version.rb +1 -1
  31. data/lib/brainiac/zoho_mail_api.rb +2 -1
  32. data/lib/brainiac.rb +7 -0
  33. data/monitor/daemon.rb +4 -27
  34. data/monitor/shared.rb +247 -0
  35. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +14 -86
  36. data/monitor/waybar/setup.rb +232 -0
  37. data/monitor/waybar/status.rb +51 -0
  38. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  39. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  40. data/monitor/xbar/plugin.rb +155 -0
  41. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  42. data/receiver.rb +91 -450
  43. data/templates/brainiac.json.example +9 -0
  44. data/templates/cli-providers/kiro.json.example +8 -2
  45. data/templates/plugins.json.example +3 -0
  46. metadata +30 -20
  47. data/lib/user_registry.rb +0 -159
  48. data/monitor/menubar.rb +0 -295
  49. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  50. data/monitor/setup-waybar-deployments.rb +0 -96
  51. data/monitor/setup-waybar-module.rb +0 -113
  52. data/monitor/setup-xbar-plugin.rb +0 -35
  53. data/monitor/view-logs.rb +0 -119
  54. data/monitor/waybar-config-updater.rb +0 -56
  55. data/monitor/waybar-deployments.rb +0 -239
  56. data/monitor/waybar.rb +0 -146
  57. data/monitor/xbar.3s.rb +0 -179
  58. /data/lib/brainiac/{card_index.rb → handlers/fizzy/card_index.rb} +0 -0
  59. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  60. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -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.error "[Planning] Cannot finalize plan — no memory file found for card #{card_id}"
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.error "[Planning] Plan file not found at #{plan_file}"
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.6"
5
+ VERSION = "0.0.7"
6
6
  end
@@ -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", "\n").strip
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