pocketrb 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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. metadata +327 -0
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Pocketrb
6
+ module Agent
7
+ # Manages spawning and coordinating subagents
8
+ class SubagentManager
9
+ attr_reader :active_agents
10
+
11
+ def initialize(parent_loop:)
12
+ @parent_loop = parent_loop
13
+ @active_agents = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # Spawn a new subagent for a specific task
18
+ # @param task [String] Task description
19
+ # @param skills [Array<String>] Skills to load for this agent
20
+ # @param origin_channel [Symbol] Channel to report back to
21
+ # @param origin_chat_id [String] Chat to report back to
22
+ # @param model [String] Model to use (defaults to parent's model)
23
+ # @return [String] Agent ID
24
+ def spawn(task:, origin_channel:, origin_chat_id:, skills: [], model: nil)
25
+ agent_id = SecureRandom.uuid[0..7]
26
+
27
+ agent_info = {
28
+ id: agent_id,
29
+ task: task,
30
+ skills: skills,
31
+ origin_channel: origin_channel,
32
+ origin_chat_id: origin_chat_id,
33
+ model: model || @parent_loop.model,
34
+ status: :starting,
35
+ started_at: Time.now,
36
+ result: nil
37
+ }
38
+
39
+ @mutex.synchronize do
40
+ @active_agents[agent_id] = agent_info
41
+ end
42
+
43
+ # Run agent in async task
44
+ Async do
45
+ run_agent(agent_id, agent_info)
46
+ end
47
+
48
+ Pocketrb.logger.info("Spawned subagent #{agent_id} for task: #{task[0..50]}...")
49
+ agent_id
50
+ end
51
+
52
+ # Get status of a subagent
53
+ # @param agent_id [String]
54
+ # @return [Hash|nil]
55
+ def get_status(agent_id)
56
+ @mutex.synchronize { @active_agents[agent_id]&.dup }
57
+ end
58
+
59
+ # List all active agents
60
+ # @return [Array<Hash>]
61
+ def list_active
62
+ @mutex.synchronize do
63
+ @active_agents.values.select { |a| a[:status] == :running }
64
+ end
65
+ end
66
+
67
+ # Terminate a subagent
68
+ # @param agent_id [String]
69
+ def terminate(agent_id)
70
+ @mutex.synchronize do
71
+ @active_agents[agent_id][:status] = :terminated if @active_agents[agent_id]
72
+ end
73
+ end
74
+
75
+ # Wait for a subagent to complete
76
+ # @param agent_id [String]
77
+ # @param timeout [Integer] Timeout in seconds
78
+ # @return [String|nil] Result
79
+ def wait_for(agent_id, timeout: 300)
80
+ deadline = Time.now + timeout
81
+
82
+ loop do
83
+ status = get_status(agent_id)
84
+ return nil unless status
85
+
86
+ case status[:status]
87
+ when :completed
88
+ return status[:result]
89
+ when :failed, :terminated
90
+ return nil
91
+ end
92
+
93
+ break if Time.now > deadline
94
+
95
+ sleep 0.5
96
+ end
97
+
98
+ nil
99
+ end
100
+
101
+ private
102
+
103
+ def run_agent(agent_id, info)
104
+ update_status(agent_id, :running)
105
+
106
+ # Create isolated context for subagent
107
+ system_prompt = build_subagent_prompt(info)
108
+
109
+ # Create a mini message bus for this agent
110
+ sub_bus = Bus::MessageBus.new
111
+
112
+ # Create the subagent loop
113
+ sub_loop = Loop.new(
114
+ bus: sub_bus,
115
+ provider: @parent_loop.provider,
116
+ workspace: @parent_loop.workspace,
117
+ model: info[:model],
118
+ max_iterations: 30,
119
+ system_prompt: system_prompt
120
+ )
121
+
122
+ # Load requested skills
123
+ load_skills(sub_loop, info[:skills])
124
+
125
+ # Process the task
126
+ msg = Bus::InboundMessage.new(
127
+ channel: :subagent,
128
+ sender_id: "parent",
129
+ chat_id: agent_id,
130
+ content: info[:task]
131
+ )
132
+
133
+ response = sub_loop.process_message(msg)
134
+
135
+ # Report result
136
+ result = response&.content || "Task completed with no output"
137
+ update_status(agent_id, :completed, result: result)
138
+
139
+ # Send result back to origin
140
+ announce_result(info, result)
141
+ rescue StandardError => e
142
+ Pocketrb.logger.error("Subagent #{agent_id} failed: #{e.message}")
143
+ update_status(agent_id, :failed, result: "Error: #{e.message}")
144
+ announce_result(info, "Subagent failed: #{e.message}")
145
+ end
146
+
147
+ def update_status(agent_id, status, result: nil)
148
+ @mutex.synchronize do
149
+ if @active_agents[agent_id]
150
+ @active_agents[agent_id][:status] = status
151
+ @active_agents[agent_id][:result] = result if result
152
+ @active_agents[agent_id][:completed_at] = Time.now if %i[completed failed terminated].include?(status)
153
+ end
154
+ end
155
+ end
156
+
157
+ def build_subagent_prompt(info)
158
+ <<~PROMPT
159
+ You are a specialized subagent spawned to complete a specific task.
160
+ You should focus exclusively on the task and report your findings concisely.
161
+
162
+ Your task: #{info[:task]}
163
+
164
+ Guidelines:
165
+ - Focus only on the assigned task
166
+ - Be concise in your response
167
+ - If you need information you don't have, say so
168
+ - Complete the task as efficiently as possible
169
+ PROMPT
170
+ end
171
+
172
+ def load_skills(loop, skill_names)
173
+ return if skill_names.empty?
174
+
175
+ skills_loader = Skills::Loader.new(workspace: @parent_loop.workspace)
176
+ skill_content = skill_names.filter_map do |name|
177
+ skill = skills_loader.load_skill(name)
178
+ skill&.to_prompt
179
+ end.join("\n\n")
180
+
181
+ loop.context.append_to_system_prompt(skill_content) unless skill_content.empty?
182
+ end
183
+
184
+ def announce_result(info, result)
185
+ outbound = Bus::OutboundMessage.new(
186
+ channel: info[:origin_channel],
187
+ chat_id: info[:origin_chat_id],
188
+ content: "**Subagent completed:**\n\n#{result}",
189
+ metadata: { subagent_id: info[:id] }
190
+ )
191
+
192
+ @parent_loop.bus.publish_outbound(outbound)
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ # Bus module provides the message bus architecture for multi-channel communication
5
+ module Bus
6
+ # Inbound message from a channel to the agent
7
+ InboundMessage = Data.define(
8
+ :channel, # Symbol - channel identifier (:cli, :telegram, :discord, etc.)
9
+ :sender_id, # String - unique sender identifier
10
+ :chat_id, # String - unique chat/conversation identifier
11
+ :content, # String - text content of the message
12
+ :media, # Array<Media> - attached media (images, files, etc.)
13
+ :metadata # Hash - channel-specific metadata
14
+ ) do
15
+ def initialize(channel:, sender_id:, chat_id:, content:, media: [], metadata: {})
16
+ super
17
+ end
18
+
19
+ # Unique key for session management
20
+ def session_key
21
+ "#{channel}:#{chat_id}"
22
+ end
23
+
24
+ # Check if message has media attachments
25
+ def has_media?
26
+ media && !media.empty?
27
+ end
28
+ end
29
+
30
+ # Outbound message from the agent to a channel
31
+ OutboundMessage = Data.define(
32
+ :channel, # Symbol - target channel
33
+ :chat_id, # String - target chat/conversation
34
+ :content, # String - text content to send
35
+ :media, # Array<Media> - media to attach
36
+ :reply_to, # String|nil - message ID to reply to
37
+ :metadata # Hash - channel-specific options
38
+ ) do
39
+ def initialize(channel:, chat_id:, content:, media: [], reply_to: nil, metadata: {})
40
+ super
41
+ end
42
+ end
43
+
44
+ # Media attachment
45
+ Media = Data.define(
46
+ :type, # Symbol - :image, :file, :audio, :video
47
+ :path, # String - file path or URL
48
+ :mime_type, # String - MIME type
49
+ :filename, # String|nil - original filename
50
+ :data # String|nil - base64 encoded data (for inline media)
51
+ ) do
52
+ def initialize(type:, path:, mime_type:, filename: nil, data: nil)
53
+ super
54
+ end
55
+
56
+ def image?
57
+ type == :image
58
+ end
59
+
60
+ def file?
61
+ type == :file
62
+ end
63
+ end
64
+
65
+ # Tool execution event
66
+ ToolExecution = Data.define(
67
+ :tool_call_id, # String - unique tool call identifier
68
+ :name, # String - tool name
69
+ :arguments, # Hash - tool arguments
70
+ :result, # String|nil - execution result
71
+ :error, # String|nil - error message if failed
72
+ :duration_ms # Integer|nil - execution time in milliseconds
73
+ ) do
74
+ def initialize(tool_call_id:, name:, arguments:, result: nil, error: nil, duration_ms: nil)
75
+ super
76
+ end
77
+
78
+ def success?
79
+ error.nil?
80
+ end
81
+
82
+ def failed?
83
+ !success?
84
+ end
85
+ end
86
+
87
+ # Agent state change event
88
+ StateChange = Data.define(
89
+ :session_key, # String - session identifier
90
+ :from_state, # Symbol - previous state
91
+ :to_state, # Symbol - new state
92
+ :reason # String|nil - reason for change
93
+ ) do
94
+ def initialize(session_key:, from_state:, to_state:, reason: nil)
95
+ super
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+
6
+ module Pocketrb
7
+ module Bus
8
+ # Thread-safe async message bus for agent communication
9
+ class MessageBus
10
+ attr_reader :stats
11
+
12
+ def initialize
13
+ @inbound = Async::Queue.new
14
+ @outbound = Async::Queue.new
15
+ @tool_events = Async::Queue.new
16
+ @state_events = Async::Queue.new
17
+ @subscribers = { inbound: [], outbound: [], tool: [], state: [] }
18
+ @mutex = Mutex.new
19
+ @stats = Stats.new
20
+ @running = false
21
+ end
22
+
23
+ # Publish an inbound message from a channel
24
+ def publish_inbound(message)
25
+ raise ArgumentError, "Expected InboundMessage" unless message.is_a?(InboundMessage)
26
+
27
+ @stats.increment(:inbound)
28
+ @inbound.enqueue(message)
29
+ notify_subscribers(:inbound, message)
30
+ end
31
+
32
+ # Consume the next inbound message (blocking)
33
+ def consume_inbound
34
+ @inbound.dequeue
35
+ end
36
+
37
+ # Publish an outbound message to a channel
38
+ def publish_outbound(message)
39
+ raise ArgumentError, "Expected OutboundMessage" unless message.is_a?(OutboundMessage)
40
+
41
+ @stats.increment(:outbound)
42
+ @outbound.enqueue(message)
43
+ notify_subscribers(:outbound, message)
44
+ end
45
+
46
+ # Consume the next outbound message (blocking)
47
+ def consume_outbound
48
+ @outbound.dequeue
49
+ end
50
+
51
+ # Publish a tool execution event
52
+ def publish_tool_event(event)
53
+ raise ArgumentError, "Expected ToolExecution" unless event.is_a?(ToolExecution)
54
+
55
+ @stats.increment(:tool_executions)
56
+ @tool_events.enqueue(event)
57
+ notify_subscribers(:tool, event)
58
+ end
59
+
60
+ # Consume the next tool event (blocking)
61
+ def consume_tool_event
62
+ @tool_events.dequeue
63
+ end
64
+
65
+ # Publish a state change event
66
+ def publish_state_event(event)
67
+ raise ArgumentError, "Expected StateChange" unless event.is_a?(StateChange)
68
+
69
+ @stats.increment(:state_changes)
70
+ @state_events.enqueue(event)
71
+ notify_subscribers(:state, event)
72
+ end
73
+
74
+ # Subscribe to events
75
+ def subscribe(type, &block)
76
+ raise ArgumentError, "Unknown event type: #{type}" unless @subscribers.key?(type)
77
+
78
+ @mutex.synchronize do
79
+ @subscribers[type] << block
80
+ end
81
+ end
82
+
83
+ # Unsubscribe from events
84
+ def unsubscribe(type, block)
85
+ @mutex.synchronize do
86
+ @subscribers[type].delete(block)
87
+ end
88
+ end
89
+
90
+ # Check if queues have pending messages
91
+ def pending_inbound?
92
+ !@inbound.empty?
93
+ end
94
+
95
+ def pending_outbound?
96
+ !@outbound.empty?
97
+ end
98
+
99
+ # Clear all queues
100
+ def clear!
101
+ @inbound = Async::Queue.new
102
+ @outbound = Async::Queue.new
103
+ @tool_events = Async::Queue.new
104
+ @state_events = Async::Queue.new
105
+ @stats.reset!
106
+ end
107
+
108
+ private
109
+
110
+ def notify_subscribers(type, event)
111
+ subscribers = @mutex.synchronize { @subscribers[type].dup }
112
+ subscribers.each do |handler|
113
+ handler.call(event)
114
+ rescue StandardError => e
115
+ Pocketrb.logger.error("Subscriber error for #{type}: #{e.message}")
116
+ end
117
+ end
118
+
119
+ # Statistics tracker
120
+ class Stats
121
+ attr_reader :data
122
+
123
+ def initialize
124
+ @data = { inbound: 0, outbound: 0, tool_executions: 0, state_changes: 0 }
125
+ @mutex = Mutex.new
126
+ end
127
+
128
+ def increment(key)
129
+ @mutex.synchronize { @data[key] += 1 }
130
+ end
131
+
132
+ def [](key)
133
+ @mutex.synchronize { @data[key] }
134
+ end
135
+
136
+ def reset!
137
+ @mutex.synchronize do
138
+ @data.transform_values! { 0 }
139
+ end
140
+ end
141
+
142
+ def to_h
143
+ @mutex.synchronize { @data.dup }
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Channels
5
+ # Base class for channel adapters
6
+ class Base
7
+ attr_reader :bus, :name
8
+
9
+ def initialize(bus:, name: nil)
10
+ @bus = bus
11
+ @name = name || self.class.name.split("::").last.downcase.to_sym
12
+ @running = false
13
+ end
14
+
15
+ # Start the channel
16
+ def run
17
+ @running = true
18
+ start_outbound_consumer
19
+ run_inbound_loop
20
+ end
21
+
22
+ # Stop the channel
23
+ def stop
24
+ @running = false
25
+ end
26
+
27
+ # Check if channel is running
28
+ def running?
29
+ @running
30
+ end
31
+
32
+ protected
33
+
34
+ # Override in subclasses to implement inbound message handling
35
+ def run_inbound_loop
36
+ raise NotImplementedError
37
+ end
38
+
39
+ # Override in subclasses to send outbound messages
40
+ def send_message(message)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ private
45
+
46
+ def start_outbound_consumer
47
+ Async do
48
+ while @running
49
+ message = @bus.consume_outbound
50
+ next unless message.channel == @name
51
+
52
+ send_message(message)
53
+ end
54
+ end
55
+ end
56
+
57
+ def create_inbound_message(sender_id:, chat_id:, content:, media: [], metadata: {})
58
+ Bus::InboundMessage.new(
59
+ channel: @name,
60
+ sender_id: sender_id,
61
+ chat_id: chat_id,
62
+ content: content,
63
+ media: media,
64
+ metadata: metadata
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ # Channels module provides multi-channel support (CLI, Telegram, WhatsApp, etc.)
5
+ module Channels
6
+ # Interactive CLI channel
7
+ class CLI < Base
8
+ def initialize(bus:, prompt: "> ")
9
+ super(bus: bus, name: :cli)
10
+ @prompt = prompt
11
+ @output_mutex = Mutex.new
12
+ end
13
+
14
+ protected
15
+
16
+ def run_inbound_loop
17
+ Async do
18
+ while @running
19
+ print @prompt
20
+ input = read_input
21
+ break if input.nil?
22
+
23
+ next if input.empty?
24
+
25
+ # Handle special commands
26
+ next if handle_command(input)
27
+
28
+ # Create and publish inbound message
29
+ message = create_inbound_message(
30
+ sender_id: "user",
31
+ chat_id: "cli",
32
+ content: input
33
+ )
34
+
35
+ @bus.publish_inbound(message)
36
+ end
37
+ end
38
+ end
39
+
40
+ def send_message(message)
41
+ @output_mutex.synchronize do
42
+ puts "\n#{format_output(message.content)}\n"
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def read_input
49
+ line = $stdin.gets
50
+ return nil if line.nil?
51
+
52
+ line.chomp
53
+ rescue Interrupt
54
+ nil
55
+ end
56
+
57
+ def handle_command(input)
58
+ case input.downcase
59
+ when "exit", "quit", "/exit", "/quit"
60
+ @running = false
61
+ puts "Goodbye!"
62
+ true
63
+ when "/help"
64
+ print_help
65
+ true
66
+ when "/clear"
67
+ system("clear") || system("cls")
68
+ true
69
+ when "/stats"
70
+ print_stats
71
+ true
72
+ else
73
+ false
74
+ end
75
+ end
76
+
77
+ def print_help
78
+ puts <<~HELP
79
+
80
+ Commands:
81
+ /help - Show this help
82
+ /clear - Clear the screen
83
+ /stats - Show message statistics
84
+ /exit - Exit the chat
85
+
86
+ HELP
87
+ end
88
+
89
+ def print_stats
90
+ stats = @bus.stats.to_h
91
+ puts <<~STATS
92
+
93
+ Statistics:
94
+ Inbound messages: #{stats[:inbound]}
95
+ Outbound messages: #{stats[:outbound]}
96
+ Tool executions: #{stats[:tool_executions]}
97
+
98
+ STATS
99
+ end
100
+
101
+ def format_output(content)
102
+ return "" if content.nil? || content.empty?
103
+
104
+ # Simple formatting - could be enhanced with tty-markdown
105
+ content
106
+ end
107
+ end
108
+ end
109
+ end