earl-bot 0.1.0
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 +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +260 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/README.md +21 -0
- data/bin/ci +49 -0
- data/bin/claude-context +155 -0
- data/bin/claude-usage +110 -0
- data/bin/coverage +221 -0
- data/bin/rubocop +10 -0
- data/bin/watch-ci +198 -0
- data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
- data/config/earl-claude-home/.claude/settings.json +34 -0
- data/earl-bot.gemspec +42 -0
- data/exe/earl +51 -0
- data/exe/earl-install +129 -0
- data/exe/earl-permission-server +39 -0
- data/lib/earl/claude_session/stats.rb +76 -0
- data/lib/earl/claude_session.rb +468 -0
- data/lib/earl/command_executor/constants.rb +53 -0
- data/lib/earl/command_executor/heartbeat_display.rb +54 -0
- data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
- data/lib/earl/command_executor/session_handler.rb +126 -0
- data/lib/earl/command_executor/spawn_handler.rb +99 -0
- data/lib/earl/command_executor/stats_formatter.rb +66 -0
- data/lib/earl/command_executor/usage_handler.rb +132 -0
- data/lib/earl/command_executor.rb +128 -0
- data/lib/earl/command_parser.rb +57 -0
- data/lib/earl/config.rb +94 -0
- data/lib/earl/cron_parser.rb +105 -0
- data/lib/earl/formatting.rb +14 -0
- data/lib/earl/heartbeat_config.rb +101 -0
- data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
- data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
- data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
- data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
- data/lib/earl/heartbeat_scheduler.rb +131 -0
- data/lib/earl/logging.rb +12 -0
- data/lib/earl/mattermost/api_client.rb +85 -0
- data/lib/earl/mattermost.rb +261 -0
- data/lib/earl/mcp/approval_handler.rb +304 -0
- data/lib/earl/mcp/config.rb +62 -0
- data/lib/earl/mcp/github_pat_handler.rb +450 -0
- data/lib/earl/mcp/handler_base.rb +13 -0
- data/lib/earl/mcp/heartbeat_handler.rb +310 -0
- data/lib/earl/mcp/memory_handler.rb +89 -0
- data/lib/earl/mcp/server.rb +123 -0
- data/lib/earl/mcp/tmux_handler.rb +562 -0
- data/lib/earl/memory/prompt_builder.rb +40 -0
- data/lib/earl/memory/store.rb +125 -0
- data/lib/earl/message_queue.rb +56 -0
- data/lib/earl/permission_config.rb +22 -0
- data/lib/earl/question_handler/question_posting.rb +58 -0
- data/lib/earl/question_handler.rb +116 -0
- data/lib/earl/runner/idle_management.rb +44 -0
- data/lib/earl/runner/lifecycle.rb +73 -0
- data/lib/earl/runner/message_handling.rb +121 -0
- data/lib/earl/runner/reaction_handling.rb +42 -0
- data/lib/earl/runner/response_lifecycle.rb +96 -0
- data/lib/earl/runner/service_builder.rb +48 -0
- data/lib/earl/runner/startup.rb +73 -0
- data/lib/earl/runner/thread_context_builder.rb +43 -0
- data/lib/earl/runner.rb +70 -0
- data/lib/earl/safari_automation.rb +497 -0
- data/lib/earl/session_manager/persistence.rb +46 -0
- data/lib/earl/session_manager/session_creation.rb +108 -0
- data/lib/earl/session_manager.rb +92 -0
- data/lib/earl/session_store.rb +84 -0
- data/lib/earl/streaming_response.rb +219 -0
- data/lib/earl/tmux/parsing.rb +80 -0
- data/lib/earl/tmux/processes.rb +34 -0
- data/lib/earl/tmux/sessions.rb +41 -0
- data/lib/earl/tmux.rb +122 -0
- data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
- data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
- data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
- data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
- data/lib/earl/tmux_monitor.rb +249 -0
- data/lib/earl/tmux_session_store.rb +133 -0
- data/lib/earl/tool_input_formatter.rb +44 -0
- data/lib/earl/version.rb +5 -0
- data/lib/earl.rb +87 -0
- data/lib/tasks/.keep +1 -0
- metadata +248 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class CommandExecutor
|
|
5
|
+
# Handles tmux session commands: !sessions, !session <name> show/status/
|
|
6
|
+
# input/nudge/approve/deny/kill.
|
|
7
|
+
module SessionHandler
|
|
8
|
+
PANE_STATUS_LABELS = {
|
|
9
|
+
active: "\u{1F7E2} Active", permission: "\u{1F7E0} Waiting for permission", idle: "\u{1F7E1} Idle"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def handle_sessions(ctx)
|
|
15
|
+
tmux = @deps.tmux
|
|
16
|
+
return reply(ctx, ":x: tmux is not installed.") unless tmux.available?
|
|
17
|
+
|
|
18
|
+
panes = tmux.list_all_panes
|
|
19
|
+
return reply(ctx, "No tmux sessions running.") if panes.empty?
|
|
20
|
+
|
|
21
|
+
claude_panes = panes.select { |pane| tmux.claude_on_tty?(pane[:tty]) }
|
|
22
|
+
return reply(ctx, "No Claude sessions found across #{panes.size} tmux panes.") if claude_panes.empty?
|
|
23
|
+
|
|
24
|
+
reply(ctx, format_sessions_table(claude_panes))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def format_sessions_table(claude_panes)
|
|
28
|
+
rows = claude_panes.map { |pane| format_claude_pane_row(pane) }
|
|
29
|
+
header = [
|
|
30
|
+
"#### :computer: Claude Sessions (#{rows.size})",
|
|
31
|
+
"| Pane | Project | Status |", "|------|---------|--------|"
|
|
32
|
+
]
|
|
33
|
+
(header + rows).join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_claude_pane_row(pane)
|
|
37
|
+
target, path = pane.values_at(:target, :path)
|
|
38
|
+
project = File.basename(path)
|
|
39
|
+
status = detect_pane_status(target)
|
|
40
|
+
label = PANE_STATUS_LABELS.fetch(status, "\u{1F7E1} Idle")
|
|
41
|
+
"| `#{target}` | #{project} | #{label} |"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def detect_pane_status(target)
|
|
45
|
+
output = @deps.tmux.capture_pane(target, lines: 20)
|
|
46
|
+
return :permission if output.include?("Do you want to proceed?")
|
|
47
|
+
return :active if output.include?("esc to interrupt")
|
|
48
|
+
|
|
49
|
+
:idle
|
|
50
|
+
rescue Tmux::Error => error
|
|
51
|
+
log(:debug, "detect_pane_status failed for #{target}: #{error.message}")
|
|
52
|
+
:idle
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_session_show(ctx)
|
|
56
|
+
with_tmux_session(ctx) do
|
|
57
|
+
target = ctx.arg
|
|
58
|
+
output = @deps.tmux.capture_pane(target)
|
|
59
|
+
reply(ctx, "#### :computer: `#{target}` pane output\n```\n#{truncate_output(output)}\n```")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def handle_session_status(ctx)
|
|
64
|
+
with_tmux_session(ctx) do
|
|
65
|
+
target = ctx.arg
|
|
66
|
+
output = @deps.tmux.capture_pane(target, lines: 200)
|
|
67
|
+
truncated = truncate_output(output, 3000)
|
|
68
|
+
reply(ctx, "#### :mag: `#{target}` status\n```\n#{truncated}\n```\n_AI summary not yet implemented._")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_session_input(ctx)
|
|
73
|
+
with_tmux_session(ctx) do
|
|
74
|
+
target = ctx.arg
|
|
75
|
+
text = ctx.args[1]
|
|
76
|
+
@deps.tmux.send_keys(target, text)
|
|
77
|
+
reply(ctx, ":keyboard: Sent to `#{target}`: `#{text}`")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_session_nudge(ctx)
|
|
82
|
+
with_tmux_session(ctx) do
|
|
83
|
+
target = ctx.arg
|
|
84
|
+
@deps.tmux.send_keys(target, "Are you stuck? What's your current status?")
|
|
85
|
+
reply(ctx, ":wave: Nudged `#{target}`.")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def handle_session_approve(ctx) = send_tmux_key_action(ctx, "Enter", ":white_check_mark: Approved permission on")
|
|
90
|
+
def handle_session_deny(ctx) = send_tmux_key_action(ctx, "Escape", ":no_entry_sign: Denied permission on")
|
|
91
|
+
|
|
92
|
+
def handle_session_kill(ctx)
|
|
93
|
+
name = ctx.arg
|
|
94
|
+
store = @deps.tmux_store
|
|
95
|
+
@deps.tmux.kill_session(name)
|
|
96
|
+
store&.delete(name)
|
|
97
|
+
reply(ctx, ":skull: Tmux session `#{name}` killed.")
|
|
98
|
+
rescue Tmux::NotFound
|
|
99
|
+
store&.delete(name)
|
|
100
|
+
reply(ctx, ":x: Session `#{name}` not found (cleaned up store).")
|
|
101
|
+
rescue Tmux::Error => error
|
|
102
|
+
reply(ctx, ":x: Error killing session: #{error.message}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def with_tmux_session(ctx)
|
|
106
|
+
yield
|
|
107
|
+
rescue Tmux::NotFound
|
|
108
|
+
reply(ctx, ":x: Session `#{ctx.arg}` not found.")
|
|
109
|
+
rescue Tmux::Error => error
|
|
110
|
+
reply(ctx, ":x: Error: #{error.message}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def truncate_output(output, max_length = 3500)
|
|
114
|
+
output.length > max_length ? "\u2026#{output[-max_length..]}" : output
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def send_tmux_key_action(ctx, key, message_prefix)
|
|
118
|
+
with_tmux_session(ctx) do
|
|
119
|
+
target = ctx.arg
|
|
120
|
+
@deps.tmux.send_keys_raw(target, key)
|
|
121
|
+
reply(ctx, "#{message_prefix} `#{target}`.")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class CommandExecutor
|
|
5
|
+
# Handles the !spawn command — creates a new Claude session in tmux.
|
|
6
|
+
module SpawnHandler
|
|
7
|
+
# Bundles spawn parameters extracted from the command.
|
|
8
|
+
SpawnRequest = Struct.new(:name, :prompt, :working_dir, keyword_init: true) do
|
|
9
|
+
def to_session_info(ctx)
|
|
10
|
+
TmuxSessionStore::TmuxSessionInfo.new(
|
|
11
|
+
name: name, channel_id: ctx.channel_id, thread_id: ctx.thread_id,
|
|
12
|
+
working_dir: working_dir, prompt: prompt, created_at: Time.now.iso8601
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def handle_spawn(ctx)
|
|
20
|
+
prompt = ctx.arg
|
|
21
|
+
return reply(ctx, ':x: Usage: `!spawn "prompt" [--name N] [--dir D]`') if prompt.to_s.strip.empty?
|
|
22
|
+
|
|
23
|
+
req = build_spawn_request(prompt, ctx.args[1].to_s)
|
|
24
|
+
validate_and_spawn(ctx, req)
|
|
25
|
+
rescue Tmux::Error => error
|
|
26
|
+
reply(ctx, ":x: Failed to spawn session: #{error.message}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_spawn_request(prompt, flags_str)
|
|
30
|
+
parsed = parse_spawn_flags(flags_str)
|
|
31
|
+
name = parsed[:name] || generate_session_name
|
|
32
|
+
SpawnRequest.new(name: name, prompt: prompt, working_dir: parsed[:dir])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def generate_session_name
|
|
36
|
+
"earl-#{Time.now.strftime("%Y%m%d%H%M%S")}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_and_spawn(ctx, req)
|
|
40
|
+
name = req.name
|
|
41
|
+
return if spawn_name_invalid?(ctx, name)
|
|
42
|
+
return if spawn_dir_invalid?(ctx, req.working_dir)
|
|
43
|
+
return if spawn_name_taken?(ctx, name)
|
|
44
|
+
|
|
45
|
+
spawn_tmux_session(ctx, req)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def spawn_name_invalid?(ctx, name)
|
|
49
|
+
return false unless name.match?(/[.:]/)
|
|
50
|
+
|
|
51
|
+
reply(ctx, ":x: Invalid session name `#{name}`: cannot contain `.` or `:` (tmux reserved).")
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def spawn_dir_invalid?(ctx, working_dir)
|
|
56
|
+
return false unless working_dir
|
|
57
|
+
return false if Dir.exist?(working_dir)
|
|
58
|
+
|
|
59
|
+
reply(ctx, ":x: Directory not found: `#{working_dir}`")
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def spawn_name_taken?(ctx, name)
|
|
64
|
+
return false unless @deps.tmux.session_exists?(name)
|
|
65
|
+
|
|
66
|
+
reply(ctx, ":x: Session `#{name}` already exists.")
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def spawn_tmux_session(ctx, req)
|
|
71
|
+
name = req.name
|
|
72
|
+
command = "claude #{Shellwords.shellescape(req.prompt)}"
|
|
73
|
+
@deps.tmux.create_session(name: name, command: command, working_dir: req.working_dir)
|
|
74
|
+
persist_spawn_info(req.to_session_info(ctx))
|
|
75
|
+
reply(ctx, spawn_success_message(req))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def persist_spawn_info(info)
|
|
79
|
+
store = @deps.tmux_store
|
|
80
|
+
store&.save(info)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def spawn_success_message(req)
|
|
84
|
+
name = req.name
|
|
85
|
+
":rocket: Spawned tmux session `#{name}`\n" \
|
|
86
|
+
"- **Prompt:** #{req.prompt}\n" \
|
|
87
|
+
"- **Dir:** #{req.working_dir || Dir.pwd}\n" \
|
|
88
|
+
"Use `!session #{name}` to check output."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_spawn_flags(str)
|
|
92
|
+
{
|
|
93
|
+
dir: str[/--dir\s+(\S+)/, 1],
|
|
94
|
+
name: str[/--name\s+(\S+)/, 1]
|
|
95
|
+
}.compact
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class CommandExecutor
|
|
5
|
+
# Formats session statistics for the !stats command.
|
|
6
|
+
module StatsFormatter
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def format_stats(stats)
|
|
10
|
+
total_in = stats.total_input_tokens
|
|
11
|
+
total_out = stats.total_output_tokens
|
|
12
|
+
cost = stats.total_cost
|
|
13
|
+
header = ["#### :bar_chart: Session Stats", "| Metric | Value |", "|--------|-------|"]
|
|
14
|
+
rows = [format_token_row(total_in, total_out)]
|
|
15
|
+
append_optional_stats(rows, stats)
|
|
16
|
+
rows << format_cost_row(cost)
|
|
17
|
+
(header + rows).join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def append_optional_stats(lines, stats)
|
|
21
|
+
append_context_line(lines, stats)
|
|
22
|
+
append_model_line(lines, stats.model_id)
|
|
23
|
+
append_ttft_line(lines, stats.time_to_first_token)
|
|
24
|
+
append_speed_line(lines, stats.tokens_per_second)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def append_context_line(lines, stats)
|
|
28
|
+
pct = stats.context_percent
|
|
29
|
+
return unless pct
|
|
30
|
+
|
|
31
|
+
lines << "| **Context used** | #{format("%.1f%%", pct)} of #{format_number(stats.context_window)} |"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def append_model_line(lines, model)
|
|
35
|
+
lines << "| **Model** | `#{model}` |" if model
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def append_ttft_line(lines, ttft)
|
|
39
|
+
lines << "| **Last TTFT** | #{format("%.1fs", ttft)} |" if ttft
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def append_speed_line(lines, tps)
|
|
43
|
+
lines << "| **Last speed** | #{format("%.0f", tps)} tok/s |" if tps
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def format_persisted_stats(persisted)
|
|
47
|
+
total_in, total_out, cost = persisted.to_h.values_at(
|
|
48
|
+
:total_input_tokens, :total_output_tokens, :total_cost
|
|
49
|
+
)
|
|
50
|
+
token_row = format_token_row(total_in || 0, total_out || 0)
|
|
51
|
+
cost_row = format_cost_row(cost)
|
|
52
|
+
["#### :bar_chart: Session Stats (stopped)", "| Metric | Value |", "|--------|-------|",
|
|
53
|
+
token_row, cost_row].join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_token_row(total_in, total_out)
|
|
57
|
+
total = format_number(total_in + total_out)
|
|
58
|
+
"| **Total tokens** | #{total} (in: #{format_number(total_in)}, out: #{format_number(total_out)}) |"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_cost_row(cost)
|
|
62
|
+
"| **Cost** | $#{format("%.4f", cost)} |"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class CommandExecutor
|
|
5
|
+
# Handles !usage and !context commands — fetches external data
|
|
6
|
+
# via background threads and posts formatted results.
|
|
7
|
+
module UsageHandler
|
|
8
|
+
CONTEXT_CATEGORIES = {
|
|
9
|
+
"messages" => "Messages", "system_prompt" => "System prompt",
|
|
10
|
+
"system_tools" => "System tools", "custom_agents" => "Custom agents",
|
|
11
|
+
"memory_files" => "Memory files", "skills" => "Skills",
|
|
12
|
+
"free_space" => "Free space", "autocompact_buffer" => "Autocompact buffer"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def handle_usage(ctx)
|
|
18
|
+
reply(ctx, ":hourglass: Fetching usage data (takes ~15s)...")
|
|
19
|
+
run_usage_fetch(ctx)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_usage_fetch(ctx)
|
|
23
|
+
Thread.new { usage_fetch_body(ctx) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def usage_fetch_body(ctx)
|
|
27
|
+
data = fetch_usage_data
|
|
28
|
+
message = data ? format_usage(data) : ":x: Failed to fetch usage data."
|
|
29
|
+
reply(ctx, message)
|
|
30
|
+
rescue StandardError => error
|
|
31
|
+
msg = error.message
|
|
32
|
+
log(:error, "Usage command error: #{msg}")
|
|
33
|
+
reply(ctx, ":x: Error fetching usage: #{msg}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch_usage_data
|
|
37
|
+
output, status = Open3.capture2(USAGE_SCRIPT, "--json", err: File::NULL)
|
|
38
|
+
return nil unless status.success?
|
|
39
|
+
|
|
40
|
+
JSON.parse(output)
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_usage(data)
|
|
46
|
+
lines = ["#### :bar_chart: Claude Usage"]
|
|
47
|
+
append_usage_section(lines, data["session"], "Session")
|
|
48
|
+
append_usage_section(lines, data["week"], "Week")
|
|
49
|
+
append_usage_section(lines, data["sonnet_week"], "Sonnet")
|
|
50
|
+
append_usage_extra(lines, data["extra"])
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append_usage_section(lines, section, label)
|
|
55
|
+
return unless section&.dig("percent_used")
|
|
56
|
+
|
|
57
|
+
lines << "- **#{label}:** #{section["percent_used"]}% used \u2014 resets #{section["resets"]}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def append_usage_extra(lines, extra)
|
|
61
|
+
return unless extra&.dig("percent_used")
|
|
62
|
+
|
|
63
|
+
pct, spent, budget, resets = extra.values_at("percent_used", "spent", "budget", "resets")
|
|
64
|
+
lines << "- **Extra:** #{pct}% used (#{spent} / #{budget}) \u2014 resets #{resets}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_context(ctx)
|
|
68
|
+
sid = @deps.session_manager.claude_session_id_for(ctx.thread_id)
|
|
69
|
+
unless sid
|
|
70
|
+
reply(ctx, "No session found for this thread.")
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
reply(ctx, ":hourglass: Fetching context data (takes ~20s)...")
|
|
75
|
+
run_context_fetch(ctx, sid)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def run_context_fetch(ctx, sid)
|
|
79
|
+
Thread.new { context_fetch_body(ctx, sid) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def context_fetch_body(ctx, sid)
|
|
83
|
+
data = fetch_context_data(sid)
|
|
84
|
+
message = data ? format_context(data) : ":x: Failed to fetch context data."
|
|
85
|
+
reply(ctx, message)
|
|
86
|
+
rescue StandardError => error
|
|
87
|
+
msg = error.message
|
|
88
|
+
log(:error, "Context command error: #{msg}")
|
|
89
|
+
reply(ctx, ":x: Error fetching context: #{msg}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def fetch_context_data(session_id)
|
|
93
|
+
output, status = Open3.capture2(CONTEXT_SCRIPT, session_id, "--json", err: File::NULL) # nosemgrep
|
|
94
|
+
return nil unless status.success?
|
|
95
|
+
|
|
96
|
+
JSON.parse(output)
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def format_context(data)
|
|
102
|
+
model, used, total, pct, cats = data.values_at("model", "used_tokens", "total_tokens", "percent_used",
|
|
103
|
+
"categories")
|
|
104
|
+
lines = [
|
|
105
|
+
"#### :brain: Context Window Usage",
|
|
106
|
+
"- **Model:** `#{model}`",
|
|
107
|
+
"- **Used:** #{used} / #{total} tokens (#{pct})"
|
|
108
|
+
]
|
|
109
|
+
append_context_categories(lines, cats)
|
|
110
|
+
lines.join("\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def append_context_categories(lines, cats)
|
|
114
|
+
return unless cats
|
|
115
|
+
|
|
116
|
+
lines << ""
|
|
117
|
+
CONTEXT_CATEGORIES.each do |key, label|
|
|
118
|
+
append_category_line(lines, cats[key], label)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def append_category_line(lines, cat, label)
|
|
123
|
+
tokens = cat&.fetch("tokens", nil)
|
|
124
|
+
return unless tokens
|
|
125
|
+
|
|
126
|
+
pct_val = cat["percent"]
|
|
127
|
+
pct = pct_val ? " (#{pct_val})" : ""
|
|
128
|
+
lines << "- **#{label}:** #{tokens} tokens#{pct}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
require_relative "command_executor/constants"
|
|
7
|
+
require_relative "command_executor/lifecycle_handler"
|
|
8
|
+
require_relative "command_executor/session_handler"
|
|
9
|
+
require_relative "command_executor/spawn_handler"
|
|
10
|
+
require_relative "command_executor/stats_formatter"
|
|
11
|
+
require_relative "command_executor/usage_handler"
|
|
12
|
+
require_relative "command_executor/heartbeat_display"
|
|
13
|
+
|
|
14
|
+
module Earl
|
|
15
|
+
# Executes `!` commands parsed by CommandParser, dispatching to the
|
|
16
|
+
# appropriate session manager or mattermost action.
|
|
17
|
+
class CommandExecutor
|
|
18
|
+
include Logging
|
|
19
|
+
include Formatting
|
|
20
|
+
include Constants
|
|
21
|
+
include LifecycleHandler
|
|
22
|
+
include SessionHandler
|
|
23
|
+
include SpawnHandler
|
|
24
|
+
include StatsFormatter
|
|
25
|
+
include UsageHandler
|
|
26
|
+
include HeartbeatDisplay
|
|
27
|
+
|
|
28
|
+
# Bundles dispatch context so thread_id + channel_id don't travel as separate args.
|
|
29
|
+
CommandContext = Data.define(:thread_id, :channel_id, :arg, :args) do
|
|
30
|
+
def post_params(message)
|
|
31
|
+
{ channel_id: channel_id, message: message, root_id: thread_id }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Groups injected dependencies to keep ivar count low.
|
|
36
|
+
Deps = Struct.new(:session_manager, :mattermost, :config, :heartbeat_scheduler,
|
|
37
|
+
:tmux_store, :tmux, :runner, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
def initialize(session_manager:, mattermost:, config:, **extras)
|
|
40
|
+
heartbeat_scheduler, tmux_store, tmux_adapter, runner = extras.values_at(:heartbeat_scheduler, :tmux_store,
|
|
41
|
+
:tmux_adapter, :runner)
|
|
42
|
+
@deps = Deps.new(
|
|
43
|
+
session_manager: session_manager, mattermost: mattermost, config: config,
|
|
44
|
+
heartbeat_scheduler: heartbeat_scheduler, tmux_store: tmux_store, tmux: tmux_adapter || Tmux, runner: runner
|
|
45
|
+
)
|
|
46
|
+
@working_dirs = {} # thread_id -> path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns { passthrough: "/command" } for passthrough commands so the
|
|
50
|
+
# runner can route them through the normal message pipeline.
|
|
51
|
+
# Returns nil for all other commands (handled inline).
|
|
52
|
+
def execute(command, thread_id:, channel_id:)
|
|
53
|
+
cmd_name = command.name
|
|
54
|
+
slash = PASSTHROUGH_COMMANDS[cmd_name]
|
|
55
|
+
return { passthrough: slash } if slash
|
|
56
|
+
|
|
57
|
+
ctx = build_context(command, thread_id, channel_id)
|
|
58
|
+
dispatch_command(cmd_name, ctx)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def working_dir_for(thread_id)
|
|
63
|
+
@working_dirs[thread_id]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def dispatch_command(name, ctx)
|
|
69
|
+
handler = DISPATCH[name]
|
|
70
|
+
send(handler, ctx) if handler
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_context(command, thread_id, channel_id)
|
|
74
|
+
cmd_args = command.args
|
|
75
|
+
CommandContext.new(thread_id: thread_id, channel_id: channel_id, arg: cmd_args.first, args: cmd_args)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_help(ctx)
|
|
79
|
+
reply(ctx, HELP_TABLE)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def handle_update(ctx)
|
|
83
|
+
reply(ctx, ":arrows_counterclockwise: Updating EARL...")
|
|
84
|
+
save_restart_context(ctx, "update")
|
|
85
|
+
@deps.runner&.request_update
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def handle_restart(ctx)
|
|
89
|
+
reply(ctx, ":arrows_counterclockwise: Restarting EARL...")
|
|
90
|
+
save_restart_context(ctx, "restart")
|
|
91
|
+
@deps.runner&.request_restart
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def save_restart_context(ctx, command)
|
|
95
|
+
config_dir = Earl.config_root
|
|
96
|
+
FileUtils.mkdir_p(config_dir)
|
|
97
|
+
path = File.join(config_dir, "restart_context.json")
|
|
98
|
+
data = ctx.deconstruct_keys(%i[channel_id thread_id]).merge(command: command)
|
|
99
|
+
File.write(path, JSON.generate(data))
|
|
100
|
+
rescue StandardError => error
|
|
101
|
+
log(:warn, "Failed to save restart context: #{error.message}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def handle_permissions(ctx)
|
|
105
|
+
reply(ctx, "Permission mode is controlled via `EARL_SKIP_PERMISSIONS` env var.")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_stats(ctx)
|
|
109
|
+
session = @deps.session_manager.get(ctx.thread_id)
|
|
110
|
+
return reply(ctx, format_stats(session.stats)) if session
|
|
111
|
+
|
|
112
|
+
reply_persisted_stats(ctx)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reply_persisted_stats(ctx)
|
|
116
|
+
persisted = @deps.session_manager.persisted_session_for(ctx.thread_id)
|
|
117
|
+
if persisted&.total_cost
|
|
118
|
+
reply(ctx, format_persisted_stats(persisted))
|
|
119
|
+
else
|
|
120
|
+
reply(ctx, "No active session for this thread.")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def reply(ctx, message)
|
|
125
|
+
@deps.mattermost.create_post(**ctx.post_params(message))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
# Parses `!` prefixed commands from user messages in Mattermost.
|
|
5
|
+
# Returns a ParsedCommand struct or nil if the message is not a command.
|
|
6
|
+
class CommandParser
|
|
7
|
+
# A parsed chat command with name and arguments.
|
|
8
|
+
ParsedCommand = Struct.new(:name, :args, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
COMMANDS = {
|
|
11
|
+
/\A!stop\z/i => :stop,
|
|
12
|
+
/\A!escape\z/i => :escape,
|
|
13
|
+
/\A!kill\z/i => :kill,
|
|
14
|
+
/\A!help\z/i => :help,
|
|
15
|
+
/\A!stats\z/i => :stats,
|
|
16
|
+
/\A!cost\z/i => :stats,
|
|
17
|
+
/\A!compact\z/i => :compact,
|
|
18
|
+
/\A!cd\s+(.+)\z/i => :cd,
|
|
19
|
+
/\A!permissions\z/i => :permissions,
|
|
20
|
+
/\A!heartbeats\z/i => :heartbeats,
|
|
21
|
+
/\A!usage\z/i => :usage,
|
|
22
|
+
/\A!context\z/i => :context,
|
|
23
|
+
/\A!sessions\z/i => :sessions,
|
|
24
|
+
# Specific !session subcommands must come before catch-all
|
|
25
|
+
/\A!session\s+(\S+)\s+status\z/i => :session_status,
|
|
26
|
+
/\A!session\s+(\S+)\s+kill\z/i => :session_kill,
|
|
27
|
+
/\A!session\s+(\S+)\s+nudge\z/i => :session_nudge,
|
|
28
|
+
/\A!session\s+(\S+)\s+approve\z/i => :session_approve,
|
|
29
|
+
/\A!session\s+(\S+)\s+deny\z/i => :session_deny,
|
|
30
|
+
/\A!session\s+(\S+)\s+"([^"]+)"\z/i => :session_input,
|
|
31
|
+
/\A!session\s+(\S+)\s+'([^']+)'\z/i => :session_input,
|
|
32
|
+
/\A!session\s+(\S+)\z/i => :session_show,
|
|
33
|
+
/\A!update\z/i => :update,
|
|
34
|
+
/\A!restart\z/i => :restart,
|
|
35
|
+
/\A!spawn\s+"([^"]+)"(.*)\z/i => :spawn
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
def self.command?(text)
|
|
39
|
+
text.strip.start_with?("!")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.parse(text)
|
|
43
|
+
stripped = text.strip
|
|
44
|
+
return nil unless stripped.start_with?("!")
|
|
45
|
+
|
|
46
|
+
COMMANDS.each do |pattern, name|
|
|
47
|
+
match = stripped.match(pattern)
|
|
48
|
+
next unless match
|
|
49
|
+
|
|
50
|
+
args = match.captures
|
|
51
|
+
return ParsedCommand.new(name: name, args: args)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|