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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +136 -6
- data/bin/brainiac +472 -44
- data/bin/brainiac-completion.bash +1 -1
- data/lib/brainiac/agents.rb +27 -74
- data/lib/brainiac/brain.rb +6 -6
- data/lib/brainiac/config.rb +40 -76
- 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/github.rb +134 -317
- data/lib/brainiac/handlers/shared/git.rb +190 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
- data/lib/brainiac/handlers/zoho.rb +103 -153
- data/lib/brainiac/helpers.rb +43 -455
- data/lib/brainiac/hooks.rb +86 -0
- data/lib/brainiac/plugins.rb +154 -0
- data/lib/brainiac/prompts.rb +34 -172
- data/lib/brainiac/restart.rb +112 -0
- data/lib/brainiac/routes/api.rb +411 -0
- data/lib/brainiac/users.rb +1 -7
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac/zoho_mail_api.rb +2 -1
- data/lib/brainiac.rb +8 -1
- data/monitor/daemon.rb +4 -27
- data/monitor/shared.rb +247 -0
- data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
- 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 +149 -0
- data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
- data/receiver.rb +44 -551
- data/templates/agents.json.example +1 -2
- data/templates/brainiac.json.example +8 -0
- data/templates/cli-providers/kiro.json.example +8 -2
- data/templates/plugins.json.example +3 -0
- data/templates/users.json.example +0 -3
- metadata +25 -23
- data/lib/brainiac/card_index.rb +0 -389
- data/lib/brainiac/deployments.rb +0 -258
- data/lib/brainiac/handlers/fizzy.rb +0 -1292
- data/lib/brainiac/planning.rb +0 -237
- 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/templates/fizzy.json.example +0 -24
- /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
DELETED
|
@@ -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
|