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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- 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
|