brainiac 0.0.1

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 +7 -0
  2. checksums.yaml.gz.sig +2 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/brainiac +1521 -0
  9. data/brainiac.gemspec +30 -0
  10. data/certs/stowzilla.pem +26 -0
  11. data/docs/waybar-config.md +96 -0
  12. data/lib/brainiac/agents.rb +203 -0
  13. data/lib/brainiac/brain.rb +197 -0
  14. data/lib/brainiac/card_index.rb +389 -0
  15. data/lib/brainiac/config.rb +263 -0
  16. data/lib/brainiac/cron.rb +629 -0
  17. data/lib/brainiac/deployments.rb +258 -0
  18. data/lib/brainiac/handlers/discord.rb +1643 -0
  19. data/lib/brainiac/handlers/fizzy.rb +1249 -0
  20. data/lib/brainiac/handlers/github.rb +598 -0
  21. data/lib/brainiac/handlers/zoho.rb +487 -0
  22. data/lib/brainiac/helpers.rb +760 -0
  23. data/lib/brainiac/planning.rb +237 -0
  24. data/lib/brainiac/prompts.rb +620 -0
  25. data/lib/brainiac/sessions.rb +282 -0
  26. data/lib/brainiac/skills.rb +276 -0
  27. data/lib/brainiac/users.rb +76 -0
  28. data/lib/brainiac/version.rb +6 -0
  29. data/lib/brainiac/zoho_mail_api.rb +109 -0
  30. data/lib/brainiac.rb +10 -0
  31. data/lib/user_registry.rb +159 -0
  32. data/monitor/daemon.rb +99 -0
  33. data/monitor/deploy-env-macos.rb +131 -0
  34. data/monitor/menubar.rb +295 -0
  35. data/monitor/open-action.sh +15 -0
  36. data/monitor/setup-menubar.rb +78 -0
  37. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  38. data/monitor/setup-waybar-deployments.rb +96 -0
  39. data/monitor/setup-waybar-module.rb +113 -0
  40. data/monitor/setup-xbar-plugin.rb +35 -0
  41. data/monitor/view-logs-macos.rb +210 -0
  42. data/monitor/view-logs-rofi.rb +194 -0
  43. data/monitor/view-logs.rb +119 -0
  44. data/monitor/waybar-config-updater.rb +56 -0
  45. data/monitor/waybar-deploy-env.rb +206 -0
  46. data/monitor/waybar-deployments.rb +239 -0
  47. data/monitor/waybar.rb +146 -0
  48. data/monitor/xbar.3s.rb +179 -0
  49. data/receiver.rb +956 -0
  50. data/templates/agents.json.example +10 -0
  51. data/templates/discord.json.example +17 -0
  52. data/templates/fizzy.json.example +24 -0
  53. data/templates/github.json.example +4 -0
  54. data/templates/testflight.json.example +8 -0
  55. data/templates/users.json.example +121 -0
  56. data/templates/zoho.json.example +27 -0
  57. data/views/dashboard.erb +437 -0
  58. data.tar.gz.sig +0 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Zoho Mail REST API client for fetching email content.
4
+ # Used when webhook payloads don't include the body (which is most of the time).
5
+ #
6
+ # Requires zoho.json to have:
7
+ # "api": {
8
+ # "client_id": "...",
9
+ # "client_secret": "...",
10
+ # "refresh_token": "...",
11
+ # "account_id": "..."
12
+ # }
13
+
14
+ ZOHO_TOKEN_URL = "https://accounts.zoho.com/oauth/v2/token"
15
+ ZOHO_MAIL_API_BASE = "https://mail.zoho.com/api/accounts"
16
+
17
+ # In-memory cache for access token (expires after ~55 min to be safe)
18
+ @zoho_access_token = nil
19
+ @zoho_token_expires_at = Time.at(0)
20
+
21
+ def zoho_api_configured?
22
+ api = ZOHO_CONFIG["api"]
23
+ api && api["client_id"] && api["client_secret"] && api["refresh_token"] && api["account_id"]
24
+ end
25
+
26
+ def zoho_refresh_access_token!
27
+ api = ZOHO_CONFIG["api"]
28
+ uri = URI(ZOHO_TOKEN_URL)
29
+ res = Net::HTTP.post_form(uri, {
30
+ "grant_type" => "refresh_token",
31
+ "client_id" => api["client_id"],
32
+ "client_secret" => api["client_secret"],
33
+ "refresh_token" => api["refresh_token"]
34
+ })
35
+
36
+ data = JSON.parse(res.body)
37
+ if data["access_token"]
38
+ @zoho_access_token = data["access_token"]
39
+ @zoho_token_expires_at = Time.now + 3300 # ~55 min
40
+ LOG.info "[Zoho:API] Refreshed access token"
41
+ @zoho_access_token
42
+ else
43
+ LOG.error "[Zoho:API] Token refresh failed: #{data["error"]}"
44
+ nil
45
+ end
46
+ rescue StandardError => e
47
+ LOG.error "[Zoho:API] Token refresh error: #{e.message}"
48
+ nil
49
+ end
50
+
51
+ def zoho_access_token
52
+ return @zoho_access_token if @zoho_access_token && Time.now < @zoho_token_expires_at
53
+
54
+ zoho_refresh_access_token!
55
+ end
56
+
57
+ # Fetch email content by messageId using the "original message" endpoint.
58
+ # This endpoint doesn't require a folder ID — just accountId + messageId.
59
+ # Returns plain-text body extracted from the MIME content, or nil.
60
+ def fetch_zoho_email_content(message_id)
61
+ return nil unless zoho_api_configured?
62
+
63
+ token = zoho_access_token
64
+ return nil unless token
65
+
66
+ account_id = ZOHO_CONFIG.dig("api", "account_id")
67
+ uri = URI("#{ZOHO_MAIL_API_BASE}/#{account_id}/messages/#{message_id}/originalmessage")
68
+
69
+ http = Net::HTTP.new(uri.host, uri.port)
70
+ http.use_ssl = true
71
+ req = Net::HTTP::Get.new(uri)
72
+ req["Authorization"] = "Zoho-oauthtoken #{token}"
73
+ req["Accept"] = "application/json"
74
+
75
+ res = http.request(req)
76
+ data = JSON.parse(res.body)
77
+
78
+ if data.dig("status", "code") == 200
79
+ raw_mime = data.dig("data", "content").to_s
80
+ text = extract_text_from_mime(raw_mime)
81
+ LOG.info "[Zoho:API] Fetched content for message #{message_id} (#{text.length} chars)"
82
+ text
83
+ else
84
+ LOG.warn "[Zoho:API] Failed to fetch content: #{data.dig("status", "description")}"
85
+ nil
86
+ end
87
+ rescue StandardError => e
88
+ LOG.error "[Zoho:API] Error fetching content: #{e.message}"
89
+ nil
90
+ end
91
+
92
+ # Extract readable text from raw MIME content.
93
+ # Prefers text/plain part; falls back to stripping HTML from text/html part.
94
+ def extract_text_from_mime(mime)
95
+ # Try to find text/plain part
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
98
+ end
99
+
100
+ # Fallback: extract text/html part and strip tags
101
+ if mime =~ %r{Content-Type: text/html[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
102
+ html = Regexp.last_match(1).gsub("\r\n", "\n")
103
+ return html.gsub(/<[^>]+>/, " ").gsub(/&nbsp;/i, " ").gsub(/&amp;/i, "&")
104
+ .gsub(/&lt;/i, "<").gsub(/&gt;/i, ">").gsub(/\s+/, " ").strip
105
+ end
106
+
107
+ # Last resort: strip all HTML-ish content from the whole thing
108
+ mime.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
109
+ end
data/lib/brainiac.rb ADDED
@@ -0,0 +1,10 @@
1
+ require_relative "brainiac/config"
2
+ require_relative "brainiac/users"
3
+ require_relative "brainiac/agents"
4
+ require_relative "brainiac/brain"
5
+ require_relative "brainiac/skills"
6
+ require_relative "brainiac/sessions"
7
+ require_relative "brainiac/prompts"
8
+ require_relative "brainiac/planning"
9
+ require_relative "brainiac/helpers"
10
+ require_relative "brainiac/cron"
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ # UserRegistry - Centralized user identity tracking
7
+ #
8
+ # Resolves user identities across platforms (Discord, GitHub, Fizzy)
9
+ # and provides canonical names, aliases, and relationships.
10
+ #
11
+ # Usage:
12
+ # registry = UserRegistry.new
13
+ # user = registry.find_by_discord_id('832331260088287242')
14
+ # puts user['canonical_name'] # => "Adam Dalton"
15
+ # puts user['identities']['github']['username'] # => "dalton"
16
+ #
17
+ class UserRegistry
18
+ USERS_FILE = File.expand_path("~/.brainiac/users.json")
19
+
20
+ def initialize
21
+ @data = load_data
22
+ end
23
+
24
+ # Find user by Discord user ID
25
+ def find_by_discord_id(user_id)
26
+ @data["users"].find { |u| u.dig("identities", "discord", "user_id") == user_id.to_s }
27
+ end
28
+
29
+ # Find user by Discord username
30
+ def find_by_discord_username(username)
31
+ @data["users"].find { |u| u.dig("identities", "discord", "username") == username.to_s }
32
+ end
33
+
34
+ # Find user by GitHub username
35
+ def find_by_github_username(username)
36
+ @data["users"].find { |u| u.dig("identities", "github", "username") == username.to_s }
37
+ end
38
+
39
+ # Find user by Fizzy username
40
+ def find_by_fizzy_username(username)
41
+ @data["users"].find { |u| u.dig("identities", "fizzy", "username") == username.to_s }
42
+ end
43
+
44
+ # Find user by canonical name
45
+ def find_by_canonical_name(name)
46
+ @data["users"].find { |u| u["canonical_name"].downcase == name.downcase }
47
+ end
48
+
49
+ # Find user by any identifier (tries all platforms)
50
+ def find(identifier)
51
+ find_by_discord_id(identifier) ||
52
+ find_by_discord_username(identifier) ||
53
+ find_by_github_username(identifier) ||
54
+ find_by_fizzy_username(identifier) ||
55
+ find_by_canonical_name(identifier)
56
+ end
57
+
58
+ # Get all users
59
+ def all
60
+ @data["users"]
61
+ end
62
+
63
+ # Get all human users (exclude AI agents)
64
+ def humans
65
+ @data["users"].reject { |u| u["notes"]&.include?("AI agent") }
66
+ end
67
+
68
+ # Get all AI agents
69
+ def agents
70
+ @data["users"].select { |u| u["notes"]&.include?("AI agent") }
71
+ end
72
+
73
+ # Reload data from disk
74
+ def reload!
75
+ @data = load_data
76
+ end
77
+
78
+ private
79
+
80
+ def load_data
81
+ return { "users" => [] } unless File.exist?(USERS_FILE)
82
+
83
+ JSON.parse(File.read(USERS_FILE))
84
+ rescue JSON::ParserError => e
85
+ warn "Failed to parse #{USERS_FILE}: #{e.message}"
86
+ { "users" => [] }
87
+ end
88
+ end
89
+
90
+ # CLI interface when run directly
91
+ if __FILE__ == $PROGRAM_NAME
92
+ require "optparse"
93
+
94
+ options = {}
95
+ OptionParser.new do |opts|
96
+ opts.banner = "Usage: user_registry.rb [options] [identifier]"
97
+
98
+ opts.on("-d", "--discord-id ID", "Find by Discord user ID") do |id|
99
+ options[:discord_id] = id
100
+ end
101
+
102
+ opts.on("-u", "--discord-username USERNAME", "Find by Discord username") do |username|
103
+ options[:discord_username] = username
104
+ end
105
+
106
+ opts.on("-g", "--github USERNAME", "Find by GitHub username") do |username|
107
+ options[:github] = username
108
+ end
109
+
110
+ opts.on("-f", "--fizzy USERNAME", "Find by Fizzy username") do |username|
111
+ options[:fizzy] = username
112
+ end
113
+
114
+ opts.on("-l", "--list", "List all users") do
115
+ options[:list] = true
116
+ end
117
+
118
+ opts.on("--humans", "List only human users") do
119
+ options[:humans] = true
120
+ end
121
+
122
+ opts.on("--agents", "List only AI agents") do
123
+ options[:agents] = true
124
+ end
125
+
126
+ opts.on("-h", "--help", "Show this help") do
127
+ puts opts
128
+ exit
129
+ end
130
+ end.parse!
131
+
132
+ registry = UserRegistry.new
133
+
134
+ if options[:list]
135
+ puts JSON.pretty_generate(registry.all)
136
+ elsif options[:humans]
137
+ puts JSON.pretty_generate(registry.humans)
138
+ elsif options[:agents]
139
+ puts JSON.pretty_generate(registry.agents)
140
+ elsif options[:discord_id]
141
+ user = registry.find_by_discord_id(options[:discord_id])
142
+ puts user ? JSON.pretty_generate(user) : "User not found"
143
+ elsif options[:discord_username]
144
+ user = registry.find_by_discord_username(options[:discord_username])
145
+ puts user ? JSON.pretty_generate(user) : "User not found"
146
+ elsif options[:github]
147
+ user = registry.find_by_github_username(options[:github])
148
+ puts user ? JSON.pretty_generate(user) : "User not found"
149
+ elsif options[:fizzy]
150
+ user = registry.find_by_fizzy_username(options[:fizzy])
151
+ puts user ? JSON.pretty_generate(user) : "User not found"
152
+ elsif ARGV[0]
153
+ user = registry.find(ARGV[0])
154
+ puts user ? JSON.pretty_generate(user) : "User not found"
155
+ else
156
+ puts "No search criteria provided. Use --help for usage."
157
+ exit 1
158
+ end
159
+ end
data/monitor/daemon.rb ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Brainiac Monitor Daemon
5
+ # Polls /api/status and exposes agent state via Unix socket for waybar
6
+ # No longer triggers config updates - waybar module polls this socket directly
7
+
8
+ require "json"
9
+ require "net/http"
10
+ require "socket"
11
+
12
+ SOCKET_PATH = "/tmp/brainiac-monitor.sock"
13
+ API_URL = "http://localhost:4567/api/status"
14
+ POLL_INTERVAL = 2 # seconds
15
+ CONFIG_PATH = File.expand_path("~/.brainiac/waybar.json")
16
+
17
+ # Load agent configuration from JSON
18
+ def load_agent_config
19
+ config = JSON.parse(File.read(CONFIG_PATH))
20
+ agents = {}
21
+ config["agents"].each do |agent|
22
+ agents[agent["name"]] = { color: agent["color"], emoji: agent["emoji"] }
23
+ end
24
+ agents
25
+ rescue StandardError => e
26
+ warn "Failed to load waybar.json: #{e.message}"
27
+ {}
28
+ end
29
+
30
+ AGENTS = load_agent_config.freeze
31
+
32
+ @state = { sessions: [], count: 0, recent: [], last_update: nil }
33
+
34
+ def fetch_status
35
+ uri = URI(API_URL)
36
+ response = Net::HTTP.get_response(uri)
37
+ return nil unless response.is_a?(Net::HTTPSuccess)
38
+
39
+ JSON.parse(response.body)
40
+ rescue StandardError => e
41
+ warn "Failed to fetch status: #{e.message}"
42
+ nil
43
+ end
44
+
45
+ def update_state
46
+ data = fetch_status
47
+ return unless data
48
+
49
+ @state = {
50
+ sessions: data["sessions"],
51
+ count: data["count"],
52
+ recent: data["recent"] || [],
53
+ last_update: Time.now.to_i
54
+ }
55
+ end
56
+
57
+ def handle_client(client)
58
+ client.puts @state.to_json
59
+ client.close
60
+ rescue StandardError => e
61
+ warn "Error handling client: #{e.message}"
62
+ end
63
+
64
+ def start_server
65
+ FileUtils.rm_f(SOCKET_PATH)
66
+
67
+ server = UNIXServer.new(SOCKET_PATH)
68
+ File.chmod(0o666, SOCKET_PATH)
69
+
70
+ # Write PID file
71
+ File.write("/tmp/brainiac-daemon.pid", Process.pid)
72
+
73
+ puts "Monitor daemon started, socket: #{SOCKET_PATH}"
74
+
75
+ # Start polling thread
76
+ poller = Thread.new do
77
+ loop do
78
+ update_state
79
+ sleep POLL_INTERVAL
80
+ end
81
+ end
82
+
83
+ # Initial state fetch
84
+ update_state
85
+
86
+ # Accept client connections
87
+ loop do
88
+ client = server.accept
89
+ Thread.new { handle_client(client) }
90
+ end
91
+ rescue Interrupt
92
+ puts "\nShutting down..."
93
+ poller&.kill
94
+ FileUtils.rm_f(SOCKET_PATH)
95
+ FileUtils.rm_f("/tmp/brainiac-daemon.pid")
96
+ exit 0
97
+ end
98
+
99
+ start_server
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Brainiac macOS Deploy Action
5
+ # Prompts for a Fizzy card number via osascript, finds the worktree, and deploys.
6
+ # Usage: deploy-env-macos.rb <env_key>
7
+
8
+ require "English"
9
+ require "json"
10
+ require "net/http"
11
+ require "shellwords"
12
+ require "uri"
13
+
14
+ SERVER_URL = "http://localhost:4567"
15
+
16
+ env_key = ARGV[0]
17
+ exit unless env_key
18
+
19
+ def fetch_deployments
20
+ uri = URI("#{SERVER_URL}/api/deployments")
21
+ response = Net::HTTP.get_response(uri)
22
+ JSON.parse(response.body)["deployments"] || []
23
+ rescue StandardError
24
+ nil
25
+ end
26
+
27
+ deployments = fetch_deployments
28
+ deployment = deployments&.find { |d| d["env"] == env_key }
29
+
30
+ prefill = deployment && deployment["status"] == "occupied" && deployment["card_number"] ? deployment["card_number"].to_s : ""
31
+
32
+ # Prompt via AppleScript
33
+ prompt_script = <<~APPLESCRIPT
34
+ set defaultAnswer to "#{prefill}"
35
+ set dialogResult to display dialog "Fizzy card number:" default answer defaultAnswer with title "Deploy to #{env_key}" buttons {"Cancel", "Deploy"} default button "Deploy"
36
+ return text returned of dialogResult
37
+ APPLESCRIPT
38
+
39
+ card_number = `osascript -e #{Shellwords.escape(prompt_script)} 2>/dev/null`.strip
40
+ exit if card_number.empty?
41
+
42
+ # Find worktree via card_map.json
43
+ card_map_path = File.expand_path("~/.brainiac/card_map.json")
44
+ worktree = nil
45
+ if File.exist?(card_map_path)
46
+ card_map = begin
47
+ JSON.parse(File.read(card_map_path))
48
+ rescue StandardError
49
+ {}
50
+ end
51
+ entry = card_map.values.find { |e| e["card_number"].to_s == card_number }
52
+ worktree = entry["worktree"] if entry && entry["worktree"] && File.directory?(entry["worktree"].to_s)
53
+ end
54
+
55
+ # Fallback: glob for worktree directories
56
+ unless worktree
57
+ matches = Dir.glob(File.expand_path("~/projects/sogholdings/*fizzy-#{card_number}-*/"))
58
+ worktree = matches.find { |d| File.directory?(d) }
59
+ end
60
+
61
+ unless worktree
62
+ system("osascript", "-e",
63
+ "display dialog \"No worktree found for card ##{card_number}\" buttons {\"OK\"} default button \"OK\" with title \"Deploy Failed\" with icon stop")
64
+ exit
65
+ end
66
+
67
+ # Resolve AWS_PROFILE
68
+ config_file = File.expand_path("~/.brainiac/deployments.json")
69
+ aws_profile = nil
70
+ if File.exist?(config_file)
71
+ cfg = begin
72
+ JSON.parse(File.read(config_file))
73
+ rescue StandardError
74
+ {}
75
+ end
76
+ aws_profile = cfg.dig("environments", env_key, "aws_profile")
77
+ end
78
+
79
+ # Mark deploying
80
+ begin
81
+ uri = URI("#{SERVER_URL}/api/deployments/#{env_key}/deploying")
82
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
83
+ req.body = { worktree: worktree }.to_json
84
+ Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
85
+ rescue StandardError
86
+ # Non-fatal
87
+ end
88
+
89
+ # Build deploy script
90
+ deploy_script = <<~BASH
91
+ cd #{Shellwords.escape(worktree)}
92
+ #{"export AWS_PROFILE=#{Shellwords.escape(aws_profile)}" if aws_profile}
93
+ echo "🚀 Deploying card ##{card_number} to #{env_key}..."
94
+ echo " Worktree: #{worktree}"
95
+ echo
96
+ logfile=$(mktemp)
97
+ ./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1 | tee "$logfile"
98
+ status=${PIPESTATUS[0]}
99
+ if [ $status -ne 0 ] && grep -q "checksums previously recorded in the dependency lock file" "$logfile"; then
100
+ echo
101
+ echo "⚠️ Terraform lock file mismatch — removing lock and running init -upgrade..."
102
+ echo
103
+ rm -f infrastructure/#{Shellwords.escape(env_key)}/.terraform.lock.hcl
104
+ (cd infrastructure/#{Shellwords.escape(env_key)} && terraform init -upgrade)
105
+ echo
106
+ echo "🔄 Retrying deploy..."
107
+ echo
108
+ ./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1
109
+ status=$?
110
+ fi
111
+ rm -f "$logfile"
112
+ echo
113
+ if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
114
+ echo
115
+ echo "Press any key to close..."
116
+ read -n 1
117
+ BASH
118
+
119
+ # Write to temp file and run in Terminal.app
120
+ script_file = "/tmp/brainiac-deploy-#{env_key}-#{$PROCESS_ID}.sh"
121
+ File.write(script_file, deploy_script)
122
+ File.chmod(0o755, script_file)
123
+
124
+ terminal_script = <<~APPLESCRIPT
125
+ tell application "Terminal"
126
+ activate
127
+ do script "#{script_file.gsub('"', '\\"')}; rm -f #{script_file.gsub('"', '\\"')}"
128
+ end tell
129
+ APPLESCRIPT
130
+
131
+ system("osascript", "-e", terminal_script)