brainiac 0.0.6 → 0.0.8

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +472 -44
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +27 -74
  7. data/lib/brainiac/brain.rb +6 -6
  8. data/lib/brainiac/config.rb +40 -76
  9. data/lib/brainiac/handlers/discord/api.rb +196 -0
  10. data/lib/brainiac/handlers/discord/config.rb +134 -0
  11. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  12. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  13. data/lib/brainiac/handlers/discord/message.rb +933 -0
  14. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  15. data/lib/brainiac/handlers/discord.rb +14 -1892
  16. data/lib/brainiac/handlers/github.rb +134 -317
  17. data/lib/brainiac/handlers/shared/git.rb +190 -0
  18. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  19. data/lib/brainiac/handlers/zoho.rb +103 -153
  20. data/lib/brainiac/helpers.rb +43 -455
  21. data/lib/brainiac/hooks.rb +86 -0
  22. data/lib/brainiac/plugins.rb +154 -0
  23. data/lib/brainiac/prompts.rb +34 -172
  24. data/lib/brainiac/restart.rb +112 -0
  25. data/lib/brainiac/routes/api.rb +411 -0
  26. data/lib/brainiac/users.rb +1 -7
  27. data/lib/brainiac/version.rb +1 -1
  28. data/lib/brainiac/zoho_mail_api.rb +2 -1
  29. data/lib/brainiac.rb +8 -1
  30. data/monitor/daemon.rb +4 -27
  31. data/monitor/shared.rb +247 -0
  32. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
  33. data/monitor/waybar/setup.rb +232 -0
  34. data/monitor/waybar/status.rb +51 -0
  35. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  36. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  37. data/monitor/xbar/plugin.rb +149 -0
  38. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  39. data/receiver.rb +44 -551
  40. data/templates/agents.json.example +1 -2
  41. data/templates/brainiac.json.example +8 -0
  42. data/templates/cli-providers/kiro.json.example +8 -2
  43. data/templates/plugins.json.example +3 -0
  44. data/templates/users.json.example +0 -3
  45. metadata +25 -23
  46. data/lib/brainiac/card_index.rb +0 -389
  47. data/lib/brainiac/deployments.rb +0 -258
  48. data/lib/brainiac/handlers/fizzy.rb +0 -1292
  49. data/lib/brainiac/planning.rb +0 -237
  50. data/lib/user_registry.rb +0 -159
  51. data/monitor/menubar.rb +0 -295
  52. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  53. data/monitor/setup-waybar-deployments.rb +0 -96
  54. data/monitor/setup-waybar-module.rb +0 -113
  55. data/monitor/setup-xbar-plugin.rb +0 -35
  56. data/monitor/view-logs.rb +0 -119
  57. data/monitor/waybar-config-updater.rb +0 -56
  58. data/monitor/waybar-deployments.rb +0 -239
  59. data/monitor/waybar.rb +0 -146
  60. data/monitor/xbar.3s.rb +0 -179
  61. data/templates/fizzy.json.example +0 -24
  62. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  63. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -1,237 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Planning mode: interactive requirement gathering and task breakdown.
4
- #
5
- # Triggered by [plan] tag in Discord or 'plan' tag in Fizzy cards.
6
- # Agent asks clarifying questions, logs Q&A to memory, generates a plan
7
- # markdown file, and creates Fizzy steps for each task.
8
-
9
- PLANS_DIR = File.join(BRAINIAC_DIR, "plans")
10
- FileUtils.mkdir_p(PLANS_DIR)
11
-
12
- # Detect if a message/card should trigger planning mode.
13
- # Returns: { mode: :planning, card_id: '...', card_number: 123 } or nil
14
- def detect_planning_mode(text:, tags: [], card_internal_id: nil, card_number: nil)
15
- # Discord: [plan] anywhere in message
16
- # Fizzy: 'plan' tag on card
17
- has_plan_tag = text.match?(/\[plan\]/i) || tags.any? { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase == "plan" }
18
- return nil unless has_plan_tag
19
-
20
- {
21
- mode: :planning,
22
- card_id: card_internal_id || "discord-#{Time.now.to_i}",
23
- card_number: card_number
24
- }
25
- end
26
-
27
- # Check if planning is complete for a given card.
28
- # Planning is complete when:
29
- # 1. A plan file exists at PLANS_DIR/card-{id}-plan.md
30
- # 2. Memory file indicates planning_complete: true
31
- def planning_complete?(card_id, agent_name)
32
- memory_file = File.join(memory_dir_for(agent_name), "card-#{card_id}.md")
33
- return false unless File.exist?(memory_file)
34
-
35
- memory_content = File.read(memory_file)
36
- memory_content.match?(/planning_complete:\s*true/i)
37
- end
38
-
39
- # Generate plan markdown from memory Q&A and create Fizzy steps.
40
- # This is called when the agent determines it has enough information.
41
- def finalize_plan(card_id:, card_number:, agent_name:, project_key:, repo_path:)
42
- memory_file = File.join(memory_dir_for(agent_name), "card-#{card_id}.md")
43
- unless File.exist?(memory_file)
44
- LOG.error "[Planning] Cannot finalize plan — no memory file found for card #{card_id}"
45
- return { success: false, error: "No memory file found" }
46
- end
47
-
48
- memory_content = File.read(memory_file)
49
-
50
- # Extract Q&A from memory (agents should format it consistently)
51
- # Expected format in memory:
52
- # ## Planning Q&A
53
- # Q: What's the goal?
54
- # A: Build a feature that does X
55
- # Q: Should it support Y?
56
- # A: Yes, and also Z
57
-
58
- plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md")
59
-
60
- # The agent should have already written the plan to the file during its session.
61
- # This function just validates it exists and creates Fizzy steps.
62
- unless File.exist?(plan_file)
63
- LOG.error "[Planning] Plan file not found at #{plan_file}"
64
- return { success: false, error: "Plan file not generated by agent" }
65
- end
66
-
67
- plan_content = File.read(plan_file)
68
-
69
- # Parse tasks from plan markdown.
70
- # Expected format:
71
- # ## Task Breakdown
72
- # ### Task 1: Title
73
- # ### Task 2: Title
74
- tasks = []
75
- plan_content.scan(/^###\s+Task\s+\d+:\s+(.+)$/i) do |match|
76
- tasks << match[0].strip
77
- end
78
-
79
- if tasks.empty?
80
- LOG.warn "[Planning] No tasks found in plan file #{plan_file}"
81
- return { success: false, error: "No tasks found in plan" }
82
- end
83
-
84
- # Create Fizzy steps for each task
85
- if card_number
86
- LOG.info "[Planning] Creating #{tasks.size} Fizzy steps for card ##{card_number}"
87
- tasks.each do |task_title|
88
- run_cmd("fizzy", "step", "create", "--card", card_number.to_s, "--content", task_title,
89
- chdir: repo_path, env: fizzy_env_for(agent_name))
90
- LOG.info "[Planning] Created step: #{task_title}"
91
- rescue StandardError => e
92
- LOG.error "[Planning] Failed to create step '#{task_title}': #{e.message}"
93
- end
94
- end
95
-
96
- # Mark planning as complete in memory
97
- updated_memory = memory_content.sub(/planning_complete:\s*false/i, "planning_complete: true")
98
- updated_memory += "\n\nplanning_complete: true\n" unless updated_memory.include?("planning_complete: true")
99
- File.write(memory_file, updated_memory)
100
-
101
- LOG.info "[Planning] Plan finalized for card #{card_id}: #{plan_file}"
102
- { success: true, plan_file: plan_file, tasks: tasks }
103
- end
104
-
105
- # Planning mode prompt — prepended to the core prompt.
106
- PROMPT_PLANNING_MODE = <<~PROMPT
107
- ## Planning Mode (ACTIVE)
108
-
109
- You are in **planning mode**. Your job is to gather requirements and break down the work into actionable tasks.
110
-
111
- ### Your Role
112
- - Ask clarifying questions to understand the problem, constraints, and desired outcome
113
- - Continue asking until you have a clear picture (don't rush to a plan)
114
- - Understand user intent naturally — "go ahead", "that's enough", "proceed" all mean the same thing
115
- - When you have enough information OR the user signals they're ready, generate the plan
116
-
117
- ### Question Guidelines
118
- - Ask specific, focused questions (not generic "anything else?")
119
- - Build on previous answers — reference what you've learned
120
- - Prioritize questions that would significantly change the approach
121
- - If you're 90% confident, proceed. If you're 60% confident, ask.
122
-
123
- ### When to Stop Asking
124
- The user will signal they're ready in natural language:
125
- - "go ahead", "proceed", "that's enough", "looks good", "yeah do it"
126
- - "I think you have enough", "start working", "make the plan"
127
-
128
- You should also stop if:
129
- - You've asked 5+ questions and have a clear understanding
130
- - The user is getting impatient or frustrated
131
- - The remaining unknowns are minor details you can decide yourself
132
-
133
- ### Generating the Plan
134
- When ready, create a plan file at `{{PLAN_FILE}}` with this structure:
135
-
136
- ```markdown
137
- # Feature: [Title]
138
-
139
- ## Problem Statement
140
- [What we're solving and why]
141
-
142
- ## Requirements
143
- - Requirement 1
144
- - Requirement 2
145
- - Requirement 3
146
-
147
- ## Approach
148
- [High-level strategy and key decisions]
149
-
150
- ## Task Breakdown
151
- ### Task 1: [Clear, actionable title]
152
- - **Objective**: [What this task accomplishes]
153
- - **Approach**: [How to implement it]
154
- - **Demo**: [What "done" looks like]
155
-
156
- ### Task 2: [Clear, actionable title]
157
- - **Objective**: [What this task accomplishes]
158
- - **Approach**: [How to implement it]
159
- - **Demo**: [What "done" looks like]
160
-
161
- [Continue for all tasks...]
162
- ```
163
-
164
- ### Memory Management
165
- Log every question and answer to your memory file in this format:
166
-
167
- ```
168
- ## Planning Q&A
169
- Q: [Your question]
170
- A: [User's answer]
171
-
172
- Q: [Next question]
173
- A: [User's answer]
174
- ```
175
-
176
- Also track:
177
- - `planning_complete: false` (update to `true` when plan is generated)
178
- - Key decisions and constraints discovered
179
- - Any blockers or unknowns that remain
180
-
181
- ### After Planning
182
- Once you've written the plan file:
183
- 1. Update memory with `planning_complete: true`
184
- 2. Post a comment summarizing the plan and linking to the file
185
- 3. The system will automatically create Fizzy steps from your task breakdown
186
-
187
- ### Important
188
- - You are READ-ONLY during planning — no code changes, no commits
189
- - Focus on understanding the problem, not solving it yet
190
- - The plan should be detailed enough that any agent could execute it
191
- - Task titles should be clear and actionable (they become Fizzy step names)
192
-
193
- PROMPT
194
-
195
- # Render planning mode prompt with appropriate channel rules.
196
- def render_planning_prompt(situation_template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :fizzy,
197
- board_key: nil)
198
- result = ""
199
- result += "#{brain_context}\n" unless brain_context.empty?
200
- result += card_context unless card_context.empty?
201
- result += PROMPT_CORE
202
-
203
- # Add planning mode instructions BEFORE channel rules
204
- plan_file = File.join(PLANS_DIR, "card-#{vars["CARD_ID"]}-plan.md")
205
- planning_vars = vars.merge("PLAN_FILE" => plan_file)
206
- result += PROMPT_PLANNING_MODE.gsub("{{PLAN_FILE}}", plan_file)
207
-
208
- result += CHANNEL_PROMPTS.fetch(channel, PROMPT_FIZZY_CHANNEL)
209
- result += situation_template
210
- result += PROMPT_REFLECTION
211
-
212
- planning_vars["KNOWLEDGE_DIR"] ||= KNOWLEDGE_DIR
213
- planning_vars["MEMORY_DIR"] ||= memory_dir_for(agent_name)
214
- planning_vars["PERSONA_DIR"] ||= persona_dir_for(agent_name)
215
- planning_vars["PERSONA_COLLECTION"] ||= persona_collection_for(agent_name)
216
- planning_vars["AGENT_NAME"] ||= agent_name
217
-
218
- # Populate column IDs from board config, falling back to defaults
219
- DEFAULT_COLUMN_IDS.each do |col_name, default_id|
220
- var_name = "#{col_name.upcase}_COLUMN_ID"
221
- planning_vars[var_name] ||= (board_key && board_column_id(board_key, col_name)) || default_id
222
- end
223
-
224
- # Touch memory file if CARD_ID is present — ensures file exists before agent tries to read it
225
- if vars["CARD_ID"]
226
- memory_file = File.join(planning_vars["MEMORY_DIR"], "card-#{vars["CARD_ID"]}.md")
227
- FileUtils.mkdir_p(planning_vars["MEMORY_DIR"])
228
- FileUtils.touch(memory_file)
229
- end
230
-
231
- roster = agent_roster
232
- roster_lines = roster.map { |_key, display| " - @#{display}" }.join("\n")
233
- planning_vars["AGENT_ROSTER"] ||= roster_lines
234
-
235
- planning_vars.each { |key, val| result.gsub!("{{#{key}}}", val.to_s) }
236
- result
237
- end
data/lib/user_registry.rb DELETED
@@ -1,159 +0,0 @@
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/menubar.rb DELETED
@@ -1,295 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Brainiac macOS Menu Bar Plugin (xbar/SwiftBar)
5
- # Reads from monitor daemon socket and outputs xbar-format text
6
- # Mirrors monitor/waybar.rb patterns for macOS-native display
7
-
8
- require "json"
9
- require "net/http"
10
- require "shellwords"
11
- require "socket"
12
- require "time"
13
- require "uri"
14
-
15
- SERVER_URL = "http://localhost:4567"
16
- SOCKET_PATH = "/tmp/brainiac-monitor.sock"
17
- CONFIG_PATH = File.expand_path("~/.brainiac/waybar.json")
18
- DEFAULT_EMOJI = "❓"
19
- SELF_PATH = File.realpath(__FILE__)
20
-
21
- def load_config
22
- JSON.parse(File.read(CONFIG_PATH))
23
- rescue StandardError => e
24
- warn "Failed to load waybar.json: #{e.message}"
25
- {}
26
- end
27
-
28
- CONFIG = load_config.freeze
29
-
30
- def load_agent_config
31
- agents = {}
32
- (CONFIG["agents"] || []).each do |agent|
33
- agents[agent["name"].downcase] = { emoji: agent["emoji"], color: agent["color"] }
34
- end
35
- agents
36
- end
37
-
38
- AGENTS = load_agent_config.freeze
39
- FIZZY_ACCOUNT_ID = CONFIG["fizzy_account_id"]
40
- DISCORD_GUILD_ID = CONFIG["discord_guild_id"]
41
-
42
- def fetch_state
43
- socket = UNIXSocket.new(SOCKET_PATH)
44
- data = socket.read
45
- socket.close
46
- JSON.parse(data)
47
- rescue Errno::ENOENT
48
- { "sessions" => [], "count" => 0, "recent" => [], "error" => "daemon not running" }
49
- rescue StandardError => e
50
- { "sessions" => [], "count" => 0, "recent" => [], "error" => e.message }
51
- end
52
-
53
- def fetch_deployments
54
- uri = URI("#{SERVER_URL}/api/deployments")
55
- response = Net::HTTP.get_response(uri)
56
- JSON.parse(response.body)["deployments"] || []
57
- rescue StandardError
58
- nil
59
- end
60
-
61
- DEPLOY_RECENT_WINDOW = 30 * 60
62
-
63
- def deploy_dot(dep)
64
- status = dep["last_deploy_status"]
65
- if status == "deploying"
66
- "🟠"
67
- elsif status == "failed"
68
- "💥"
69
- elsif dep["status"] == "occupied"
70
- deploy_time = dep["last_deploy_at"] || dep["deployed_at"]
71
- recent = deploy_time && (Time.now - Time.parse(deploy_time)) < DEPLOY_RECENT_WINDOW
72
- recent ? "🚀" : "🔴"
73
- else
74
- "🟢"
75
- end
76
- end
77
-
78
- LOG_VIEWER_PATH = File.join(File.dirname(SELF_PATH), "view-logs-macos.rb")
79
- DEPLOY_SCRIPT_PATH = File.join(File.dirname(SELF_PATH), "deploy-env-macos.rb")
80
-
81
- ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/
82
- LOG_PREVIEW_LINES = 15
83
- LOG_LINE_MAX = 80
84
- LOG_FONT = "SFMono-Regular"
85
- LOG_SIZE = 12
86
-
87
- def tail_log(log_file, lines: LOG_PREVIEW_LINES)
88
- return [] unless log_file && File.exist?(log_file)
89
-
90
- raw = `tail -n 50 #{log_file.shellescape} 2>/dev/null`
91
- raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
92
- .lines
93
- .map { |l| l.gsub(ANSI_REGEX, "").gsub(/[^[:print:]\t]/, "").strip }
94
- .reject(&:empty?)
95
- .last(lines)
96
- rescue StandardError
97
- []
98
- end
99
-
100
- def format_log_line(text)
101
- text.length > LOG_LINE_MAX ? "#{text[0, LOG_LINE_MAX]}…" : text
102
- end
103
-
104
- def format_elapsed(seconds)
105
- return "#{seconds}s" if seconds < 60
106
-
107
- minutes = seconds / 60
108
- return "#{minutes}m" if minutes < 60
109
-
110
- "#{minutes / 60}h"
111
- end
112
-
113
- def format_context(card_key)
114
- return "" unless card_key
115
-
116
- if card_key.start_with?("discord-")
117
- "Discord"
118
- elsif card_key.start_with?("card-")
119
- "##{card_key.split("-")[1]}"
120
- else
121
- card_key
122
- end
123
- end
124
-
125
- def time_ago(iso_string)
126
- return nil unless iso_string
127
-
128
- seconds = (Time.now - Time.parse(iso_string)).to_i
129
- "#{format_elapsed(seconds)} ago"
130
- rescue StandardError
131
- nil
132
- end
133
-
134
- def log_action(log_file)
135
- return "" unless log_file
136
-
137
- " | shell=#{LOG_VIEWER_PATH} param1=#{log_file} terminal=false refresh=false"
138
- end
139
-
140
- OPEN_SCRIPT = File.join(File.dirname(SELF_PATH), "open-action.sh")
141
-
142
- def full_log_action(log_file)
143
- return "" unless log_file
144
-
145
- " | shell=#{OPEN_SCRIPT} param1=#{log_file.shellescape} terminal=false refresh=false"
146
- end
147
-
148
- def prompt_url(card_key)
149
- return nil unless card_key
150
-
151
- if card_key.start_with?("card-")
152
- card_num = card_key.split("-")[1]
153
- "https://app.fizzy.do/#{FIZZY_ACCOUNT_ID}/cards/#{card_num}" if FIZZY_ACCOUNT_ID && card_num
154
- elsif card_key.start_with?("discord-") && DISCORD_GUILD_ID
155
- parts = card_key.split("-")
156
- # discord-AGENT-CHANNEL_ID-MESSAGE_ID (agent name may contain hyphens, IDs are last two numeric parts)
157
- channel_id = parts[-2]
158
- message_id = parts[-1]
159
- "https://discord.com/channels/#{DISCORD_GUILD_ID}/#{channel_id}/#{message_id}" if channel_id && message_id
160
- end
161
- end
162
-
163
- def prompt_action(card_key)
164
- url = prompt_url(card_key)
165
- return "" unless url
166
-
167
- " | shell=#{OPEN_SCRIPT} param1=#{url} terminal=false refresh=false"
168
- end
169
-
170
- def worktree_path(log_file, card_key)
171
- return nil unless log_file && card_key&.start_with?("card-")
172
-
173
- dir = File.dirname(log_file, 2)
174
- dir if File.directory?(dir) && dir != "/"
175
- end
176
-
177
- def worktree_action(log_file, card_key)
178
- path = worktree_path(log_file, card_key)
179
- return "" unless path
180
-
181
- " | shell=#{OPEN_SCRIPT} param1=#{path.shellescape} terminal=false refresh=false"
182
- end
183
-
184
- COLOR_MAP = {
185
- "red" => "#ff5555", "green" => "#50fa7b", "blue" => "#8be9fd",
186
- "yellow" => "#f1fa8c", "cyan" => "#8be9fd", "magenta" => "#ff79c6",
187
- "purple" => "#bd93f9", "pink" => "#ff79c6", "white" => "#f8f8f2"
188
- }.freeze
189
-
190
- def hex_color(name)
191
- COLOR_MAP[name] || name
192
- end
193
-
194
- def generate_output
195
- state = fetch_state
196
- deployments = fetch_deployments
197
-
198
- return ["⚠️", "---", state["error"], "---", "Refresh | refresh=true"].join("\n") if state["error"] && !deployments
199
-
200
- sessions = state["sessions"] || []
201
- recent = state["recent"] || []
202
- lines = []
203
-
204
- # Title line — agent emojis + deploy dots
205
- parts = []
206
- parts << sessions.map { |s| AGENTS.dig(s["agent"]&.downcase, :emoji) || DEFAULT_EMOJI }.join(" ") if sessions.any?
207
- parts << deployments.map { |d| deploy_dot(d) }.join if deployments&.any?
208
- title = parts.any? ? parts.join(" ") : "💤"
209
- lines << title
210
- lines << "---"
211
-
212
- # Active sessions
213
- if sessions.any?
214
- lines << "Active | size=12"
215
- sessions.each do |s|
216
- agent_key = (s["agent"] || "").downcase
217
- emoji = AGENTS.dig(agent_key, :emoji) || DEFAULT_EMOJI
218
- color = AGENTS.dig(agent_key, :color)
219
- color_str = color ? " color=#{hex_color(color)}" : ""
220
- context = format_context(s["card_key"])
221
- elapsed = format_elapsed(s["elapsed_seconds"] || 0)
222
- lines << "#{emoji} #{s["agent"]}: #{context} (#{elapsed}) |#{color_str}"
223
-
224
- tail_log(s["log_file"]).each do |line|
225
- lines << "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
226
- end
227
- lines << "-- ---" if s["log_file"]
228
- lines << "-- Tail Log#{log_action(s["log_file"])}" if s["log_file"]
229
- lines << "-- View Full Log#{full_log_action(s["log_file"])}" if s["log_file"]
230
- lines << "-- Open Prompt#{prompt_action(s["card_key"])}" unless prompt_url(s["card_key"]).nil?
231
- wt = worktree_path(s["log_file"], s["card_key"])
232
- lines << "-- Open Worktree#{worktree_action(s["log_file"], s["card_key"])}" if wt
233
- end
234
- else
235
- lines << "No active sessions | size=12"
236
- end
237
-
238
- # Recent completed sessions
239
- if recent.any?
240
- lines << "---"
241
- lines << "Recent | size=12"
242
- recent.each do |s|
243
- agent_key = (s["agent"] || "").downcase
244
- emoji = AGENTS.dig(agent_key, :emoji) || DEFAULT_EMOJI
245
- context = format_context(s["card_key"])
246
- ago = time_ago(s["finished_at"]) || "?"
247
- lines << "#{emoji} #{s["agent"]}: #{context} — #{ago}"
248
-
249
- tail_log(s["log_file"]).each do |line|
250
- lines << "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
251
- end
252
- lines << "-- ---" if s["log_file"]
253
- lines << "-- Tail Log#{log_action(s["log_file"])}" if s["log_file"]
254
- lines << "-- View Full Log#{full_log_action(s["log_file"])}" if s["log_file"]
255
- lines << "-- Open Prompt#{prompt_action(s["card_key"])}" unless prompt_url(s["card_key"]).nil?
256
- wt = worktree_path(s["log_file"], s["card_key"])
257
- lines << "-- Open Worktree#{worktree_action(s["log_file"], s["card_key"])}" if wt
258
- end
259
- end
260
-
261
- # Deployments
262
- if deployments&.any?
263
- lines << "---"
264
- lines << "Deployments | size=12"
265
- deployments.each do |d|
266
- label = d["label"] || d["env"]
267
- env = d["env"]
268
- dot = deploy_dot(d)
269
- if d["status"] == "occupied"
270
- card = d["card_number"] ? "##{d["card_number"]}" : d["branch"] || "unknown"
271
- ago = time_ago(d["deployed_at"])
272
- status_label = case d["last_deploy_status"]
273
- when "deploying" then " — deploying…"
274
- when "failed" then " — FAILED"
275
- else ""
276
- end
277
- line = "#{dot} #{label}: #{card}#{status_label}#{" (#{ago})" if ago}"
278
- url = d["url"]
279
- lines << (url ? "#{line} | href=#{url}" : line)
280
- else
281
- ago = time_ago(d["cleared_at"])
282
- last = d["last_card"] ? " (was ##{d["last_card"]})" : ""
283
- lines << "#{dot} #{label}: Available#{" #{ago}" if ago}#{last}"
284
- end
285
- lines << "-- Deploy to #{label} | shell=#{DEPLOY_SCRIPT_PATH} param1=#{env} terminal=false refresh=true"
286
- lines << "-- Open #{label} | shell=#{OPEN_SCRIPT} param1=#{d["url"]} terminal=false refresh=false" if d["status"] == "occupied" && d["url"]
287
- end
288
- end
289
-
290
- lines << "---"
291
- lines << "Refresh | refresh=true"
292
- lines.join("\n")
293
- end
294
-
295
- puts generate_output