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,562 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "shellwords"
5
+
6
+ module Earl
7
+ module Mcp
8
+ # MCP handler exposing a manage_tmux_sessions tool to list, capture, control,
9
+ # spawn, and kill Claude sessions running in tmux panes.
10
+ # Conforms to the Server handler interface: tool_definitions, handles?, call.
11
+ class TmuxHandler
12
+ include Logging
13
+ include HandlerBase
14
+
15
+ TOOL_NAME = "manage_tmux_sessions"
16
+ TOOL_NAMES = [TOOL_NAME].freeze
17
+ VALID_ACTIONS = %w[list capture status approve deny send_input spawn kill].freeze
18
+
19
+ # Bundles spawn parameters that travel together through the confirmation and creation flow.
20
+ SpawnRequest = Data.define(:name, :prompt, :working_dir, :session)
21
+
22
+ # Reaction emojis and pane status labels for spawn confirmation flow.
23
+ module Reactions
24
+ APPROVE_EMOJIS = %w[+1 white_check_mark].freeze
25
+ DENY_EMOJIS = %w[-1].freeze
26
+ ALL = (APPROVE_EMOJIS + DENY_EMOJIS).freeze
27
+
28
+ PANE_STATUS_LABELS = {
29
+ active: "Active",
30
+ permission: "Waiting for permission",
31
+ idle: "Idle"
32
+ }.freeze
33
+ end
34
+
35
+ def initialize(config:, api_client:, tmux_store:, tmux_adapter: Tmux)
36
+ @config = config
37
+ @api = api_client
38
+ @tmux_store = tmux_store
39
+ @tmux = tmux_adapter
40
+ end
41
+
42
+ def tool_definitions
43
+ [tool_definition]
44
+ end
45
+
46
+ TARGET_REQUIRED_ACTIONS = %w[capture status approve deny send_input kill].freeze
47
+
48
+ def call(name, arguments)
49
+ return unless handles?(name)
50
+
51
+ error = validate_call_args(arguments)
52
+ return error if error
53
+
54
+ send("handle_#{arguments["action"]}", arguments)
55
+ end
56
+
57
+ private
58
+
59
+ def validate_call_args(arguments)
60
+ action = arguments["action"]
61
+ valid_list = VALID_ACTIONS.join(", ")
62
+ return text_content("Error: action is required (#{valid_list})") unless action
63
+ unless VALID_ACTIONS.include?(action)
64
+ return text_content("Error: unknown action '#{action}'. Valid: #{valid_list}")
65
+ end
66
+
67
+ text_content("Error: target is required for #{action}") if target_required_but_missing?(action,
68
+ arguments)
69
+ end
70
+
71
+ def target_required_but_missing?(action, arguments)
72
+ TARGET_REQUIRED_ACTIONS.include?(action) && !arguments["target"]
73
+ end
74
+
75
+ # Action handlers for list, capture, status, approve, deny, send_input, spawn, kill.
76
+ module ActionHandlers
77
+ private
78
+
79
+ # --- list ---
80
+
81
+ def handle_list(_arguments)
82
+ return text_content("Error: tmux is not available") unless @tmux.available?
83
+
84
+ panes = @tmux.list_all_panes
85
+ claude_panes = select_claude_panes(panes)
86
+ format_pane_list(panes, claude_panes)
87
+ end
88
+
89
+ def select_claude_panes(panes)
90
+ panes.select { |pane| @tmux.claude_on_tty?(pane[:tty]) }
91
+ end
92
+
93
+ def format_pane_list(panes, claude_panes)
94
+ return text_content("No tmux sessions running.") if panes.empty?
95
+ return text_content("No Claude sessions found across #{panes.size} tmux panes.") if claude_panes.empty?
96
+
97
+ lines = claude_panes.map { |pane| format_pane(pane) }
98
+ text_content("**Claude Sessions (#{claude_panes.size}):**\n\n#{lines.join("\n")}")
99
+ end
100
+
101
+ # --- capture ---
102
+
103
+ def handle_capture(arguments)
104
+ target = arguments["target"]
105
+ lines = arguments.fetch("lines", 100).to_i
106
+ lines = [lines, 1].max
107
+ output = @tmux.capture_pane(target, lines: lines)
108
+ text_content("**`#{target}` output (last #{lines} lines):**\n```\n#{output}\n```")
109
+ rescue Tmux::NotFound
110
+ text_content("Error: session/pane '#{target}' not found")
111
+ rescue Tmux::Error => error
112
+ text_content("Error: #{error.message}")
113
+ end
114
+
115
+ # --- status ---
116
+
117
+ def handle_status(arguments)
118
+ target = arguments["target"]
119
+ output = @tmux.capture_pane(target, lines: 200)
120
+ status_label = Reactions::PANE_STATUS_LABELS.fetch(classify_pane_output(output), "Idle")
121
+ text_content("**`#{target}` status: #{status_label}**\n```\n#{output}\n```")
122
+ rescue Tmux::NotFound
123
+ text_content("Error: session/pane '#{target}' not found")
124
+ rescue Tmux::Error => error
125
+ text_content("Error: #{error.message}")
126
+ end
127
+
128
+ # --- approve ---
129
+
130
+ def handle_approve(arguments)
131
+ target = arguments["target"]
132
+ @tmux.send_keys_raw(target, "Enter")
133
+ text_content("Approved permission on `#{target}`.")
134
+ rescue Tmux::NotFound
135
+ text_content("Error: session/pane '#{target}' not found")
136
+ rescue Tmux::Error => error
137
+ text_content("Error: #{error.message}")
138
+ end
139
+
140
+ # --- deny ---
141
+
142
+ def handle_deny(arguments)
143
+ target = arguments["target"]
144
+ @tmux.send_keys_raw(target, "Escape")
145
+ text_content("Denied permission on `#{target}`.")
146
+ rescue Tmux::NotFound
147
+ text_content("Error: session/pane '#{target}' not found")
148
+ rescue Tmux::Error => error
149
+ text_content("Error: #{error.message}")
150
+ end
151
+
152
+ # --- send_input ---
153
+
154
+ def handle_send_input(arguments)
155
+ target = arguments["target"]
156
+ text = arguments["text"]
157
+ return text_content("Error: text is required for send_input") unless text
158
+
159
+ @tmux.send_keys(target, text)
160
+ text_content("Sent to `#{target}`: `#{text}`")
161
+ rescue Tmux::NotFound
162
+ text_content("Error: session/pane '#{target}' not found")
163
+ rescue Tmux::Error => error
164
+ text_content("Error: #{error.message}")
165
+ end
166
+
167
+ # --- spawn ---
168
+
169
+ def handle_spawn(arguments)
170
+ spawn_error = validate_spawn_args(arguments)
171
+ return spawn_error if spawn_error
172
+
173
+ request = build_spawn_request(arguments)
174
+ execute_spawn(request)
175
+ rescue Tmux::Error => error
176
+ text_content("Error: #{error.message}")
177
+ end
178
+
179
+ # --- kill ---
180
+
181
+ def handle_kill(arguments)
182
+ target = arguments["target"]
183
+ kill_tmux_session(target)
184
+ rescue Tmux::Error => error
185
+ text_content("Error: #{error.message}")
186
+ end
187
+ end
188
+
189
+ include ActionHandlers
190
+
191
+ # --- helpers ---
192
+
193
+ def text_content(text)
194
+ { content: [{ type: "text", text: text }] }
195
+ end
196
+
197
+ # Pane formatting and status detection.
198
+ module PaneOperations
199
+ private
200
+
201
+ def format_pane(pane)
202
+ target, path = pane.values_at(:target, :path)
203
+ project = File.basename(path)
204
+ status = detect_pane_status(target)
205
+ label = Reactions::PANE_STATUS_LABELS.fetch(status, "Idle")
206
+ "- `#{target}` — #{project} (#{label})"
207
+ end
208
+
209
+ def detect_pane_status(target)
210
+ output = @tmux.capture_pane(target, lines: 20)
211
+ classify_pane_output(output)
212
+ rescue Tmux::Error => error
213
+ log(:debug, "detect_pane_status failed for #{target}: #{error.message}")
214
+ :idle
215
+ end
216
+
217
+ def classify_pane_output(output)
218
+ return :permission if output.include?("Do you want to proceed?")
219
+ return :active if output.include?("esc to interrupt")
220
+
221
+ :idle
222
+ end
223
+
224
+ def kill_tmux_session(target)
225
+ msg = begin
226
+ @tmux.kill_session(target)
227
+ "Killed tmux session `#{target}`."
228
+ rescue Tmux::NotFound
229
+ "Error: session '#{target}' not found (cleaned up store)"
230
+ end
231
+ @tmux_store.delete(target)
232
+ text_content(msg)
233
+ end
234
+ end
235
+
236
+ # Spawn argument validation and request building.
237
+ module SpawnValidation
238
+ private
239
+
240
+ def validate_spawn_args(arguments)
241
+ error = validate_prompt(arguments)
242
+ error ||= validate_working_dir(arguments)
243
+ error || validate_session_or_name(arguments)
244
+ end
245
+
246
+ def validate_prompt(arguments)
247
+ prompt = arguments["prompt"]
248
+ text_content("Error: prompt is required for spawn") unless prompt && !prompt.strip.empty?
249
+ end
250
+
251
+ def validate_working_dir(arguments)
252
+ working_dir = arguments["working_dir"]
253
+ text_content("Error: directory '#{working_dir}' not found") if working_dir && !Dir.exist?(working_dir)
254
+ end
255
+
256
+ def validate_session_or_name(arguments)
257
+ session = arguments["session"]
258
+ if session
259
+ validate_existing_session(session)
260
+ else
261
+ validate_new_session_name(arguments)
262
+ end
263
+ end
264
+
265
+ def validate_existing_session(session)
266
+ text_content("Error: session '#{session}' not found") unless @tmux.session_exists?(session)
267
+ end
268
+
269
+ def validate_new_session_name(arguments)
270
+ name = arguments["name"] || generate_session_name
271
+ if name.match?(/[.:]/)
272
+ return text_content("Error: session name '#{name}' cannot contain '.' or ':' (tmux target delimiters)")
273
+ end
274
+
275
+ text_content("Error: session '#{name}' already exists") if @tmux.session_exists?(name)
276
+ end
277
+
278
+ def generate_session_name
279
+ "earl-#{Time.now.strftime("%Y%m%d%H%M%S")}-#{SecureRandom.hex(2)}"
280
+ end
281
+
282
+ def build_spawn_request(arguments)
283
+ name, prompt, working_dir, session = arguments.values_at("name", "prompt", "working_dir", "session")
284
+ SpawnRequest.new(
285
+ name: name || generate_session_name, prompt: prompt,
286
+ working_dir: working_dir, session: session
287
+ )
288
+ end
289
+ end
290
+
291
+ # Spawn confirmation, session creation, and Mattermost messaging.
292
+ module SpawnConfirmation
293
+ private
294
+
295
+ def execute_spawn(request)
296
+ case request_spawn_confirmation(request)
297
+ when :approved then create_spawned_session(request)
298
+ when :error then text_content("Error: spawn confirmation failed (could not post or connect to Mattermost)")
299
+ else text_content("Spawn denied by user.")
300
+ end
301
+ end
302
+
303
+ def create_spawned_session(request)
304
+ name, prompt, working_dir, session = request.deconstruct
305
+ command = "claude #{Shellwords.shellescape(prompt)}"
306
+ if session
307
+ @tmux.create_window(session: session, name: name, command: command, working_dir: working_dir)
308
+ else
309
+ @tmux.create_session(name: name, command: command, working_dir: working_dir)
310
+ end
311
+ persist_session_info(name, working_dir, prompt)
312
+ mode = session ? "window in `#{session}`" : "session"
313
+ text_content("Spawned tmux #{mode} `#{name}`.\n- Prompt: #{prompt}\n- Dir: #{working_dir || Dir.pwd}")
314
+ end
315
+
316
+ def persist_session_info(name, working_dir, prompt)
317
+ info = TmuxSessionStore::TmuxSessionInfo.new(
318
+ name: name, channel_id: @config.platform_channel_id,
319
+ thread_id: @config.platform_thread_id,
320
+ working_dir: working_dir, prompt: prompt, created_at: Time.now.iso8601
321
+ )
322
+ @tmux_store.save(info)
323
+ end
324
+
325
+ def request_spawn_confirmation(request)
326
+ post_id = post_confirmation_request(request)
327
+ return :error unless post_id
328
+
329
+ add_reaction_options(post_id)
330
+ wait_for_confirmation(post_id)
331
+ ensure
332
+ delete_confirmation_post(post_id) if post_id
333
+ end
334
+
335
+ def post_confirmation_request(request)
336
+ message = build_confirmation_message(request)
337
+ post_to_channel(message)
338
+ rescue IOError, JSON::ParserError, Errno::ECONNREFUSED, Errno::ECONNRESET => error
339
+ log(:error, "Failed to post spawn confirmation: #{error.message}")
340
+ nil
341
+ end
342
+
343
+ def build_confirmation_message(request)
344
+ name, prompt, working_dir, session = request.deconstruct
345
+ dir_line = working_dir ? "\n- **Dir:** #{working_dir}" : ""
346
+ session_line = session ? "\n- **Session:** #{session} (new window)" : ""
347
+ ":rocket: **Spawn Request**\n" \
348
+ "Claude wants to spawn #{session ? "window" : "session"} `#{name}`\n" \
349
+ "- **Prompt:** #{prompt}#{dir_line}#{session_line}\n" \
350
+ "React: :+1: approve | :-1: deny"
351
+ end
352
+
353
+ def post_to_channel(message)
354
+ response = @api.post("/posts", {
355
+ channel_id: @config.platform_channel_id,
356
+ message: message,
357
+ root_id: @config.platform_thread_id
358
+ })
359
+
360
+ return unless response.is_a?(Net::HTTPSuccess)
361
+
362
+ JSON.parse(response.body)["id"]
363
+ end
364
+
365
+ def add_reaction_options(post_id)
366
+ Reactions::ALL.each do |emoji|
367
+ response = @api.post("/reactions", {
368
+ user_id: @config.platform_bot_id,
369
+ post_id: post_id,
370
+ emoji_name: emoji
371
+ })
372
+ log(:warn, "Failed to add reaction #{emoji} to post #{post_id}") unless response.is_a?(Net::HTTPSuccess)
373
+ end
374
+ rescue IOError, Errno::ECONNREFUSED, Errno::ECONNRESET => error
375
+ log(:error, "Failed to add reaction options to post #{post_id}: #{error.message}")
376
+ end
377
+ end
378
+
379
+ # WebSocket-based polling for spawn confirmation reactions.
380
+ module SpawnPolling
381
+ private
382
+
383
+ def wait_for_confirmation(post_id)
384
+ timeout_sec = @config.permission_timeout_ms / 1000.0
385
+ deadline = Time.now + timeout_sec
386
+
387
+ websocket = connect_websocket
388
+ return :error unless websocket
389
+
390
+ poll_confirmation(websocket, post_id, deadline)
391
+ ensure
392
+ close_websocket(websocket)
393
+ end
394
+
395
+ def close_websocket(websocket)
396
+ websocket&.close
397
+ rescue IOError, Errno::ECONNRESET => error
398
+ log(:debug, "Failed to close spawn confirmation WebSocket: #{error.message}")
399
+ end
400
+
401
+ def connect_websocket
402
+ websocket = WebSocket::Client::Simple.connect(@config.websocket_url)
403
+ token = @config.platform_token
404
+ ws_ref = websocket
405
+ websocket.on(:open) do
406
+ ws_ref.send(JSON.generate({ seq: 1, action: "authentication_challenge", data: { token: token } }))
407
+ end
408
+ websocket
409
+ rescue IOError, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH => error
410
+ log(:error, "Spawn confirmation WebSocket failed: #{error.message}")
411
+ nil
412
+ end
413
+
414
+ def poll_confirmation(websocket, post_id, deadline)
415
+ queue = setup_reaction_listener(websocket, post_id)
416
+ await_reaction(queue, deadline)
417
+ end
418
+
419
+ def setup_reaction_listener(websocket, post_id)
420
+ queue = Queue.new
421
+ websocket.on(:message) do |msg|
422
+ reaction_data = parse_reaction_event(msg)
423
+ next unless reaction_data
424
+
425
+ matches_post = reaction_data["post_id"] == post_id
426
+ queue.push(reaction_data) if matches_post
427
+ end
428
+ queue
429
+ end
430
+
431
+ def parse_reaction_event(msg)
432
+ raw = msg.data
433
+ return unless raw && !raw.empty?
434
+
435
+ parsed = JSON.parse(raw)
436
+ event_name, nested_data = parsed.values_at("event", "data")
437
+ return unless event_name == "reaction_added"
438
+
439
+ reaction_json = nested_data&.dig("reaction") || "{}"
440
+ JSON.parse(reaction_json)
441
+ rescue JSON::ParserError
442
+ log(:debug, "Spawn confirmation: skipped unparsable WebSocket message")
443
+ nil
444
+ end
445
+
446
+ def await_reaction(queue, deadline)
447
+ loop do
448
+ remaining = deadline - Time.now
449
+ return :denied if remaining <= 0
450
+
451
+ reaction = dequeue_reaction(queue)
452
+ next unless reaction
453
+
454
+ result = classify_reaction(reaction)
455
+ return result if result
456
+ end
457
+ end
458
+
459
+ def dequeue_reaction(queue)
460
+ queue.pop(true)
461
+ rescue ThreadError
462
+ sleep 0.5
463
+ nil
464
+ end
465
+
466
+ def classify_reaction(reaction)
467
+ user_id = reaction["user_id"]
468
+ return if user_id == @config.platform_bot_id
469
+ return unless allowed_reactor?(user_id)
470
+
471
+ emoji = reaction["emoji_name"]
472
+ return :approved if Reactions::APPROVE_EMOJIS.include?(emoji)
473
+
474
+ :denied if Reactions::DENY_EMOJIS.include?(emoji)
475
+ end
476
+
477
+ def allowed_reactor?(user_id)
478
+ allowed = @config.allowed_users
479
+ return true if allowed.empty?
480
+
481
+ response = @api.get("/users/#{user_id}")
482
+ return false unless response.is_a?(Net::HTTPSuccess)
483
+
484
+ user = JSON.parse(response.body)
485
+ allowed.include?(user["username"])
486
+ end
487
+
488
+ def delete_confirmation_post(post_id)
489
+ @api.delete("/posts/#{post_id}")
490
+ rescue StandardError => error
491
+ log(:warn, "Failed to delete spawn confirmation: #{error.message}")
492
+ end
493
+ end
494
+
495
+ # Tool definition building: splits the large schema into composable property groups.
496
+ module ToolDefinitionBuilder
497
+ private
498
+
499
+ def tool_definition
500
+ {
501
+ name: TOOL_NAME,
502
+ description: tmux_tool_description,
503
+ inputSchema: tmux_input_schema
504
+ }
505
+ end
506
+
507
+ def tmux_tool_description
508
+ "Manage Claude sessions running in tmux. " \
509
+ "List sessions, capture output, approve/deny permissions, " \
510
+ "send input, spawn new sessions, or kill sessions."
511
+ end
512
+
513
+ def tmux_input_schema
514
+ { type: "object", properties: tmux_properties, required: %w[action] }
515
+ end
516
+
517
+ def tmux_properties
518
+ {}.merge(tmux_action_properties)
519
+ .merge(tmux_capture_properties)
520
+ .merge(tmux_spawn_properties)
521
+ end
522
+
523
+ def tmux_action_properties
524
+ {
525
+ action: { type: "string", enum: VALID_ACTIONS, description: "Action to perform" },
526
+ target: {
527
+ type: "string",
528
+ description: "Tmux pane target (e.g., 'session:window.pane'). " \
529
+ "Required for capture, status, approve, deny, send_input, kill."
530
+ }
531
+ }
532
+ end
533
+
534
+ def tmux_capture_properties
535
+ {
536
+ text: { type: "string", description: "Text to send (required for send_input)" },
537
+ lines: { type: "integer", description: "Number of lines to capture (default 100, for capture action)" }
538
+ }
539
+ end
540
+
541
+ def tmux_spawn_properties
542
+ {
543
+ prompt: { type: "string", description: "Prompt for new Claude session (required for spawn)" },
544
+ name: { type: "string", description: "Session name for spawn (auto-generated if omitted)" },
545
+ working_dir: { type: "string", description: "Working directory for spawn" },
546
+ session: {
547
+ type: "string",
548
+ description: "Existing tmux session to add a window to (for spawn). " \
549
+ "If omitted, creates a new session."
550
+ }
551
+ }
552
+ end
553
+ end
554
+
555
+ include PaneOperations
556
+ include SpawnValidation
557
+ include SpawnConfirmation
558
+ include SpawnPolling
559
+ include ToolDefinitionBuilder
560
+ end
561
+ end
562
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Memory
5
+ # Builds the --append-system-prompt text from memory files.
6
+ # Combines SOUL.md, USER.md, and recent episodic memories into a single
7
+ # prompt string wrapped in <earl-memory> tags.
8
+ class PromptBuilder
9
+ def initialize(store:)
10
+ @store = store
11
+ end
12
+
13
+ def build
14
+ body = build_sections.join("\n\n")
15
+ return nil if body.empty?
16
+
17
+ "<earl-memory>\n#{body}\n</earl-memory>\n\n" \
18
+ "You have persistent memory via save_memory and search_memory tools.\n" \
19
+ "Save important facts you learn. Search when you need to recall something."
20
+ end
21
+
22
+ private
23
+
24
+ def build_sections
25
+ sections = []
26
+ append_section(sections, "Core Identity", @store.soul)
27
+ append_section(sections, "User Notes", @store.users)
28
+ append_section(sections, "Recent Memories", @store.recent_memories)
29
+ sections
30
+ end
31
+
32
+ def append_section(sections, heading, content)
33
+ stripped = content.to_s.strip
34
+ return if stripped.empty?
35
+
36
+ sections << "## #{heading}\n#{stripped}"
37
+ end
38
+ end
39
+ end
40
+ end