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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Runner
|
|
5
|
+
# Streaming response lifecycle: creates responses, wires callbacks, handles completion.
|
|
6
|
+
module ResponseLifecycle
|
|
7
|
+
# Bundles response context that travels together through the lifecycle.
|
|
8
|
+
ResponseBundle = Struct.new(:session, :response, :thread_id, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def prepare_response(session, thread_id, channel_id)
|
|
13
|
+
response = StreamingResponse.new(thread_id: thread_id, mattermost: @services.mattermost, channel_id: channel_id)
|
|
14
|
+
@responses.active_responses[thread_id] = response
|
|
15
|
+
response.start_typing
|
|
16
|
+
bundle = ResponseBundle.new(session: session, response: response, thread_id: thread_id)
|
|
17
|
+
wire_all_callbacks(bundle)
|
|
18
|
+
response
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def wire_all_callbacks(bundle)
|
|
22
|
+
session, response, thread_id = bundle.deconstruct
|
|
23
|
+
resp_channel_id = response.channel_id
|
|
24
|
+
wire_text_callbacks(session, response)
|
|
25
|
+
session.on_complete { |_| handle_response_complete(thread_id) }
|
|
26
|
+
session.on_tool_use do |tool_use|
|
|
27
|
+
response.on_tool_use(tool_use)
|
|
28
|
+
handle_tool_use(thread_id: thread_id, tool_use: tool_use, channel_id: resp_channel_id)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def wire_text_callbacks(session, response)
|
|
33
|
+
session.on_text { |text| response.on_text(text) }
|
|
34
|
+
session.on_system { |event| response.on_text(event[:message]) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_tool_use(thread_id:, tool_use:, channel_id:)
|
|
38
|
+
result = @services.question_handler.handle_tool_use(thread_id: thread_id, tool_use: tool_use,
|
|
39
|
+
channel_id: channel_id)
|
|
40
|
+
return unless result.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
tool_use_id = result[:tool_use_id]
|
|
43
|
+
@responses.question_threads[tool_use_id] = thread_id if tool_use_id
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_response_complete(thread_id)
|
|
47
|
+
response = @responses.active_responses.delete(thread_id)
|
|
48
|
+
session = @services.session_manager.get(thread_id)
|
|
49
|
+
|
|
50
|
+
if session && response
|
|
51
|
+
bundle = ResponseBundle.new(session: session, response: response, thread_id: thread_id)
|
|
52
|
+
finalize_response(bundle)
|
|
53
|
+
else
|
|
54
|
+
log_missing_completion(thread_id, response)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
process_next_queued(thread_id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def finalize_response(bundle)
|
|
61
|
+
session, response, thread_id = bundle.deconstruct
|
|
62
|
+
stats = session.stats
|
|
63
|
+
response.on_complete
|
|
64
|
+
log_session_stats(stats, thread_id)
|
|
65
|
+
@services.session_manager.save_stats(thread_id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def log_missing_completion(thread_id, response)
|
|
69
|
+
log(:warn, "Completion for thread #{thread_id[0..7]} with missing session or response (likely killed)")
|
|
70
|
+
response&.stop_typing
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log_session_stats(stats, thread_id)
|
|
74
|
+
summary = stats.format_summary("Thread #{thread_id[0..7]} complete")
|
|
75
|
+
log(:info, summary)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def log_processing_error(thread_id, error)
|
|
79
|
+
log(:error, "Error processing message for thread #{thread_id[0..7]}: #{error.message}")
|
|
80
|
+
log(:error, error.backtrace&.first(5)&.join("\n"))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stop_active_response(thread_id)
|
|
84
|
+
response = @responses.active_responses.delete(thread_id)
|
|
85
|
+
response&.stop_typing
|
|
86
|
+
@app_state.message_queue.dequeue(thread_id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cleanup_failed_send(thread_id)
|
|
90
|
+
response = @responses.active_responses.delete(thread_id)
|
|
91
|
+
response&.stop_typing
|
|
92
|
+
@app_state.message_queue.release(thread_id)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Runner
|
|
5
|
+
# Constructs and wires together the service dependency graph.
|
|
6
|
+
module ServiceBuilder
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def build_services
|
|
10
|
+
core = build_core_services
|
|
11
|
+
assemble_services(core)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build_core_services
|
|
15
|
+
config = Config.new
|
|
16
|
+
session_store = SessionStore.new
|
|
17
|
+
mattermost = Mattermost.new(config)
|
|
18
|
+
{ config: config, session_store: session_store, mattermost: mattermost,
|
|
19
|
+
tmux_store: TmuxSessionStore.new,
|
|
20
|
+
session_manager: SessionManager.new(config: config, session_store: session_store) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def assemble_services(core)
|
|
24
|
+
config, mattermost, tmux_store = core.values_at(:config, :mattermost, :tmux_store)
|
|
25
|
+
Services.new(
|
|
26
|
+
**core,
|
|
27
|
+
heartbeat_scheduler: HeartbeatScheduler.new(config: config, mattermost: mattermost),
|
|
28
|
+
command_executor: build_command_executor(core),
|
|
29
|
+
question_handler: QuestionHandler.new(mattermost: mattermost),
|
|
30
|
+
tmux_monitor: TmuxMonitor.new(mattermost: mattermost, tmux_store: tmux_store)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_command_executor(core)
|
|
35
|
+
CommandExecutor.new(
|
|
36
|
+
session_manager: core[:session_manager], mattermost: core[:mattermost],
|
|
37
|
+
config: core[:config], heartbeat_scheduler: nil, tmux_store: core[:tmux_store]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def wire_circular_deps
|
|
42
|
+
executor_deps = @services.command_executor.instance_variable_get(:@deps)
|
|
43
|
+
executor_deps.heartbeat_scheduler = @services.heartbeat_scheduler
|
|
44
|
+
executor_deps.runner = self
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Runner
|
|
5
|
+
# Startup, channel configuration, and restart notification logic.
|
|
6
|
+
module Startup
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def configure_channels
|
|
10
|
+
channels = @services.config.channels
|
|
11
|
+
@services.mattermost.configure_channels(Set.new(channels.keys)) if channels.size > 1
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def log_startup
|
|
15
|
+
config = @services.config
|
|
16
|
+
channel_names = resolve_channel_names(config.channels.keys)
|
|
17
|
+
count = channel_names.size
|
|
18
|
+
log(:info,
|
|
19
|
+
"EARL is running. Listening in #{count} channel#{"s" unless count == 1}: #{channel_names.join(", ")}")
|
|
20
|
+
log(:info, "Allowed users: #{config.allowed_users.join(", ")}")
|
|
21
|
+
notify_restart
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def notify_restart
|
|
25
|
+
data = read_restart_context
|
|
26
|
+
return unless data
|
|
27
|
+
|
|
28
|
+
verb = data["command"] == "update" ? "updated" : "restarted"
|
|
29
|
+
@services.mattermost.create_post(
|
|
30
|
+
channel_id: data["channel_id"],
|
|
31
|
+
message: ":white_check_mark: EARL #{verb} successfully.",
|
|
32
|
+
root_id: data["thread_id"]
|
|
33
|
+
)
|
|
34
|
+
rescue StandardError => error
|
|
35
|
+
log(:warn, "Failed to post restart notification: #{error.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read_restart_context
|
|
39
|
+
path = File.join(Earl.config_root, "restart_context.json")
|
|
40
|
+
data = JSON.parse(File.read(path))
|
|
41
|
+
File.delete(path)
|
|
42
|
+
data
|
|
43
|
+
rescue Errno::ENOENT
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve_channel_names(channel_ids)
|
|
48
|
+
channel_ids.map do |id|
|
|
49
|
+
info = @services.mattermost.get_channel(channel_id: id)
|
|
50
|
+
info&.fetch("display_name", nil) || info&.fetch("name", nil) || id[0..7]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start_background_services
|
|
55
|
+
start_idle_checker
|
|
56
|
+
@services.heartbeat_scheduler.start
|
|
57
|
+
@services.tmux_monitor.start
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def setup_handlers
|
|
61
|
+
setup_signal_handlers
|
|
62
|
+
setup_message_handler
|
|
63
|
+
setup_reaction_handler
|
|
64
|
+
setup_close_handler
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def setup_signal_handlers
|
|
68
|
+
%w[INT TERM].each { |signal| trap(signal) { handle_shutdown_signal } }
|
|
69
|
+
trap("HUP") { handle_restart_signal }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Runner
|
|
5
|
+
# Builds contextual messages for new Claude sessions by prepending
|
|
6
|
+
# Mattermost thread transcripts so Claude has conversation history.
|
|
7
|
+
class ThreadContextBuilder
|
|
8
|
+
MAX_PRIOR_POSTS = 20
|
|
9
|
+
|
|
10
|
+
def initialize(mattermost:)
|
|
11
|
+
@mattermost = mattermost
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# When a Claude session is first created for a thread that already has messages
|
|
15
|
+
# (e.g., from !commands and EARL replies), prepend the thread transcript so
|
|
16
|
+
# Claude has context. Returns the original text if no prior messages exist.
|
|
17
|
+
def build(thread_id, text)
|
|
18
|
+
prior_posts = fetch_prior_posts(thread_id, text)
|
|
19
|
+
return text if prior_posts.empty?
|
|
20
|
+
|
|
21
|
+
transcript = format_transcript(prior_posts)
|
|
22
|
+
"Here is the conversation so far in this Mattermost thread:\n\n#{transcript}\n\n" \
|
|
23
|
+
"---\n\nUser's latest message: #{text}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fetch_prior_posts(thread_id, current_text)
|
|
29
|
+
posts = @mattermost.get_thread_posts(thread_id)
|
|
30
|
+
posts.reject { |post| post[:message] == current_text }.last(MAX_PRIOR_POSTS)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format_transcript(posts)
|
|
34
|
+
posts.map { |post| format_post(post) }.join("\n\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def format_post(post)
|
|
38
|
+
role = post[:is_bot] ? "EARL" : "User"
|
|
39
|
+
"#{role}: #{post[:message]}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/earl/runner.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
# Main event loop that connects Mattermost messages to Claude sessions,
|
|
5
|
+
# managing per-thread message queuing, command parsing, question handling,
|
|
6
|
+
# and streaming response delivery.
|
|
7
|
+
class Runner
|
|
8
|
+
include Logging
|
|
9
|
+
include Formatting
|
|
10
|
+
|
|
11
|
+
# Tracks runtime state: shutdown flag, restart intent, and per-thread message queue.
|
|
12
|
+
AppState = Struct.new(:shutting_down, :pending_restart, :pending_update, :shutdown_thread, :message_queue,
|
|
13
|
+
:idle_checker_thread, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Bundles user message parameters that travel together through message routing.
|
|
16
|
+
UserMessage = Data.define(:thread_id, :text, :channel_id, :sender_name)
|
|
17
|
+
|
|
18
|
+
# Groups injected service dependencies to keep ivar count low.
|
|
19
|
+
Services = Struct.new(:config, :session_store, :session_manager, :mattermost,
|
|
20
|
+
:command_executor, :question_handler, :heartbeat_scheduler,
|
|
21
|
+
:tmux_store, :tmux_monitor, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
# Groups per-thread response tracking state.
|
|
24
|
+
ResponseState = Struct.new(:question_threads, :active_responses, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
IDLE_CHECK_INTERVAL = 300 # 5 minutes
|
|
27
|
+
IDLE_TIMEOUT = 1800 # 30 minutes
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@services = build_services
|
|
31
|
+
wire_circular_deps
|
|
32
|
+
@app_state = AppState.new(shutting_down: false, pending_restart: false, pending_update: false,
|
|
33
|
+
shutdown_thread: nil, message_queue: MessageQueue.new)
|
|
34
|
+
@responses = ResponseState.new(question_threads: {}, active_responses: {})
|
|
35
|
+
|
|
36
|
+
configure_channels
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start
|
|
40
|
+
setup_handlers
|
|
41
|
+
ClaudeSession.cleanup_mcp_configs
|
|
42
|
+
@services.session_manager.resume_all
|
|
43
|
+
@services.tmux_store.cleanup!
|
|
44
|
+
start_background_services
|
|
45
|
+
@services.mattermost.connect
|
|
46
|
+
log_startup
|
|
47
|
+
sleep 0.5 until @app_state.shutting_down
|
|
48
|
+
wait_and_exec_restart if @app_state.pending_restart
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def request_restart
|
|
52
|
+
@app_state.pending_restart = true
|
|
53
|
+
begin_shutdown { restart }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def request_update
|
|
57
|
+
@app_state.pending_restart = true
|
|
58
|
+
@app_state.pending_update = true
|
|
59
|
+
begin_shutdown { restart }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
include ServiceBuilder
|
|
63
|
+
include Startup
|
|
64
|
+
include Lifecycle
|
|
65
|
+
include MessageHandling
|
|
66
|
+
include ReactionHandling
|
|
67
|
+
include ResponseLifecycle
|
|
68
|
+
include IdleManagement
|
|
69
|
+
end
|
|
70
|
+
end
|