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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CLAUDE.md +260 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +177 -0
  7. data/LICENSE +21 -0
  8. data/README.md +106 -0
  9. data/Rakefile +11 -0
  10. data/bin/README.md +21 -0
  11. data/bin/ci +49 -0
  12. data/bin/claude-context +155 -0
  13. data/bin/claude-usage +110 -0
  14. data/bin/coverage +221 -0
  15. data/bin/rubocop +10 -0
  16. data/bin/watch-ci +198 -0
  17. data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
  18. data/config/earl-claude-home/.claude/settings.json +34 -0
  19. data/earl-bot.gemspec +42 -0
  20. data/exe/earl +51 -0
  21. data/exe/earl-install +129 -0
  22. data/exe/earl-permission-server +39 -0
  23. data/lib/earl/claude_session/stats.rb +76 -0
  24. data/lib/earl/claude_session.rb +468 -0
  25. data/lib/earl/command_executor/constants.rb +53 -0
  26. data/lib/earl/command_executor/heartbeat_display.rb +54 -0
  27. data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
  28. data/lib/earl/command_executor/session_handler.rb +126 -0
  29. data/lib/earl/command_executor/spawn_handler.rb +99 -0
  30. data/lib/earl/command_executor/stats_formatter.rb +66 -0
  31. data/lib/earl/command_executor/usage_handler.rb +132 -0
  32. data/lib/earl/command_executor.rb +128 -0
  33. data/lib/earl/command_parser.rb +57 -0
  34. data/lib/earl/config.rb +94 -0
  35. data/lib/earl/cron_parser.rb +105 -0
  36. data/lib/earl/formatting.rb +14 -0
  37. data/lib/earl/heartbeat_config.rb +101 -0
  38. data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
  39. data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
  40. data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
  41. data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
  42. data/lib/earl/heartbeat_scheduler.rb +131 -0
  43. data/lib/earl/logging.rb +12 -0
  44. data/lib/earl/mattermost/api_client.rb +85 -0
  45. data/lib/earl/mattermost.rb +261 -0
  46. data/lib/earl/mcp/approval_handler.rb +304 -0
  47. data/lib/earl/mcp/config.rb +62 -0
  48. data/lib/earl/mcp/github_pat_handler.rb +450 -0
  49. data/lib/earl/mcp/handler_base.rb +13 -0
  50. data/lib/earl/mcp/heartbeat_handler.rb +310 -0
  51. data/lib/earl/mcp/memory_handler.rb +89 -0
  52. data/lib/earl/mcp/server.rb +123 -0
  53. data/lib/earl/mcp/tmux_handler.rb +562 -0
  54. data/lib/earl/memory/prompt_builder.rb +40 -0
  55. data/lib/earl/memory/store.rb +125 -0
  56. data/lib/earl/message_queue.rb +56 -0
  57. data/lib/earl/permission_config.rb +22 -0
  58. data/lib/earl/question_handler/question_posting.rb +58 -0
  59. data/lib/earl/question_handler.rb +116 -0
  60. data/lib/earl/runner/idle_management.rb +44 -0
  61. data/lib/earl/runner/lifecycle.rb +73 -0
  62. data/lib/earl/runner/message_handling.rb +121 -0
  63. data/lib/earl/runner/reaction_handling.rb +42 -0
  64. data/lib/earl/runner/response_lifecycle.rb +96 -0
  65. data/lib/earl/runner/service_builder.rb +48 -0
  66. data/lib/earl/runner/startup.rb +73 -0
  67. data/lib/earl/runner/thread_context_builder.rb +43 -0
  68. data/lib/earl/runner.rb +70 -0
  69. data/lib/earl/safari_automation.rb +497 -0
  70. data/lib/earl/session_manager/persistence.rb +46 -0
  71. data/lib/earl/session_manager/session_creation.rb +108 -0
  72. data/lib/earl/session_manager.rb +92 -0
  73. data/lib/earl/session_store.rb +84 -0
  74. data/lib/earl/streaming_response.rb +219 -0
  75. data/lib/earl/tmux/parsing.rb +80 -0
  76. data/lib/earl/tmux/processes.rb +34 -0
  77. data/lib/earl/tmux/sessions.rb +41 -0
  78. data/lib/earl/tmux.rb +122 -0
  79. data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
  80. data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
  81. data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
  82. data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
  83. data/lib/earl/tmux_monitor.rb +249 -0
  84. data/lib/earl/tmux_session_store.rb +133 -0
  85. data/lib/earl/tool_input_formatter.rb +44 -0
  86. data/lib/earl/version.rb +5 -0
  87. data/lib/earl.rb +87 -0
  88. data/lib/tasks/.keep +1 -0
  89. 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
@@ -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