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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared git operations: worktree lifecycle, branching, fetching, cleanup.
|
|
4
|
+
#
|
|
5
|
+
# All git-related utilities live here. Handlers call these instead of
|
|
6
|
+
# reimplementing git worktree/branch logic.
|
|
7
|
+
|
|
8
|
+
# Debounced repo git fetch — avoids fetching the same repo multiple times within a short window.
|
|
9
|
+
REPO_LAST_FETCH = {}
|
|
10
|
+
REPO_FETCH_DEBOUNCE = 300 # 5 minutes
|
|
11
|
+
|
|
12
|
+
def debounced_repo_fetch(repo_path)
|
|
13
|
+
last = REPO_LAST_FETCH[repo_path]
|
|
14
|
+
if last && (Time.now - last) < REPO_FETCH_DEBOUNCE
|
|
15
|
+
LOG.info "Skipping git fetch for #{repo_path} — fetched #{(Time.now - last).to_i}s ago"
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
run_cmd("git", "fetch", "origin", chdir: repo_path)
|
|
20
|
+
REPO_LAST_FETCH[repo_path] = Time.now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get_default_branch(repo_path)
|
|
24
|
+
default_branch = run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: repo_path).strip
|
|
25
|
+
begin
|
|
26
|
+
run_cmd("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD", chdir: repo_path).strip.sub("origin/", "")
|
|
27
|
+
rescue StandardError
|
|
28
|
+
default_branch
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Capture git HEAD and working tree status for a directory.
|
|
33
|
+
def capture_git_state(chdir)
|
|
34
|
+
head, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
|
|
35
|
+
status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
|
|
36
|
+
[head.strip, status.strip]
|
|
37
|
+
rescue StandardError
|
|
38
|
+
[nil, nil]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Trust the version manager config in a directory (supports mise and asdf)
|
|
42
|
+
def trust_version_manager(path, chdir:)
|
|
43
|
+
if system("which mise >/dev/null 2>&1")
|
|
44
|
+
run_cmd("mise", "trust", path, chdir: chdir)
|
|
45
|
+
elsif system("which asdf >/dev/null 2>&1")
|
|
46
|
+
LOG.info "asdf detected — no explicit trust needed for #{path}"
|
|
47
|
+
else
|
|
48
|
+
LOG.info "No version manager (mise/asdf) found — skipping trust for #{path}"
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
LOG.warn "Could not trust version manager in #{path}: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Copy gitignored files matching .worktreeinclude patterns from repo to worktree.
|
|
55
|
+
# Symlink directories matching .worktreelink patterns instead of copying.
|
|
56
|
+
def apply_worktree_includes(repo_path, worktree_path)
|
|
57
|
+
copied = 0
|
|
58
|
+
linked = 0
|
|
59
|
+
|
|
60
|
+
[".worktreeinclude", ".worktreelink"].each do |filename|
|
|
61
|
+
config_file = File.join(repo_path, filename)
|
|
62
|
+
next unless File.exist?(config_file)
|
|
63
|
+
|
|
64
|
+
symlink_mode = filename == ".worktreelink"
|
|
65
|
+
patterns = File.readlines(config_file).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
|
|
66
|
+
next if patterns.empty?
|
|
67
|
+
|
|
68
|
+
patterns.each do |pattern|
|
|
69
|
+
Dir.glob(pattern, File::FNM_DOTMATCH, base: repo_path).each do |match|
|
|
70
|
+
src = File.join(repo_path, match)
|
|
71
|
+
dest = File.join(worktree_path, match)
|
|
72
|
+
next if File.exist?(dest) || File.symlink?(dest)
|
|
73
|
+
|
|
74
|
+
_, _, st = Open3.capture3("git", "check-ignore", "-q", match, chdir: repo_path)
|
|
75
|
+
next unless st.success?
|
|
76
|
+
|
|
77
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
78
|
+
|
|
79
|
+
if symlink_mode && File.directory?(src)
|
|
80
|
+
FileUtils.ln_s(src, dest)
|
|
81
|
+
linked += 1
|
|
82
|
+
LOG.info "Symlinked #{match} from main repo"
|
|
83
|
+
elsif File.file?(src)
|
|
84
|
+
FileUtils.cp(src, dest)
|
|
85
|
+
copied += 1
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
LOG.info "Worktree include: copied #{copied} file(s), symlinked #{linked} dir(s) for #{worktree_path}" if copied.positive? || linked.positive?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Run a project-level hook script from .brainiac/<hook_name> if it exists.
|
|
95
|
+
def run_project_hook(repo_path, hook_name, extra_env: {})
|
|
96
|
+
hook = File.join(repo_path, ".brainiac", hook_name)
|
|
97
|
+
return unless File.exist?(hook)
|
|
98
|
+
|
|
99
|
+
env = { "REPO_PATH" => repo_path }.merge(extra_env)
|
|
100
|
+
LOG.info "Running .brainiac/#{hook_name} hook for #{repo_path}"
|
|
101
|
+
output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path)
|
|
102
|
+
if status.success?
|
|
103
|
+
LOG.info ".brainiac/#{hook_name} completed successfully"
|
|
104
|
+
else
|
|
105
|
+
LOG.warn ".brainiac/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Create or reuse a git worktree for a given branch.
|
|
110
|
+
# Returns the worktree path on success.
|
|
111
|
+
def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil)
|
|
112
|
+
worktree_path ||= File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
|
|
113
|
+
base_ref ||= "origin/#{get_default_branch(repo_path)}"
|
|
114
|
+
|
|
115
|
+
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
116
|
+
|
|
117
|
+
if File.directory?(worktree_path)
|
|
118
|
+
is_tracked = worktree_list.include?(worktree_path)
|
|
119
|
+
|
|
120
|
+
if is_tracked
|
|
121
|
+
LOG.info "Worktree directory #{worktree_path} is tracked by git"
|
|
122
|
+
else
|
|
123
|
+
LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
|
|
124
|
+
begin
|
|
125
|
+
FileUtils.rm_rf(worktree_path)
|
|
126
|
+
LOG.info "Successfully removed orphaned directory"
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
LOG.error "Failed to remove orphaned directory: #{e.message}"
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
|
|
135
|
+
|
|
136
|
+
if branch_exists
|
|
137
|
+
LOG.info "Branch #{branch} already exists, checking for existing worktree"
|
|
138
|
+
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
139
|
+
has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
|
|
140
|
+
|
|
141
|
+
if has_worktree && File.directory?(worktree_path)
|
|
142
|
+
LOG.info "Reusing existing worktree at #{worktree_path}"
|
|
143
|
+
else
|
|
144
|
+
LOG.info "Creating worktree from existing branch #{branch}"
|
|
145
|
+
run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
LOG.info "Creating new branch #{branch} and worktree"
|
|
149
|
+
run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, chdir: repo_path)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
trust_version_manager(worktree_path, chdir: worktree_path)
|
|
153
|
+
apply_worktree_includes(repo_path, worktree_path)
|
|
154
|
+
run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path })
|
|
155
|
+
|
|
156
|
+
worktree_path
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Clean up all worktrees associated with a work item (primary + cross-agent review).
|
|
160
|
+
# Safe: skips worktrees with uncommitted changes.
|
|
161
|
+
def cleanup_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
|
|
162
|
+
return unless card_number
|
|
163
|
+
|
|
164
|
+
repo_dir = File.dirname(repo_path)
|
|
165
|
+
repo_base = File.basename(repo_path)
|
|
166
|
+
cleaned = 0
|
|
167
|
+
|
|
168
|
+
# Find worktrees that contain the work item number in their name (any naming convention)
|
|
169
|
+
candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*#{card_number}*")).select { |d| File.directory?(d) }
|
|
170
|
+
candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
|
|
171
|
+
|
|
172
|
+
candidates.uniq.each do |wt_path|
|
|
173
|
+
status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path)
|
|
174
|
+
if status_output.strip.empty?
|
|
175
|
+
branch_name = File.basename(wt_path).sub("#{repo_base}--", "")
|
|
176
|
+
begin
|
|
177
|
+
run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path)
|
|
178
|
+
run_cmd("git", "branch", "-D", branch_name, chdir: repo_path)
|
|
179
|
+
cleaned += 1
|
|
180
|
+
LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})"
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
LOG.warn "Failed to clean up worktree #{wt_path}: #{e.message}"
|
|
183
|
+
end
|
|
184
|
+
else
|
|
185
|
+
LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive?
|
|
190
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared inline tag parsing for handler messages.
|
|
4
|
+
#
|
|
5
|
+
# Messages from any channel can contain inline tags like:
|
|
6
|
+
# [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan]
|
|
7
|
+
#
|
|
8
|
+
# This module provides a single parser that extracts all tags and returns
|
|
9
|
+
# a structured result with the cleaned text.
|
|
10
|
+
|
|
11
|
+
# Parse inline tags from message text.
|
|
12
|
+
# Returns a hash:
|
|
13
|
+
# {
|
|
14
|
+
# project: "my-project" or nil,
|
|
15
|
+
# model_tag: "opus" or nil (raw tag, not resolved model ID),
|
|
16
|
+
# effort: "high" or nil,
|
|
17
|
+
# cli_provider: "grok" or nil,
|
|
18
|
+
# chat_mode: true/false,
|
|
19
|
+
# planning: true/false,
|
|
20
|
+
# deploy_intent: "dev01" / :auto / nil,
|
|
21
|
+
# worktree_override: "branch-name" or nil,
|
|
22
|
+
# clean_text: "the message with all tags stripped"
|
|
23
|
+
# }
|
|
24
|
+
def parse_inline_tags(text)
|
|
25
|
+
result = {
|
|
26
|
+
project: nil,
|
|
27
|
+
model_tag: nil,
|
|
28
|
+
effort: nil,
|
|
29
|
+
cli_provider: nil,
|
|
30
|
+
chat_mode: false,
|
|
31
|
+
planning: false,
|
|
32
|
+
deploy_intent: nil,
|
|
33
|
+
worktree_override: nil,
|
|
34
|
+
clean_text: text.dup
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# [project:my-project]
|
|
38
|
+
if (match = result[:clean_text].match(/\[project:(\S+)\]/i))
|
|
39
|
+
result[:project] = match[1]
|
|
40
|
+
result[:clean_text].sub!(match[0], "")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# [effort:high]
|
|
44
|
+
if (match = result[:clean_text].match(/\[effort:(\w+)\]/i))
|
|
45
|
+
result[:effort] = match[1].downcase
|
|
46
|
+
result[:clean_text].sub!(match[0], "")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# [cli:grok]
|
|
50
|
+
if (match = result[:clean_text].match(/\[cli:(\w+)\]/i))
|
|
51
|
+
result[:cli_provider] = match[1].downcase
|
|
52
|
+
result[:clean_text].sub!(match[0], "")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# [chat], [question], [?]
|
|
56
|
+
if result[:clean_text].match?(/\[(chat|question|\?)\]/i)
|
|
57
|
+
result[:chat_mode] = true
|
|
58
|
+
result[:clean_text].sub!(/\[(chat|question|\?)\]/i, "")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# [plan]
|
|
62
|
+
if result[:clean_text].match?(/\[plan\]/i)
|
|
63
|
+
result[:planning] = true
|
|
64
|
+
result[:clean_text].sub!(/\[plan\]/i, "")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# [deploy] or [deploy:dev01]
|
|
68
|
+
if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
|
|
69
|
+
result[:deploy_intent] = match[1]&.strip&.downcase || :auto
|
|
70
|
+
result[:clean_text].sub!(match[0], "")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# [worktree:branch-name]
|
|
74
|
+
if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
|
|
75
|
+
result[:worktree_override] = match[1].strip
|
|
76
|
+
result[:clean_text].sub!(match[0], "")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Model tag: any remaining [word] that isn't a known tag — detected separately
|
|
80
|
+
# because it depends on the project's allowed_models config. We just capture
|
|
81
|
+
# the raw match here for the caller to resolve.
|
|
82
|
+
if (match = result[:clean_text].match(/\[(\w+)\]/))
|
|
83
|
+
result[:model_tag] = match[1].downcase
|
|
84
|
+
result[:clean_text].sub!(match[0], "")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
result[:clean_text].strip!
|
|
88
|
+
result
|
|
89
|
+
end
|
|
@@ -86,28 +86,34 @@ end
|
|
|
86
86
|
|
|
87
87
|
# Match an email against configured rules. Returns the first matching rule or nil.
|
|
88
88
|
# If no rules match, returns a fallback rule (if configured) so nothing is missed.
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
body = email["summary"].to_s + email["html"].to_s
|
|
104
|
-
matches = false unless body.downcase.include?(rule["body_contains"].downcase)
|
|
105
|
-
end
|
|
106
|
-
matches = false if matches && zoho_email_excluded?(email, rule["exclude_words"])
|
|
89
|
+
def zoho_rule_matches?(rule, email)
|
|
90
|
+
return false if rule["enabled"] == false
|
|
91
|
+
|
|
92
|
+
%w[from_contains to_contains subject_contains].each do |field|
|
|
93
|
+
pattern = rule[field]
|
|
94
|
+
next if pattern.nil? || pattern.empty?
|
|
95
|
+
|
|
96
|
+
email_field = case field
|
|
97
|
+
when "from_contains" then email["fromAddress"]
|
|
98
|
+
when "to_contains" then email["toAddress"]
|
|
99
|
+
when "subject_contains" then email["subject"]
|
|
100
|
+
end
|
|
101
|
+
return false unless email_field.to_s.downcase.include?(pattern.downcase)
|
|
102
|
+
end
|
|
107
103
|
|
|
108
|
-
|
|
104
|
+
if rule["body_contains"] && !rule["body_contains"].empty?
|
|
105
|
+
body = "#{email["summary"]}#{email["html"]}"
|
|
106
|
+
return false unless body.downcase.include?(rule["body_contains"].downcase)
|
|
109
107
|
end
|
|
110
108
|
|
|
109
|
+
!zoho_email_excluded?(email, rule["exclude_words"])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def match_zoho_rule(email)
|
|
113
|
+
rules = ZOHO_CONFIG["rules"] || []
|
|
114
|
+
matched = rules.find { |rule| zoho_rule_matches?(rule, email) }
|
|
115
|
+
return matched if matched
|
|
116
|
+
|
|
111
117
|
# Fallback: post unmatched emails so nothing slips through
|
|
112
118
|
fallback = zoho_fallback_rule
|
|
113
119
|
return nil if fallback && zoho_email_excluded?(email, ZOHO_CONFIG.dig("fallback", "exclude_words"))
|
|
@@ -126,6 +132,23 @@ def zoho_fallback_rule
|
|
|
126
132
|
"notify_as" => fallback["notify_as"] }
|
|
127
133
|
end
|
|
128
134
|
|
|
135
|
+
# Extract body text from email payload for notification display.
|
|
136
|
+
def extract_zoho_body_text(email, rule)
|
|
137
|
+
body_text = email["summary"].to_s.strip
|
|
138
|
+
if body_text.empty?
|
|
139
|
+
raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
|
|
140
|
+
body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/\s+/, " ").strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
body_text = fetch_zoho_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"]
|
|
144
|
+
|
|
145
|
+
return nil if body_text.empty?
|
|
146
|
+
|
|
147
|
+
max_len = rule["show_body"] ? 1800 : 500
|
|
148
|
+
body_text = "#{body_text[0..max_len]}..." if body_text.length > max_len
|
|
149
|
+
body_text
|
|
150
|
+
end
|
|
151
|
+
|
|
129
152
|
# Format a Discord notification for a matched email.
|
|
130
153
|
def format_zoho_notification(email, rule)
|
|
131
154
|
label = rule["label"] || "Zoho Mail"
|
|
@@ -135,23 +158,8 @@ def format_zoho_notification(email, rule)
|
|
|
135
158
|
parts << "**From:** #{email["fromAddress"]}" if email["fromAddress"]
|
|
136
159
|
parts << "**To:** #{email["toAddress"]}" if email["toAddress"]
|
|
137
160
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if body_text.empty?
|
|
141
|
-
raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
|
|
142
|
-
body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/\s+/, " ").strip
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# If still empty and show_body requested, fetch via Zoho Mail API
|
|
146
|
-
body_text = fetch_zoho_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"]
|
|
147
|
-
|
|
148
|
-
if !body_text.empty? && rule["show_body"]
|
|
149
|
-
body_text = "#{body_text[0..1800]}..." if body_text.length > 1800
|
|
150
|
-
parts << "```\n#{body_text}\n```"
|
|
151
|
-
elsif !body_text.empty?
|
|
152
|
-
body_text = "#{body_text[0..500]}..." if body_text.length > 500
|
|
153
|
-
parts << "```\n#{body_text}\n```"
|
|
154
|
-
end
|
|
161
|
+
body_text = extract_zoho_body_text(email, rule)
|
|
162
|
+
parts << "```\n#{body_text}\n```" if body_text
|
|
155
163
|
|
|
156
164
|
parts.join("\n")
|
|
157
165
|
end
|
|
@@ -187,7 +195,7 @@ ZOHO_TRIAGE_DIR = File.join(BRAINIAC_DIR, "tmp", "zoho", "triage")
|
|
|
187
195
|
FileUtils.mkdir_p(ZOHO_TRIAGE_DIR)
|
|
188
196
|
|
|
189
197
|
ZOHO_TRIAGE_PROMPT = <<~PROMPT
|
|
190
|
-
You are triaging a support email. Decide whether this email needs a
|
|
198
|
+
You are triaging a support email. Decide whether this email needs a card or not.
|
|
191
199
|
|
|
192
200
|
## Email
|
|
193
201
|
**From:** {{FROM}}
|
|
@@ -226,7 +234,7 @@ ZOHO_TRIAGE_PROMPT = <<~PROMPT
|
|
|
226
234
|
"title": "Brief descriptive title for the card",
|
|
227
235
|
"description": "HTML description with relevant details from the email",
|
|
228
236
|
"project_tag": "project-tag-name or null",
|
|
229
|
-
"assign_to": "
|
|
237
|
+
"assign_to": "agent-name from the assignment rules above"
|
|
230
238
|
}
|
|
231
239
|
```
|
|
232
240
|
|
|
@@ -248,41 +256,22 @@ ZOHO_TRIAGE_PROMPT = <<~PROMPT
|
|
|
248
256
|
PROMPT
|
|
249
257
|
|
|
250
258
|
# Dispatch an agent to triage a support email. The agent decides whether to create
|
|
251
|
-
# a
|
|
259
|
+
# a card or just notify Discord.
|
|
252
260
|
def dispatch_zoho_triage(email, rule)
|
|
253
261
|
agent_name = rule["dispatch_agent"]
|
|
254
262
|
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
255
263
|
response_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.json")
|
|
256
264
|
log_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.log")
|
|
257
265
|
|
|
258
|
-
|
|
259
|
-
body = body[0..2000] if body.length > 2000
|
|
260
|
-
|
|
261
|
-
prompt = ZOHO_TRIAGE_PROMPT
|
|
262
|
-
.gsub("{{FROM}}", email["fromAddress"].to_s)
|
|
263
|
-
.gsub("{{TO}}", email["toAddress"].to_s)
|
|
264
|
-
.gsub("{{SUBJECT}}", email["subject"].to_s)
|
|
265
|
-
.gsub("{{BODY}}", body)
|
|
266
|
-
.gsub("{{PROJECT_TAGS}}", zoho_triage_project_tags)
|
|
267
|
-
.gsub("{{AGENT_ASSIGNMENT}}", zoho_triage_agent_assignment)
|
|
268
|
-
|
|
269
|
-
prompt += "\n\nWrite your JSON response to: #{response_file}\n"
|
|
270
|
-
|
|
271
|
-
prompt_file = File.join(ZOHO_TRIAGE_DIR, "triage-prompt-#{timestamp}.md")
|
|
272
|
-
File.write(prompt_file, prompt)
|
|
266
|
+
prompt_file = write_zoho_triage_prompt(email, response_file, timestamp)
|
|
273
267
|
|
|
274
268
|
agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
275
269
|
project_config = default_project_config
|
|
270
|
+
resolved = resolve_project_cli_config(project_config || DEFAULT_PROJECT)
|
|
276
271
|
|
|
277
|
-
|
|
278
|
-
agent_cli = resolved["agent_cli"] || "kiro-cli"
|
|
279
|
-
agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
|
|
280
|
-
resolved["agent_model_flag"] || "--model"
|
|
281
|
-
|
|
282
|
-
cmd = [agent_cli]
|
|
272
|
+
cmd = [resolved["agent_cli"]]
|
|
283
273
|
cmd.push("--agent", agent_key)
|
|
284
|
-
cmd.concat(agent_cli_args.split)
|
|
285
|
-
add_trust_tools!(cmd, agent_cli_args)
|
|
274
|
+
cmd.concat(resolved["agent_cli_args"].split)
|
|
286
275
|
|
|
287
276
|
spawn_env = {}
|
|
288
277
|
agent_env = agent_env_for(agent_name)
|
|
@@ -294,12 +283,32 @@ def dispatch_zoho_triage(email, rule)
|
|
|
294
283
|
LOG.info "[Zoho:Triage] Command: #{cmd.join(" ")}"
|
|
295
284
|
|
|
296
285
|
pid = spawn(spawn_env, *cmd,
|
|
297
|
-
chdir: work_dir,
|
|
298
|
-
|
|
299
|
-
out: [log_file, "w"],
|
|
300
|
-
err: %i[child out])
|
|
286
|
+
chdir: work_dir, in: prompt_file,
|
|
287
|
+
out: [log_file, "w"], err: %i[child out])
|
|
301
288
|
|
|
302
|
-
|
|
289
|
+
monitor_zoho_triage(pid, response_file, log_file, prompt_file, email, rule)
|
|
290
|
+
pid
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def write_zoho_triage_prompt(email, response_file, timestamp)
|
|
294
|
+
body = (email["summary"] || email["html"] || "").to_s.gsub(/\s+/, " ").strip
|
|
295
|
+
body = body[0..2000] if body.length > 2000
|
|
296
|
+
|
|
297
|
+
prompt = ZOHO_TRIAGE_PROMPT
|
|
298
|
+
.gsub("{{FROM}}", email["fromAddress"].to_s)
|
|
299
|
+
.gsub("{{TO}}", email["toAddress"].to_s)
|
|
300
|
+
.gsub("{{SUBJECT}}", email["subject"].to_s)
|
|
301
|
+
.gsub("{{BODY}}", body)
|
|
302
|
+
.gsub("{{PROJECT_TAGS}}", zoho_triage_project_tags)
|
|
303
|
+
.gsub("{{AGENT_ASSIGNMENT}}", zoho_triage_agent_assignment)
|
|
304
|
+
prompt += "\n\nWrite your JSON response to: #{response_file}\n"
|
|
305
|
+
|
|
306
|
+
prompt_file = File.join(ZOHO_TRIAGE_DIR, "triage-prompt-#{timestamp}.md")
|
|
307
|
+
File.write(prompt_file, prompt)
|
|
308
|
+
prompt_file
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def monitor_zoho_triage(pid, response_file, log_file, prompt_file, email, rule)
|
|
303
312
|
Thread.new do
|
|
304
313
|
Process.wait(pid)
|
|
305
314
|
exit_status = $CHILD_STATUS
|
|
@@ -313,14 +322,11 @@ def dispatch_zoho_triage(email, rule)
|
|
|
313
322
|
notify_zoho_match(email, rule)
|
|
314
323
|
end
|
|
315
324
|
|
|
316
|
-
# Cleanup prompt file after a delay
|
|
317
325
|
Thread.new do
|
|
318
326
|
sleep 300
|
|
319
327
|
FileUtils.rm_f(prompt_file)
|
|
320
328
|
end
|
|
321
329
|
end
|
|
322
|
-
|
|
323
|
-
pid
|
|
324
330
|
end
|
|
325
331
|
|
|
326
332
|
# Read the triage response — try the response file first, then extract from log
|
|
@@ -384,7 +390,7 @@ def execute_zoho_triage_decision(decision, email, rule)
|
|
|
384
390
|
end
|
|
385
391
|
end
|
|
386
392
|
|
|
387
|
-
# Create a
|
|
393
|
+
# Create a card from the triage decision
|
|
388
394
|
def create_zoho_triage_card(decision, email, channel_id, token)
|
|
389
395
|
board_id = ZOHO_CONFIG["triage_board_id"]
|
|
390
396
|
unless board_id
|
|
@@ -398,90 +404,34 @@ def create_zoho_triage_card(decision, email, channel_id, token)
|
|
|
398
404
|
tags = ["support"]
|
|
399
405
|
tags << decision["project_tag"] if decision["project_tag"]
|
|
400
406
|
|
|
401
|
-
#
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
assign_zoho_triage_card(card_number, decision["assign_to"], spawn_env) if card_number && decision["assign_to"]
|
|
428
|
-
|
|
429
|
-
# Notify Discord
|
|
430
|
-
if channel_id && token
|
|
431
|
-
msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
|
|
432
|
-
msg += "**Title:** #{title}\n"
|
|
433
|
-
msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
|
|
434
|
-
msg += "**Tags:** #{tags.join(", ")}\n"
|
|
435
|
-
msg += "**From:** #{email["fromAddress"]}"
|
|
436
|
-
send_discord_message(channel_id, msg, token: token)
|
|
407
|
+
# Emit hook — work item plugin handles card creation (card management plugins)
|
|
408
|
+
results = Brainiac.emit(:create_work_item,
|
|
409
|
+
board_id: board_id,
|
|
410
|
+
title: title,
|
|
411
|
+
description: description,
|
|
412
|
+
tags: tags,
|
|
413
|
+
assign_to: decision["assign_to"])
|
|
414
|
+
|
|
415
|
+
card_info = results.compact.first
|
|
416
|
+
if card_info
|
|
417
|
+
card_number = card_info[:number]
|
|
418
|
+
card_url = card_info[:url]
|
|
419
|
+
LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
|
|
420
|
+
|
|
421
|
+
# Notify Discord
|
|
422
|
+
if channel_id && token
|
|
423
|
+
msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
|
|
424
|
+
msg += "**Title:** #{title}\n"
|
|
425
|
+
msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
|
|
426
|
+
msg += "**Tags:** #{tags.join(", ")}\n"
|
|
427
|
+
msg += "**From:** #{email["fromAddress"]}"
|
|
428
|
+
send_discord_message(channel_id, msg, token: token)
|
|
429
|
+
end
|
|
430
|
+
else
|
|
431
|
+
LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
|
|
432
|
+
notify_zoho_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" }.merge(rule_defaults(nil)))
|
|
437
433
|
end
|
|
438
434
|
rescue StandardError => e
|
|
439
435
|
LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
440
436
|
notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil)))
|
|
441
437
|
end
|
|
442
|
-
|
|
443
|
-
# Resolve tag names to IDs by querying Fizzy
|
|
444
|
-
def resolve_zoho_triage_tags(tag_names)
|
|
445
|
-
agent_env = agent_env_for("Threepio")
|
|
446
|
-
spawn_env = agent_env.empty? ? {} : agent_env
|
|
447
|
-
|
|
448
|
-
output, status = Open3.capture2e(spawn_env, "fizzy", "tag", "list", "--all")
|
|
449
|
-
return [] unless status.success?
|
|
450
|
-
|
|
451
|
-
all_tags = JSON.parse(output)["data"] || []
|
|
452
|
-
tag_names.filter_map do |name|
|
|
453
|
-
tag = all_tags.find { |t| t["title"].downcase == name.downcase }
|
|
454
|
-
tag&.dig("id")
|
|
455
|
-
end
|
|
456
|
-
rescue StandardError => e
|
|
457
|
-
LOG.warn "[Zoho:Triage] Failed to resolve tags: #{e.message}"
|
|
458
|
-
[]
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
# Assign a card to the appropriate agent
|
|
462
|
-
def assign_zoho_triage_card(card_number, agent_name, spawn_env)
|
|
463
|
-
# Map agent names to Fizzy user IDs
|
|
464
|
-
agent_user_ids = {
|
|
465
|
-
"Galen" => "03fja52opiykf0mua7aeqv8uk",
|
|
466
|
-
"Avon" => "03fnwe6kl4g2t8xw0djbfkv96",
|
|
467
|
-
"Sheogorath" => "03fnwjyt6gighy98ld46u2hni"
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
user_id = agent_user_ids[agent_name]
|
|
471
|
-
unless user_id
|
|
472
|
-
LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name}"
|
|
473
|
-
return
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
output, status = Open3.capture2e(spawn_env, "fizzy", "card", "assign", card_number.to_s, "--user", user_id)
|
|
477
|
-
if status.success?
|
|
478
|
-
LOG.info "[Zoho:Triage] Assigned card ##{card_number} to #{agent_name}"
|
|
479
|
-
else
|
|
480
|
-
LOG.warn "[Zoho:Triage] Failed to assign card ##{card_number}: #{output}"
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
def rule_defaults(rule)
|
|
485
|
-
{ "discord_channel_id" => rule&.dig("discord_channel_id") || ZOHO_CONFIG["default_discord_channel_id"],
|
|
486
|
-
"notify_as" => rule&.dig("notify_as") || ZOHO_CONFIG["notify_as"] }
|
|
487
|
-
end
|