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,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Pocketrb
|
|
7
|
+
module Channels
|
|
8
|
+
# WhatsApp channel via Node.js bridge WebSocket
|
|
9
|
+
# Connects to a whatsapp-web.js bridge running on localhost
|
|
10
|
+
#
|
|
11
|
+
# Bridge Protocol:
|
|
12
|
+
# - Receive: {type: "message", sender: "...", content: "...", timestamp: ..., isGroup: bool}
|
|
13
|
+
# - Send: {type: "send", to: "phone@s.whatsapp.net", text: "..."}
|
|
14
|
+
# - Status: {type: "status", status: "connected"}
|
|
15
|
+
# - QR: {type: "qr", qr: "data:image/png;base64,..."}
|
|
16
|
+
class WhatsApp < Base
|
|
17
|
+
RECONNECT_DELAY = 5
|
|
18
|
+
DEFAULT_BRIDGE_URL = "ws://localhost:3001"
|
|
19
|
+
|
|
20
|
+
def initialize(bus:, bridge_url: DEFAULT_BRIDGE_URL, allowed_users: nil, download_media: true)
|
|
21
|
+
super(bus: bus, name: :whatsapp)
|
|
22
|
+
@bridge_url = bridge_url
|
|
23
|
+
@allowed_users = allowed_users # Array of phone numbers
|
|
24
|
+
@download_media = download_media
|
|
25
|
+
@ws = nil
|
|
26
|
+
@connected = false
|
|
27
|
+
@media_processor = Media::Processor.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check if we can connect to the bridge
|
|
31
|
+
def available?
|
|
32
|
+
require "websocket-client-simple"
|
|
33
|
+
true
|
|
34
|
+
rescue LoadError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
protected
|
|
39
|
+
|
|
40
|
+
def run_inbound_loop
|
|
41
|
+
require "websocket-client-simple"
|
|
42
|
+
|
|
43
|
+
Pocketrb.logger.info("Starting WhatsApp channel (bridge: #{@bridge_url})")
|
|
44
|
+
|
|
45
|
+
loop do
|
|
46
|
+
break unless @running
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
connect_and_listen
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
Pocketrb.logger.error("WhatsApp connection error: #{e.message}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if @running
|
|
55
|
+
Pocketrb.logger.info("Reconnecting to WhatsApp bridge in #{RECONNECT_DELAY}s...")
|
|
56
|
+
sleep RECONNECT_DELAY
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
rescue LoadError
|
|
60
|
+
Pocketrb.logger.error("websocket-client-simple gem not installed. Run: gem install websocket-client-simple")
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def send_message(message)
|
|
65
|
+
return unless @ws && @connected
|
|
66
|
+
|
|
67
|
+
jid = to_jid(message.chat_id)
|
|
68
|
+
payload = {
|
|
69
|
+
type: "send",
|
|
70
|
+
to: jid,
|
|
71
|
+
text: message.content
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@ws.send(payload.to_json)
|
|
75
|
+
Pocketrb.logger.debug("WhatsApp: sent message to #{jid}")
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
Pocketrb.logger.error("WhatsApp send error: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def connect_and_listen
|
|
83
|
+
channel = self
|
|
84
|
+
|
|
85
|
+
@ws = WebSocket::Client::Simple.connect(@bridge_url)
|
|
86
|
+
|
|
87
|
+
@ws.on :open do
|
|
88
|
+
channel.instance_variable_set(:@connected, true)
|
|
89
|
+
Pocketrb.logger.info("WhatsApp: connected to bridge")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@ws.on :message do |msg|
|
|
93
|
+
channel.send(:handle_bridge_message, msg.data)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@ws.on :error do |e|
|
|
97
|
+
Pocketrb.logger.error("WhatsApp WebSocket error: #{e.message}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@ws.on :close do |_e|
|
|
101
|
+
channel.instance_variable_set(:@connected, false)
|
|
102
|
+
Pocketrb.logger.warn("WhatsApp: disconnected from bridge")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Block until disconnected
|
|
106
|
+
sleep 0.5 while @ws.open? && @running
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def handle_bridge_message(data)
|
|
110
|
+
message = JSON.parse(data)
|
|
111
|
+
|
|
112
|
+
case message["type"]
|
|
113
|
+
when "message"
|
|
114
|
+
handle_incoming_message(message)
|
|
115
|
+
when "status"
|
|
116
|
+
Pocketrb.logger.info("WhatsApp status: #{message["status"]}")
|
|
117
|
+
when "qr"
|
|
118
|
+
handle_qr(message)
|
|
119
|
+
when "ready"
|
|
120
|
+
Pocketrb.logger.info("WhatsApp: ready")
|
|
121
|
+
when "authenticated"
|
|
122
|
+
Pocketrb.logger.info("WhatsApp: authenticated")
|
|
123
|
+
when "error"
|
|
124
|
+
Pocketrb.logger.error("WhatsApp error: #{message["error"]}")
|
|
125
|
+
else
|
|
126
|
+
Pocketrb.logger.debug("WhatsApp: unknown message type: #{message["type"]}")
|
|
127
|
+
end
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
Pocketrb.logger.warn("WhatsApp: invalid JSON: #{e.message}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def handle_incoming_message(message)
|
|
133
|
+
sender = extract_phone(message["sender"] || message["from"])
|
|
134
|
+
return unless allowed_user?(sender)
|
|
135
|
+
|
|
136
|
+
# Skip if it's from self
|
|
137
|
+
return if message["fromMe"]
|
|
138
|
+
|
|
139
|
+
content = message["content"] || message["body"] || ""
|
|
140
|
+
chat_id = message["sender"] || message["from"]
|
|
141
|
+
is_group = message["isGroup"] || chat_id.include?("@g.us")
|
|
142
|
+
|
|
143
|
+
# Process media if present
|
|
144
|
+
media = []
|
|
145
|
+
if @download_media && message["hasMedia"]
|
|
146
|
+
media_item = process_whatsapp_media(message)
|
|
147
|
+
media << media_item if media_item
|
|
148
|
+
|
|
149
|
+
# Add indicator to content
|
|
150
|
+
content = "[Image attached - I can see this image]\n#{content}".strip if media_item&.image?
|
|
151
|
+
content = "[Media attached: #{media_item&.filename}]\n#{content}".strip if media_item && !media_item.image?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Skip if no content and no media
|
|
155
|
+
return if content.empty? && media.empty?
|
|
156
|
+
|
|
157
|
+
Pocketrb.logger.debug("WhatsApp message from #{sender}: #{content[0..50]}... (#{media.size} media)")
|
|
158
|
+
|
|
159
|
+
inbound = create_inbound_message(
|
|
160
|
+
sender_id: sender,
|
|
161
|
+
chat_id: chat_id,
|
|
162
|
+
content: content.empty? ? "[media only]" : content,
|
|
163
|
+
media: media,
|
|
164
|
+
metadata: {
|
|
165
|
+
is_group: is_group,
|
|
166
|
+
timestamp: message["timestamp"],
|
|
167
|
+
message_id: message["id"]
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@bus.publish_inbound(inbound)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def process_whatsapp_media(message)
|
|
175
|
+
return nil unless message["mediaData"] || message["mediaUrl"]
|
|
176
|
+
|
|
177
|
+
mime_type = message["mimetype"] || message["mediaType"] || "application/octet-stream"
|
|
178
|
+
filename = message["filename"] || "media_#{Time.now.to_i}.#{extension_for_mime(mime_type)}"
|
|
179
|
+
|
|
180
|
+
if message["mediaData"]
|
|
181
|
+
# Base64 encoded data from bridge
|
|
182
|
+
require "base64"
|
|
183
|
+
bytes = Base64.decode64(message["mediaData"])
|
|
184
|
+
@media_processor.from_bytes(bytes, mime_type: mime_type, filename: filename)
|
|
185
|
+
elsif message["mediaUrl"]
|
|
186
|
+
# URL to download
|
|
187
|
+
@media_processor.download(message["mediaUrl"], filename: filename, mime_type: mime_type)
|
|
188
|
+
end
|
|
189
|
+
rescue StandardError => e
|
|
190
|
+
Pocketrb.logger.warn("Failed to process WhatsApp media: #{e.message}")
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def extension_for_mime(mime_type)
|
|
195
|
+
case mime_type
|
|
196
|
+
when %r{^image/jpeg} then "jpg"
|
|
197
|
+
when %r{^image/png} then "png"
|
|
198
|
+
when %r{^image/gif} then "gif"
|
|
199
|
+
when %r{^image/webp} then "webp"
|
|
200
|
+
when %r{^audio/ogg} then "ogg"
|
|
201
|
+
when %r{^audio/mpeg} then "mp3"
|
|
202
|
+
when %r{^video/mp4} then "mp4"
|
|
203
|
+
else "bin"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def handle_qr(message)
|
|
208
|
+
return unless message["qr"]
|
|
209
|
+
|
|
210
|
+
Pocketrb.logger.info("WhatsApp: QR code received. Scan in bridge terminal or use the data URL.")
|
|
211
|
+
# Could save to file or display in terminal if needed
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def allowed_user?(phone)
|
|
215
|
+
return true if @allowed_users.nil? || @allowed_users.empty?
|
|
216
|
+
|
|
217
|
+
normalized = normalize_phone(phone)
|
|
218
|
+
@allowed_users.any? { |allowed| normalize_phone(allowed) == normalized }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def to_jid(phone)
|
|
222
|
+
return phone if phone.include?("@")
|
|
223
|
+
|
|
224
|
+
# Remove any non-digits
|
|
225
|
+
clean = phone.gsub(/\D/, "")
|
|
226
|
+
"#{clean}@s.whatsapp.net"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def extract_phone(jid)
|
|
230
|
+
return jid unless jid
|
|
231
|
+
|
|
232
|
+
jid.split("@").first
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def normalize_phone(phone)
|
|
236
|
+
return nil unless phone
|
|
237
|
+
|
|
238
|
+
phone.to_s.gsub(/\D/, "")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Pocketrb
|
|
6
|
+
class CLI
|
|
7
|
+
# Base class for CLI commands with common utilities
|
|
8
|
+
class Base < Thor
|
|
9
|
+
include Thor::Actions
|
|
10
|
+
|
|
11
|
+
# Configures Thor to exit with error status on command failure
|
|
12
|
+
# @return [Boolean] true to exit on failure
|
|
13
|
+
def self.exit_on_failure?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Set up logging based on verbose/quiet options
|
|
20
|
+
def setup_logging
|
|
21
|
+
if options[:verbose]
|
|
22
|
+
Pocketrb.logger.level = Logger::DEBUG
|
|
23
|
+
elsif options[:quiet]
|
|
24
|
+
Pocketrb.logger.level = Logger::ERROR
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resolve workspace directory from options or current directory
|
|
29
|
+
# @return [Pathname] workspace path
|
|
30
|
+
def resolve_workspace
|
|
31
|
+
path = options[:workspace] || Dir.pwd
|
|
32
|
+
Pathname.new(path).expand_path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Resolve memory directory from options or workspace
|
|
36
|
+
# @return [Pathname] memory directory path
|
|
37
|
+
def resolve_memory_dir
|
|
38
|
+
path = options[:memory_dir] || options[:workspace] || Dir.pwd
|
|
39
|
+
Pathname.new(path).expand_path
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Create provider instance from config
|
|
43
|
+
# @param config [Config] configuration object
|
|
44
|
+
# @return [Providers::Base] provider instance
|
|
45
|
+
def create_provider(config)
|
|
46
|
+
provider_name = config[:provider]&.to_sym || :anthropic
|
|
47
|
+
Providers::Registry.get(provider_name, config.provider_config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Handle cron job execution
|
|
51
|
+
# @param agent_loop [Agent::Loop] agent loop instance
|
|
52
|
+
# @param bus [Bus::MessageBus] message bus
|
|
53
|
+
# @param job [Cron::Job] cron job to execute
|
|
54
|
+
def handle_cron_job(agent_loop, bus, job)
|
|
55
|
+
if job.payload.deliver
|
|
56
|
+
# Deliver message directly to channel
|
|
57
|
+
outbound = Pocketrb::Bus::OutboundMessage.new(
|
|
58
|
+
channel: job.payload.channel&.to_sym || :cli,
|
|
59
|
+
chat_id: job.payload.to || "cron",
|
|
60
|
+
content: job.payload.message
|
|
61
|
+
)
|
|
62
|
+
bus.publish_outbound(outbound)
|
|
63
|
+
else
|
|
64
|
+
# Process as agent task
|
|
65
|
+
msg = Pocketrb::Bus::InboundMessage.new(
|
|
66
|
+
channel: :cron,
|
|
67
|
+
sender_id: "cron",
|
|
68
|
+
chat_id: job.id,
|
|
69
|
+
content: job.payload.message,
|
|
70
|
+
metadata: { job_id: job.id, job_name: job.name }
|
|
71
|
+
)
|
|
72
|
+
agent_loop.process_message(msg)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Process heartbeat message
|
|
77
|
+
# @param agent_loop [Agent::Loop] agent loop instance
|
|
78
|
+
# @param prompt [String] heartbeat prompt
|
|
79
|
+
# @return [String, nil] response content
|
|
80
|
+
def process_heartbeat(agent_loop, prompt)
|
|
81
|
+
msg = Pocketrb::Bus::InboundMessage.new(
|
|
82
|
+
channel: :heartbeat,
|
|
83
|
+
sender_id: "heartbeat",
|
|
84
|
+
chat_id: "heartbeat",
|
|
85
|
+
content: prompt
|
|
86
|
+
)
|
|
87
|
+
response = agent_loop.process_message(msg)
|
|
88
|
+
response&.content
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Default TOOLS.md content for new workspaces
|
|
92
|
+
# @return [String] markdown content
|
|
93
|
+
def default_tools_content
|
|
94
|
+
<<~MD
|
|
95
|
+
# Tools
|
|
96
|
+
|
|
97
|
+
This document describes the tools and skills available in this workspace.
|
|
98
|
+
|
|
99
|
+
## Built-in Tools
|
|
100
|
+
|
|
101
|
+
- **read_file**: Read file contents
|
|
102
|
+
- **write_file**: Write content to a file
|
|
103
|
+
- **edit_file**: Edit a file with search/replace
|
|
104
|
+
- **list_dir**: List directory contents
|
|
105
|
+
- **exec**: Execute shell commands
|
|
106
|
+
- **web_search**: Search the web
|
|
107
|
+
- **web_fetch**: Fetch web page content
|
|
108
|
+
- **think**: Internal reasoning tool
|
|
109
|
+
- **plan**: Create and manage execution plans
|
|
110
|
+
- **memory**: Search and store in long-term memory
|
|
111
|
+
|
|
112
|
+
## Skills
|
|
113
|
+
|
|
114
|
+
Create custom skills in the `skills/` directory.
|
|
115
|
+
MD
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
class CLI
|
|
5
|
+
# Chat command - interactive chat mode
|
|
6
|
+
class Chat < Base
|
|
7
|
+
desc "chat", "Interactive chat mode (single session)"
|
|
8
|
+
option :model, type: :string, aliases: "-m", desc: "Model to use"
|
|
9
|
+
option :provider, type: :string, aliases: "-p", desc: "LLM provider"
|
|
10
|
+
option :system_prompt, type: :string, aliases: "-s", desc: "Custom system prompt"
|
|
11
|
+
|
|
12
|
+
# Starts an interactive chat session with the agent
|
|
13
|
+
# @return [void]
|
|
14
|
+
def call
|
|
15
|
+
setup_logging
|
|
16
|
+
workspace = resolve_workspace
|
|
17
|
+
memory_dir = resolve_memory_dir
|
|
18
|
+
|
|
19
|
+
config = Pocketrb::Config.load(memory_dir)
|
|
20
|
+
config[:model] = options[:model] if options[:model]
|
|
21
|
+
config[:provider] = options[:provider] if options[:provider]
|
|
22
|
+
|
|
23
|
+
provider = create_provider(config)
|
|
24
|
+
bus = Pocketrb::Bus::MessageBus.new
|
|
25
|
+
|
|
26
|
+
agent_loop = Pocketrb::Agent::Loop.new(
|
|
27
|
+
bus: bus,
|
|
28
|
+
provider: provider,
|
|
29
|
+
workspace: workspace,
|
|
30
|
+
memory_dir: memory_dir,
|
|
31
|
+
model: config[:model],
|
|
32
|
+
system_prompt: options[:system_prompt],
|
|
33
|
+
mcp_endpoint: config[:mcp_endpoint]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
say "Pocketrb Chat - #{config[:model]}", :green
|
|
37
|
+
say "Memory: #{memory_dir}" if memory_dir != workspace
|
|
38
|
+
say "Type 'exit' or 'quit' to end session\n"
|
|
39
|
+
|
|
40
|
+
Async do
|
|
41
|
+
# Simple REPL
|
|
42
|
+
loop do
|
|
43
|
+
print "\n> "
|
|
44
|
+
input = $stdin.gets&.chomp
|
|
45
|
+
break if input.nil? || %w[exit quit].include?(input.downcase)
|
|
46
|
+
|
|
47
|
+
next if input.empty?
|
|
48
|
+
|
|
49
|
+
msg = Pocketrb::Bus::InboundMessage.new(
|
|
50
|
+
channel: :cli,
|
|
51
|
+
sender_id: "user",
|
|
52
|
+
chat_id: "chat",
|
|
53
|
+
content: input
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
response = agent_loop.process_message(msg)
|
|
57
|
+
puts "\n#{response.content}" if response
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
say "\nGoodbye!", :yellow
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
default_task :call
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
class CLI
|
|
5
|
+
# Config command - manages configuration
|
|
6
|
+
class Config < Thor
|
|
7
|
+
desc "show", "Show current configuration"
|
|
8
|
+
def show
|
|
9
|
+
workspace = options[:workspace] || Dir.pwd
|
|
10
|
+
config = Pocketrb::Config.load(workspace)
|
|
11
|
+
|
|
12
|
+
say "Configuration for #{workspace}:"
|
|
13
|
+
config.to_h.each do |key, value|
|
|
14
|
+
# Don't show API keys
|
|
15
|
+
display_value = key.to_s.include?("api_key") ? "[REDACTED]" : value
|
|
16
|
+
say " #{key}: #{display_value}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "set KEY VALUE", "Set a configuration value"
|
|
21
|
+
def set(key, value)
|
|
22
|
+
workspace = options[:workspace] || Dir.pwd
|
|
23
|
+
config = Pocketrb::Config.load(workspace)
|
|
24
|
+
|
|
25
|
+
# Type conversion
|
|
26
|
+
value = case value
|
|
27
|
+
when /^\d+$/ then value.to_i
|
|
28
|
+
when /^\d+\.\d+$/ then value.to_f
|
|
29
|
+
when "true" then true
|
|
30
|
+
when "false" then false
|
|
31
|
+
else value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
config.set(key, value)
|
|
35
|
+
say "Set #{key} = #{value}", :green
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "get KEY", "Get a configuration value"
|
|
39
|
+
def get(key)
|
|
40
|
+
workspace = options[:workspace] || Dir.pwd
|
|
41
|
+
config = Pocketrb::Config.load(workspace)
|
|
42
|
+
|
|
43
|
+
value = config[key]
|
|
44
|
+
if value
|
|
45
|
+
say "#{key}: #{value}"
|
|
46
|
+
else
|
|
47
|
+
say "Key '#{key}' not found", :yellow
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
class CLI
|
|
5
|
+
# Cron command - manages scheduled jobs
|
|
6
|
+
class Cron < Thor
|
|
7
|
+
desc "list", "List scheduled jobs"
|
|
8
|
+
option :all, type: :boolean, aliases: "-a", desc: "Include disabled jobs"
|
|
9
|
+
def list
|
|
10
|
+
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
11
|
+
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
12
|
+
|
|
13
|
+
service = Pocketrb::Cron::Service.new(
|
|
14
|
+
store_path: cron_store,
|
|
15
|
+
on_job: ->(_) {}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
jobs = service.list_jobs(include_disabled: options[:all])
|
|
19
|
+
if jobs.empty?
|
|
20
|
+
say "No scheduled jobs", :yellow
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
say "Scheduled jobs:"
|
|
25
|
+
jobs.each do |job|
|
|
26
|
+
status = job.enabled ? "enabled" : "disabled"
|
|
27
|
+
next_run = job.state.next_run_at_ms ? Time.at(job.state.next_run_at_ms / 1000).strftime("%Y-%m-%d %H:%M") : "never"
|
|
28
|
+
say " #{job.id}: #{job.name} [#{status}] - next: #{next_run}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "add", "Add a scheduled job"
|
|
33
|
+
option :name, type: :string, required: true, desc: "Job name"
|
|
34
|
+
option :message, type: :string, required: true, desc: "Message to process"
|
|
35
|
+
option :every, type: :numeric, desc: "Run every N seconds"
|
|
36
|
+
option :cron, type: :string, desc: "Cron expression (e.g., '0 9 * * *')"
|
|
37
|
+
option :at, type: :string, desc: "Run once at ISO datetime"
|
|
38
|
+
option :deliver, type: :boolean, default: false, desc: "Deliver to channel instead of processing"
|
|
39
|
+
option :channel, type: :string, desc: "Target channel for delivery"
|
|
40
|
+
option :to, type: :string, desc: "Target chat ID for delivery"
|
|
41
|
+
def add
|
|
42
|
+
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
43
|
+
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
44
|
+
|
|
45
|
+
service = Pocketrb::Cron::Service.new(
|
|
46
|
+
store_path: cron_store,
|
|
47
|
+
on_job: ->(_) {}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
job = if options[:every]
|
|
51
|
+
service.add_interval_job(
|
|
52
|
+
name: options[:name],
|
|
53
|
+
every: options[:every],
|
|
54
|
+
message: options[:message],
|
|
55
|
+
deliver: options[:deliver],
|
|
56
|
+
channel: options[:channel],
|
|
57
|
+
to: options[:to]
|
|
58
|
+
)
|
|
59
|
+
elsif options[:cron]
|
|
60
|
+
service.add_cron_job(
|
|
61
|
+
name: options[:name],
|
|
62
|
+
cron: options[:cron],
|
|
63
|
+
message: options[:message],
|
|
64
|
+
deliver: options[:deliver],
|
|
65
|
+
channel: options[:channel],
|
|
66
|
+
to: options[:to]
|
|
67
|
+
)
|
|
68
|
+
elsif options[:at]
|
|
69
|
+
at_time = Time.parse(options[:at])
|
|
70
|
+
service.add_one_time_job(
|
|
71
|
+
name: options[:name],
|
|
72
|
+
at: at_time,
|
|
73
|
+
message: options[:message],
|
|
74
|
+
deliver: options[:deliver],
|
|
75
|
+
channel: options[:channel],
|
|
76
|
+
to: options[:to]
|
|
77
|
+
)
|
|
78
|
+
else
|
|
79
|
+
say "Error: Must specify --every, --cron, or --at", :red
|
|
80
|
+
exit 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
say "Created job: #{job.id} (#{job.name})", :green
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
desc "remove JOB_ID", "Remove a scheduled job"
|
|
87
|
+
def remove(job_id)
|
|
88
|
+
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
89
|
+
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
90
|
+
|
|
91
|
+
service = Pocketrb::Cron::Service.new(
|
|
92
|
+
store_path: cron_store,
|
|
93
|
+
on_job: ->(_) {}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if service.remove_job(job_id)
|
|
97
|
+
say "Removed job: #{job_id}", :green
|
|
98
|
+
else
|
|
99
|
+
say "Job not found: #{job_id}", :red
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc "enable JOB_ID", "Enable a scheduled job"
|
|
104
|
+
def enable(job_id)
|
|
105
|
+
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
106
|
+
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
107
|
+
|
|
108
|
+
service = Pocketrb::Cron::Service.new(
|
|
109
|
+
store_path: cron_store,
|
|
110
|
+
on_job: ->(_) {}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if service.enable_job(job_id, enabled: true)
|
|
114
|
+
say "Enabled job: #{job_id}", :green
|
|
115
|
+
else
|
|
116
|
+
say "Job not found: #{job_id}", :red
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
desc "disable JOB_ID", "Disable a scheduled job"
|
|
121
|
+
def disable(job_id)
|
|
122
|
+
workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
|
|
123
|
+
cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
|
|
124
|
+
|
|
125
|
+
service = Pocketrb::Cron::Service.new(
|
|
126
|
+
store_path: cron_store,
|
|
127
|
+
on_job: ->(_) {}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if service.enable_job(job_id, enabled: false)
|
|
131
|
+
say "Disabled job: #{job_id}", :green
|
|
132
|
+
else
|
|
133
|
+
say "Job not found: #{job_id}", :red
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
desc "trigger JOB_ID", "Trigger a job manually"
|
|
138
|
+
def trigger(_job_id)
|
|
139
|
+
say "Manual job execution requires running gateway", :yellow
|
|
140
|
+
say "Use 'pocketrb gateway' and the job will be executed", :yellow
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|