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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Earl
7
+ module Mcp
8
+ # MCP handler exposing a manage_heartbeat tool to create, update, delete,
9
+ # and list heartbeat schedules. Writes changes to the heartbeats YAML file;
10
+ # the HeartbeatScheduler auto-reloads when it detects file changes.
11
+ # Conforms to the Server handler interface: tool_definitions, handles?, call.
12
+ class HeartbeatHandler
13
+ include HandlerBase
14
+
15
+ TOOL_NAMES = %w[manage_heartbeat].freeze
16
+ def self.config_path
17
+ @config_path ||= File.join(Earl.config_root, "heartbeats.yml")
18
+ end
19
+
20
+ VALID_ACTIONS = %w[list create update delete].freeze
21
+ MUTABLE_FIELDS = %w[description cron interval run_at channel_id working_dir
22
+ prompt permission_mode persistent timeout enabled once].freeze
23
+
24
+ def initialize(default_channel_id: nil, config_path: self.class.config_path)
25
+ @default_channel_id = default_channel_id
26
+ @config_path = config_path
27
+ end
28
+
29
+ def tool_definitions
30
+ [manage_heartbeat_definition]
31
+ end
32
+
33
+ NAME_REQUIRED_ACTIONS = %w[create update delete].freeze
34
+
35
+ def call(name, arguments)
36
+ return unless handles?(name)
37
+
38
+ error = validate_action(arguments)
39
+ return error if error
40
+
41
+ send("handle_#{arguments["action"]}", arguments)
42
+ end
43
+
44
+ private
45
+
46
+ def validate_action(arguments)
47
+ action = arguments["action"]
48
+ return text_content("Error: action is required (list, create, update, delete)") unless action
49
+ unless VALID_ACTIONS.include?(action)
50
+ return text_content("Error: unknown action '#{action}'. Valid: #{VALID_ACTIONS.join(", ")}")
51
+ end
52
+
53
+ text_content("Error: name is required") if name_required_but_missing?(action, arguments)
54
+ end
55
+
56
+ def name_required_but_missing?(action, arguments)
57
+ return false unless NAME_REQUIRED_ACTIONS.include?(action)
58
+
59
+ hb_name = arguments["name"]
60
+ !hb_name || hb_name.empty?
61
+ end
62
+
63
+ # --- Action handlers ---
64
+
65
+ def handle_list(_arguments)
66
+ data = load_yaml
67
+ heartbeats = data["heartbeats"] || {}
68
+ return text_content("No heartbeats defined.") if heartbeats.empty?
69
+
70
+ lines = heartbeats.map { |name, config| format_heartbeat(name, config) }
71
+ text_content("**Heartbeats (#{heartbeats.size}):**\n\n#{lines.join("\n\n")}")
72
+ end
73
+
74
+ def handle_create(arguments)
75
+ name = arguments["name"]
76
+ data = load_yaml
77
+ heartbeats = (data["heartbeats"] ||= {})
78
+ return text_content("Error: heartbeat '#{name}' already exists") if heartbeats.key?(name)
79
+
80
+ heartbeats[name] = build_entry(arguments)
81
+ save_yaml(data)
82
+ text_content("Created heartbeat '#{name}'. Scheduler will pick it up within 30 seconds.")
83
+ end
84
+
85
+ def handle_update(arguments)
86
+ name = arguments["name"]
87
+ data = load_yaml
88
+ heartbeats = (data["heartbeats"] ||= {})
89
+ entry = heartbeats.fetch(name, nil)
90
+ return text_content("Error: heartbeat '#{name}' not found") unless entry
91
+
92
+ merge_entry(entry, arguments)
93
+ save_yaml(data)
94
+ text_content("Updated heartbeat '#{name}'. Scheduler will pick up changes within 30 seconds.")
95
+ end
96
+
97
+ def handle_delete(arguments)
98
+ name = arguments["name"]
99
+ data = load_yaml
100
+ heartbeats = (data["heartbeats"] ||= {})
101
+ removed = heartbeats.delete(name)
102
+ return text_content("Error: heartbeat '#{name}' not found") unless removed
103
+
104
+ save_yaml(data)
105
+ text_content("Deleted heartbeat '#{name}'. Scheduler will remove it within 30 seconds.")
106
+ end
107
+
108
+ # --- YAML I/O ---
109
+
110
+ # YAML file persistence for heartbeat definitions.
111
+ module YamlPersistence
112
+ private
113
+
114
+ def load_yaml
115
+ return { "heartbeats" => {} } unless File.exist?(@config_path)
116
+
117
+ data = YAML.safe_load_file(@config_path)
118
+ data.is_a?(Hash) ? data : { "heartbeats" => {} }
119
+ end
120
+
121
+ def save_yaml(data)
122
+ FileUtils.mkdir_p(File.dirname(@config_path))
123
+ # Use file lock to coordinate with HeartbeatScheduler#disable_heartbeat
124
+ File.open(@config_path, File::CREAT | File::WRONLY | File::TRUNC) do |file|
125
+ file.flock(File::LOCK_EX)
126
+ file.write(YAML.dump(data))
127
+ end
128
+ end
129
+ end
130
+
131
+ include YamlPersistence
132
+
133
+ # Entry building: constructs and merges heartbeat configuration hashes.
134
+ module EntryBuilder
135
+ OPTIONAL_STRING_KEYS = %w[working_dir prompt permission_mode timeout].freeze
136
+ SCHEDULE_KEYS = %w[cron interval run_at].freeze
137
+
138
+ private
139
+
140
+ def build_entry(arguments)
141
+ schedule = slice_present(arguments, SCHEDULE_KEYS)
142
+ schedule["run_at"] = Time.now.to_i if arguments["once"] && schedule.empty?
143
+
144
+ base = slice_present(arguments, %w[description once])
145
+ base.merge(
146
+ "schedule" => schedule,
147
+ "channel_id" => arguments["channel_id"] || @default_channel_id,
148
+ "enabled" => arguments.fetch("enabled", true)
149
+ ).merge(slice_present(arguments, OPTIONAL_STRING_KEYS))
150
+ .merge(slice_key(arguments, "persistent"))
151
+ end
152
+
153
+ def slice_present(hash, keys)
154
+ keys.each_with_object({}) do |key, result|
155
+ result[key] = hash[key] if hash.key?(key)
156
+ end
157
+ end
158
+
159
+ def slice_key(hash, key)
160
+ hash.key?(key) ? { key => hash[key] } : {}
161
+ end
162
+
163
+ def merge_entry(entry, arguments)
164
+ schedule = (entry["schedule"] ||= {})
165
+ MUTABLE_FIELDS.each do |field|
166
+ next unless arguments.key?(field)
167
+
168
+ value = arguments[field]
169
+ target = SCHEDULE_KEYS.include?(field) ? schedule : entry
170
+ target[field] = value
171
+ end
172
+ end
173
+
174
+ def build_schedule(arguments)
175
+ slice_present(arguments, SCHEDULE_KEYS)
176
+ end
177
+ end
178
+
179
+ include EntryBuilder
180
+
181
+ # --- Formatting ---
182
+
183
+ # Human-readable heartbeat list formatting.
184
+ module Formatting
185
+ private
186
+
187
+ def format_heartbeat(name, config)
188
+ schedule, enabled, once, description = extract_display_fields(config, name)
189
+ schedule_str = format_schedule(schedule)
190
+ enabled_str = enabled ? "enabled" : "disabled"
191
+ once_badge = once ? ", once" : ""
192
+ "- **#{name}** (#{enabled_str}#{once_badge}, #{schedule_str})\n #{description}"
193
+ end
194
+
195
+ def extract_display_fields(config, name)
196
+ [
197
+ config["schedule"] || {},
198
+ config.fetch("enabled", true),
199
+ config.fetch("once", false),
200
+ config["description"] || name
201
+ ]
202
+ end
203
+
204
+ def format_schedule(schedule)
205
+ cron = schedule["cron"]
206
+ interval = schedule["interval"]
207
+ run_at = schedule["run_at"]
208
+
209
+ return "cron: `#{cron}`" if cron
210
+ return "interval: #{interval}s" if interval
211
+ return "run_at: #{Time.at(run_at).strftime("%Y-%m-%d %H:%M:%S")}" if run_at
212
+
213
+ "no schedule"
214
+ end
215
+ end
216
+
217
+ include Formatting
218
+
219
+ # --- MCP response helper ---
220
+
221
+ def text_content(text)
222
+ { content: [{ type: "text", text: text }] }
223
+ end
224
+
225
+ # Tool definition building: splits the large schema into composable property groups.
226
+ module ToolDefinitionBuilder
227
+ private
228
+
229
+ def manage_heartbeat_definition
230
+ {
231
+ name: "manage_heartbeat",
232
+ description: heartbeat_tool_description,
233
+ inputSchema: heartbeat_input_schema
234
+ }
235
+ end
236
+
237
+ def heartbeat_tool_description
238
+ "Manage Earl's heartbeat schedules. " \
239
+ "Create, update, delete, or list tasks that run on cron, interval, or one-shot (run_at) schedules."
240
+ end
241
+
242
+ def heartbeat_input_schema
243
+ {
244
+ type: "object",
245
+ properties: heartbeat_properties,
246
+ required: %w[action]
247
+ }
248
+ end
249
+
250
+ def heartbeat_properties
251
+ {}.merge(action_properties)
252
+ .merge(schedule_properties)
253
+ .merge(session_properties)
254
+ .merge(behavior_properties)
255
+ end
256
+
257
+ def action_properties
258
+ {
259
+ action: {
260
+ type: "string", enum: VALID_ACTIONS,
261
+ description: "Action to perform: list, create, update, or delete"
262
+ },
263
+ name: { type: "string", description: "Heartbeat name (required for create/update/delete)" },
264
+ description: { type: "string", description: "Human-readable description of the heartbeat" }
265
+ }
266
+ end
267
+
268
+ def schedule_properties
269
+ {
270
+ cron: { type: "string", description: "Cron expression (e.g. '0 9 * * 1-5' for weekdays at 9am)" },
271
+ interval: { type: "integer", description: "Interval in seconds between runs (alternative to cron)" },
272
+ run_at: {
273
+ type: "integer",
274
+ description: "Unix timestamp for one-shot execution (alternative to cron/interval). " \
275
+ "Must be used with once: true to avoid repeated firing."
276
+ }
277
+ }
278
+ end
279
+
280
+ def session_properties
281
+ {
282
+ channel_id: { type: "string",
283
+ description: "Mattermost channel ID to post results (defaults to current channel)" },
284
+ working_dir: { type: "string", description: "Working directory for the Claude session" },
285
+ prompt: { type: "string", description: "The prompt to send to Claude when the heartbeat fires" },
286
+ permission_mode: {
287
+ type: "string", enum: %w[auto interactive],
288
+ description: "Permission mode: auto (no approval) or interactive (requires reaction approval)"
289
+ }
290
+ }
291
+ end
292
+
293
+ def behavior_properties
294
+ {
295
+ persistent: { type: "boolean", description: "Whether to reuse the same Claude session across runs" },
296
+ timeout: { type: "integer", description: "Maximum seconds to wait for completion (default: 600)" },
297
+ enabled: { type: "boolean", description: "Whether the heartbeat is active (default: true)" },
298
+ once: {
299
+ type: "boolean",
300
+ description: "Run once then auto-disable. Combine with run_at for scheduled one-shots, " \
301
+ "or omit schedule to fire immediately."
302
+ }
303
+ }
304
+ end
305
+ end
306
+
307
+ include ToolDefinitionBuilder
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Mcp
5
+ # MCP handler exposing save_memory and search_memory tools.
6
+ # Conforms to the Server handler interface: tool_definitions, handles?, call.
7
+ class MemoryHandler
8
+ include HandlerBase
9
+
10
+ TOOL_NAMES = %w[save_memory search_memory].freeze
11
+
12
+ def initialize(store:, username: nil)
13
+ @store = store
14
+ @username = username
15
+ end
16
+
17
+ def tool_definitions
18
+ [save_memory_definition, search_memory_definition]
19
+ end
20
+
21
+ DISPATCH = { "save_memory" => :handle_save, "search_memory" => :handle_search }.freeze
22
+
23
+ def call(name, arguments)
24
+ method = DISPATCH[name]
25
+ return unless method
26
+
27
+ send(method, arguments)
28
+ end
29
+
30
+ private
31
+
32
+ def handle_save(arguments)
33
+ text = arguments["text"] || arguments["fact"] || ""
34
+ username = arguments["username"] || @username || "unknown"
35
+
36
+ result = @store.save(username: username, text: text)
37
+ text_content("Saved: #{result[:entry]}")
38
+ end
39
+
40
+ def handle_search(arguments)
41
+ query = arguments["query"] || ""
42
+ limit = (arguments["limit"] || 20).to_i
43
+
44
+ results = @store.search(query: query, limit: limit)
45
+ return text_content("No memories found for: #{query}") if results.empty?
46
+
47
+ text_content(format_search_results(results))
48
+ end
49
+
50
+ def format_search_results(results)
51
+ count = results.size
52
+ formatted = results.map { |result| "**#{result[:file]}**: #{result[:line]}" }.join("\n")
53
+ "Found #{count} memor#{count == 1 ? "y" : "ies"}:\n#{formatted}"
54
+ end
55
+
56
+ def text_content(text)
57
+ { content: [{ type: "text", text: text }] }
58
+ end
59
+
60
+ def save_memory_definition
61
+ build_tool_definition(
62
+ "save_memory",
63
+ "Save a fact or observation to persistent memory. " \
64
+ "Use this to remember important information about users, preferences, or context.",
65
+ text: { type: "string", description: "The fact or observation to save" },
66
+ username: { type: "string", description: "The username this memory relates to (optional)" }
67
+ )
68
+ end
69
+
70
+ def search_memory_definition
71
+ build_tool_definition(
72
+ "search_memory",
73
+ "Search persistent memory for previously saved facts. " \
74
+ "Use this to recall information about users, preferences, or past interactions.",
75
+ query: { type: "string", description: "Keywords to search for" },
76
+ limit: { type: "integer", description: "Maximum results to return (default: 20)" }
77
+ )
78
+ end
79
+
80
+ def build_tool_definition(name, description, **properties)
81
+ required = name == "save_memory" ? %w[text] : %w[query]
82
+ {
83
+ name: name, description: description,
84
+ inputSchema: { type: "object", properties: properties, required: required }
85
+ }
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Mcp
5
+ # Minimal JSON-RPC 2.0 server over stdio for the Claude CLI MCP integration.
6
+ # Routes tools/list and tools/call to registered handlers via duck-typed interface:
7
+ # #tool_definitions → Array of tool hashes
8
+ # #handles?(tool_name) → Boolean
9
+ # #call(tool_name, arguments) → result hash
10
+ class Server
11
+ include Logging
12
+
13
+ def initialize(handlers:, input: $stdin, output: $stdout)
14
+ @handlers = Array(handlers)
15
+ @input = input
16
+ @output = output
17
+ @output.sync = true
18
+ end
19
+
20
+ def run
21
+ @input.each_line do |line|
22
+ request = parse_request(line)
23
+ next unless request
24
+
25
+ response = handle_request(request)
26
+ write_response(response) if response
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def parse_request(line)
33
+ JSON.parse(line.strip)
34
+ rescue JSON::ParserError => error
35
+ log(:warn, "MCP: unparsable input: #{error.message}")
36
+ nil
37
+ end
38
+
39
+ def handle_request(request)
40
+ method_name, request_id, params = request.values_at("method", "id", "params")
41
+ dispatch_method(method_name, request_id, params || {})
42
+ end
43
+
44
+ def dispatch_method(method_name, request_id, params)
45
+ case method_name
46
+ when "initialize" then initialize_response(request_id)
47
+ when "notifications/initialized" then nil
48
+ when "tools/list" then tools_list_response(request_id)
49
+ when "tools/call" then tools_call_response(request_id, params)
50
+ else error_response(request_id, -32_601, "Method not found: #{method_name}")
51
+ end
52
+ end
53
+
54
+ def initialize_response(id)
55
+ {
56
+ jsonrpc: "2.0",
57
+ id: id,
58
+ result: {
59
+ protocolVersion: "2024-11-05",
60
+ capabilities: { tools: {} },
61
+ serverInfo: { name: "earl-mcp-server", version: "1.0.0" }
62
+ }
63
+ }
64
+ end
65
+
66
+ def tools_list_response(id)
67
+ {
68
+ jsonrpc: "2.0",
69
+ id: id,
70
+ result: {
71
+ tools: @handlers.flat_map(&:tool_definitions)
72
+ }
73
+ }
74
+ end
75
+
76
+ def tools_call_response(id, params)
77
+ tool_name, arguments = extract_tool_params(params)
78
+ handler = find_tool_handler(tool_name)
79
+ return error_response(id, -32_602, "No handler for tool: #{tool_name}") unless handler
80
+
81
+ log(:info, "MCP tool call: #{tool_name}")
82
+ result = handler.call(tool_name, arguments)
83
+ log_tool_result(tool_name, result)
84
+
85
+ { jsonrpc: "2.0", id: id, result: result }
86
+ rescue StandardError => error
87
+ handle_tool_error(id, error)
88
+ end
89
+
90
+ def extract_tool_params(args)
91
+ tool_name = args["name"] || args.dig("arguments", "tool_name") || "unknown"
92
+ [tool_name, args["arguments"] || {}]
93
+ end
94
+
95
+ def find_tool_handler(tool_name)
96
+ @handlers.find { |candidate| candidate.handles?(tool_name) }
97
+ end
98
+
99
+ def log_tool_result(tool_name, result)
100
+ log(:info, "MCP tool result for #{tool_name}: #{result.inspect[0..200]}")
101
+ end
102
+
103
+ def handle_tool_error(id, error)
104
+ msg = error.message
105
+ log(:error, "MCP tool call error: #{error.class}: #{msg}")
106
+ log(:error, error.backtrace&.first(3)&.join("\n"))
107
+ error_response(id, -32_603, "Internal error: #{msg}")
108
+ end
109
+
110
+ def error_response(id, code, message)
111
+ {
112
+ jsonrpc: "2.0",
113
+ id: id,
114
+ error: { code: code, message: message }
115
+ }
116
+ end
117
+
118
+ def write_response(response)
119
+ @output.puts(JSON.generate(response))
120
+ end
121
+ end
122
+ end
123
+ end