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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module Pocketrb
8
+ module MCP
9
+ # MCP client for connecting to MCP HTTP Bridge
10
+ # Implements JSON-RPC 2.0 protocol for MCP communication
11
+ class Client
12
+ DEFAULT_ENDPOINT = "http://localhost:7878"
13
+ TIMEOUT = 30
14
+
15
+ attr_reader :endpoint, :connected
16
+
17
+ def initialize(endpoint: nil)
18
+ @endpoint = endpoint || ENV["MCP_ENDPOINT"] || DEFAULT_ENDPOINT
19
+ @connected = false
20
+ @request_id = 0
21
+ end
22
+
23
+ # Initialize the MCP connection
24
+ def connect
25
+ response = rpc_call("initialize", {
26
+ protocolVersion: "2024-11-05",
27
+ capabilities: {
28
+ tools: {}
29
+ },
30
+ clientInfo: {
31
+ name: "pocketrb",
32
+ version: Pocketrb::VERSION
33
+ }
34
+ })
35
+
36
+ @connected = !response["result"].nil?
37
+ @server_info = response.dig("result", "serverInfo")
38
+ @capabilities = response.dig("result", "capabilities")
39
+
40
+ Pocketrb.logger.info("MCP connected to #{@server_info&.dig("name") || "server"}")
41
+ @connected
42
+ rescue StandardError => e
43
+ Pocketrb.logger.warn("MCP connection failed: #{e.message}")
44
+ @connected = false
45
+ end
46
+
47
+ # List available tools from MCP server
48
+ def list_tools
49
+ ensure_connected!
50
+
51
+ response = rpc_call("tools/list", {})
52
+ response.dig("result", "tools") || []
53
+ end
54
+
55
+ # Call a tool on the MCP server
56
+ def call_tool(name:, arguments: {})
57
+ ensure_connected!
58
+
59
+ response = rpc_call("tools/call", {
60
+ name: name,
61
+ arguments: arguments
62
+ })
63
+
64
+ raise MCPError, "Tool call failed: #{response["error"]["message"]}" if response["error"]
65
+
66
+ response.dig("result", "content")&.first&.dig("text")
67
+ end
68
+
69
+ # Search memory via MCP
70
+ def search(query:, limit: 10)
71
+ # Try MCP tool first
72
+ if tool_available?("memory_search")
73
+ call_tool(name: "memory_search", arguments: { query: query, limit: limit })
74
+ else
75
+ # Fall back to direct HTTP endpoint
76
+ http_search(query, limit)
77
+ end
78
+ end
79
+
80
+ # Store to memory via MCP
81
+ def store(content:, metadata: {})
82
+ # Try MCP tool first
83
+ if tool_available?("memory_store")
84
+ call_tool(name: "memory_store", arguments: { content: content, metadata: metadata })
85
+ else
86
+ # Fall back to direct HTTP endpoint
87
+ http_store(content, metadata)
88
+ end
89
+ end
90
+
91
+ # Check if connected
92
+ def connected?
93
+ @connected
94
+ end
95
+
96
+ # Disconnect from server
97
+ def disconnect
98
+ @connected = false
99
+ end
100
+
101
+ private
102
+
103
+ def ensure_connected!
104
+ return if @connected
105
+
106
+ connect
107
+ raise MCPError, "Not connected to MCP server" unless @connected
108
+ end
109
+
110
+ def rpc_call(method, params)
111
+ @request_id += 1
112
+
113
+ request_body = {
114
+ jsonrpc: "2.0",
115
+ id: @request_id,
116
+ method: method,
117
+ params: params
118
+ }
119
+
120
+ response = client.post("/rpc") do |req|
121
+ req.body = request_body.to_json
122
+ end
123
+
124
+ raise MCPError, "RPC call failed: HTTP #{response.status}" unless response.success?
125
+
126
+ JSON.parse(response.body)
127
+ rescue Faraday::Error => e
128
+ raise MCPError, "RPC connection error: #{e.message}"
129
+ end
130
+
131
+ def client
132
+ @client ||= Faraday.new(url: @endpoint) do |f|
133
+ f.headers["Content-Type"] = "application/json"
134
+ f.options.timeout = TIMEOUT
135
+ f.adapter Faraday.default_adapter
136
+ end
137
+ end
138
+
139
+ def tool_available?(name)
140
+ @tools ||= list_tools
141
+ @tools.any? { |t| t["name"] == name }
142
+ rescue MCPError
143
+ false
144
+ end
145
+
146
+ # Direct HTTP endpoints (fallback when not using MCP tools)
147
+ def http_search(query, limit)
148
+ response = client.post("/search") do |req|
149
+ req.body = { query: query, limit: limit }.to_json
150
+ end
151
+
152
+ return nil unless response.success?
153
+
154
+ JSON.parse(response.body)
155
+ rescue StandardError
156
+ nil
157
+ end
158
+
159
+ def http_store(content, metadata)
160
+ response = client.post("/store") do |req|
161
+ req.body = { content: content, metadata: metadata }.to_json
162
+ end
163
+
164
+ return false unless response.success?
165
+
166
+ true
167
+ rescue StandardError
168
+ false
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module MCP
5
+ # Tool for interacting with memory via MCP
6
+ class MemoryTool < Tools::Base
7
+ def initialize(context = {})
8
+ super
9
+ @client = context[:mcp_client] || Client.new
10
+ end
11
+
12
+ def name
13
+ "memory"
14
+ end
15
+
16
+ def description
17
+ "Interact with long-term memory. Search for relevant information or store new knowledge for future reference."
18
+ end
19
+
20
+ def parameters
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ action: {
25
+ type: "string",
26
+ enum: %w[search store],
27
+ description: "Action to perform: 'search' to find information, 'store' to save new knowledge"
28
+ },
29
+ query: {
30
+ type: "string",
31
+ description: "For search: the query to search for"
32
+ },
33
+ content: {
34
+ type: "string",
35
+ description: "For store: the content to save to memory"
36
+ },
37
+ tags: {
38
+ type: "array",
39
+ items: { type: "string" },
40
+ description: "For store: tags to associate with the content"
41
+ },
42
+ limit: {
43
+ type: "integer",
44
+ description: "For search: maximum number of results (default: 5)"
45
+ }
46
+ },
47
+ required: ["action"]
48
+ }
49
+ end
50
+
51
+ def available?
52
+ @client.connected? || @client.connect
53
+ rescue StandardError
54
+ false
55
+ end
56
+
57
+ def execute(action:, query: nil, content: nil, tags: nil, limit: 5)
58
+ case action
59
+ when "search"
60
+ execute_search(query, limit)
61
+ when "store"
62
+ execute_store(content, tags)
63
+ else
64
+ error("Unknown action: #{action}. Use 'search' or 'store'.")
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def execute_search(query, limit)
71
+ return error("Query is required for search") if query.nil? || query.empty?
72
+
73
+ results = @client.search(query: query, limit: limit)
74
+
75
+ return "No relevant memories found for: #{query}" if results.nil? || results.empty?
76
+
77
+ format_search_results(results, query)
78
+ rescue MCPError => e
79
+ error("Memory search failed: #{e.message}")
80
+ end
81
+
82
+ def execute_store(content, tags)
83
+ return error("Content is required for store") if content.nil? || content.empty?
84
+
85
+ metadata = {}
86
+ metadata[:tags] = tags if tags && !tags.empty?
87
+ metadata[:timestamp] = Time.now.iso8601
88
+ metadata[:source] = "pocketrb"
89
+
90
+ result = @client.store(content: content, metadata: metadata)
91
+
92
+ if result
93
+ success("Stored to memory: #{content[0..100]}#{"..." if content.length > 100}")
94
+ else
95
+ error("Failed to store to memory")
96
+ end
97
+ rescue MCPError => e
98
+ error("Memory store failed: #{e.message}")
99
+ end
100
+
101
+ def format_search_results(results, query)
102
+ output = ["Memory search results for: #{query}\n"]
103
+
104
+ if results.is_a?(String)
105
+ # Direct text response from MCP
106
+ output << results
107
+ elsif results.is_a?(Array)
108
+ results.each_with_index do |result, idx|
109
+ output << format_result(result, idx + 1)
110
+ end
111
+ elsif results.is_a?(Hash) && results["results"]
112
+ results["results"].each_with_index do |result, idx|
113
+ output << format_result(result, idx + 1)
114
+ end
115
+ end
116
+
117
+ output.join("\n")
118
+ end
119
+
120
+ def format_result(result, index)
121
+ content = result["content"] || result["text"] || result.to_s
122
+ score = result["score"]
123
+ tags = result["tags"] || result.dig("metadata", "tags")
124
+
125
+ parts = ["#{index}. #{content[0..500]}"]
126
+ parts << " Score: #{score.round(3)}" if score
127
+ parts << " Tags: #{tags.join(", ")}" if tags && !tags.empty?
128
+
129
+ parts.join("\n")
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ module Pocketrb
8
+ module Media
9
+ # Handles media processing: downloading, encoding, type detection
10
+ class Processor
11
+ # Supported image MIME types for vision models
12
+ VISION_IMAGE_TYPES = %w[
13
+ image/jpeg
14
+ image/png
15
+ image/gif
16
+ image/webp
17
+ ].freeze
18
+
19
+ # Audio types that can be transcribed
20
+ AUDIO_TYPES = %w[
21
+ audio/ogg
22
+ audio/mpeg
23
+ audio/mp3
24
+ audio/wav
25
+ audio/webm
26
+ audio/m4a
27
+ ].freeze
28
+
29
+ # Max file size for inline encoding (5MB)
30
+ MAX_INLINE_SIZE = 5 * 1024 * 1024
31
+
32
+ attr_reader :cache_dir
33
+
34
+ def initialize(cache_dir: nil)
35
+ @cache_dir = cache_dir || default_cache_dir
36
+ ensure_cache_dir!
37
+ end
38
+
39
+ # Download media from URL and return Media object
40
+ # @param url [String] URL to download
41
+ # @param filename [String, nil] Original filename
42
+ # @param mime_type [String, nil] Known MIME type
43
+ # @return [Bus::Media]
44
+ def download(url, filename: nil, mime_type: nil)
45
+ require "faraday"
46
+
47
+ response = Faraday.get(url)
48
+ raise MediaError, "Failed to download: HTTP #{response.status}" unless response.success?
49
+
50
+ content_type = mime_type || response.headers["content-type"]&.split(";")&.first || "application/octet-stream"
51
+ filename ||= extract_filename(url, content_type)
52
+
53
+ # Save to cache
54
+ cache_path = cache_file_path(filename)
55
+ File.binwrite(cache_path, response.body)
56
+
57
+ type = detect_type(content_type)
58
+ data = encode_if_small(response.body, content_type)
59
+
60
+ Bus::Media.new(
61
+ type: type,
62
+ path: cache_path,
63
+ mime_type: content_type,
64
+ filename: filename,
65
+ data: data
66
+ )
67
+ end
68
+
69
+ # Process a local file into a Media object
70
+ # @param path [String] File path
71
+ # @param mime_type [String, nil] Override MIME type
72
+ # @return [Bus::Media]
73
+ def from_file(path, mime_type: nil)
74
+ raise MediaError, "File not found: #{path}" unless File.exist?(path)
75
+
76
+ content = File.binread(path)
77
+ content_type = mime_type || detect_mime_type(path)
78
+ filename = File.basename(path)
79
+ type = detect_type(content_type)
80
+ data = encode_if_small(content, content_type)
81
+
82
+ Bus::Media.new(
83
+ type: type,
84
+ path: path,
85
+ mime_type: content_type,
86
+ filename: filename,
87
+ data: data
88
+ )
89
+ end
90
+
91
+ # Encode raw bytes into a Media object
92
+ # @param bytes [String] Raw binary data
93
+ # @param mime_type [String] MIME type
94
+ # @param filename [String, nil] Filename
95
+ # @return [Bus::Media]
96
+ def from_bytes(bytes, mime_type:, filename: nil)
97
+ type = detect_type(mime_type)
98
+ filename ||= "media_#{Time.now.to_i}.#{extension_for(mime_type)}"
99
+
100
+ # Save to cache
101
+ cache_path = cache_file_path(filename)
102
+ File.binwrite(cache_path, bytes)
103
+
104
+ data = encode_if_small(bytes, mime_type)
105
+
106
+ Bus::Media.new(
107
+ type: type,
108
+ path: cache_path,
109
+ mime_type: mime_type,
110
+ filename: filename,
111
+ data: data
112
+ )
113
+ end
114
+
115
+ # Check if media is a vision-compatible image
116
+ # @param media [Bus::Media]
117
+ # @return [Boolean]
118
+ def vision_compatible?(media)
119
+ VISION_IMAGE_TYPES.include?(media.mime_type)
120
+ end
121
+
122
+ # Check if media is audio that can be transcribed
123
+ # @param media [Bus::Media]
124
+ # @return [Boolean]
125
+ def audio?(media)
126
+ AUDIO_TYPES.include?(media.mime_type) || media.type == :audio
127
+ end
128
+
129
+ # Get base64 data for media (loads from file if needed)
130
+ # @param media [Bus::Media]
131
+ # @return [String] Base64 encoded data
132
+ def get_base64(media)
133
+ return media.data if media.data
134
+
135
+ content = File.binread(media.path)
136
+ Base64.strict_encode64(content)
137
+ end
138
+
139
+ # Format media for Anthropic vision API
140
+ # @param media [Bus::Media]
141
+ # @return [Hash] Anthropic image content block
142
+ def format_for_anthropic(media)
143
+ unless vision_compatible?(media)
144
+ return { type: "text", text: "[Attached file: #{media.filename} (#{media.mime_type})]" }
145
+ end
146
+
147
+ {
148
+ type: "image",
149
+ source: {
150
+ type: "base64",
151
+ media_type: media.mime_type,
152
+ data: get_base64(media)
153
+ }
154
+ }
155
+ end
156
+
157
+ # Clean up old cached files
158
+ # @param older_than [Integer] Age in seconds
159
+ def cleanup_cache(older_than: 86_400)
160
+ return unless @cache_dir.exist?
161
+
162
+ cutoff = Time.now - older_than
163
+ Dir.glob(@cache_dir.join("*")).each do |file|
164
+ File.delete(file) if File.mtime(file) < cutoff
165
+ rescue StandardError
166
+ # Ignore cleanup errors
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def default_cache_dir
173
+ Pathname.new(Dir.tmpdir).join("pocketrb-media")
174
+ end
175
+
176
+ def ensure_cache_dir!
177
+ FileUtils.mkdir_p(@cache_dir)
178
+ end
179
+
180
+ def cache_file_path(filename)
181
+ # Sanitize filename and add timestamp to avoid conflicts
182
+ safe_name = filename.gsub(/[^a-zA-Z0-9._-]/, "_")
183
+ @cache_dir.join("#{Time.now.to_i}_#{safe_name}")
184
+ end
185
+
186
+ def detect_type(mime_type)
187
+ case mime_type
188
+ when %r{^image/}
189
+ :image
190
+ when %r{^audio/}
191
+ :audio
192
+ when %r{^video/}
193
+ :video
194
+ else
195
+ :file
196
+ end
197
+ end
198
+
199
+ def detect_mime_type(path)
200
+ ext = File.extname(path).downcase
201
+ MIME_TYPES[ext] || "application/octet-stream"
202
+ end
203
+
204
+ def encode_if_small(content, mime_type)
205
+ return nil if content.bytesize > MAX_INLINE_SIZE
206
+ return nil unless VISION_IMAGE_TYPES.include?(mime_type)
207
+
208
+ Base64.strict_encode64(content)
209
+ end
210
+
211
+ def extract_filename(url, content_type)
212
+ # Try to get filename from URL
213
+ uri_path = begin
214
+ URI.parse(url).path
215
+ rescue StandardError
216
+ ""
217
+ end
218
+ name = File.basename(uri_path)
219
+
220
+ if name.empty? || !name.include?(".")
221
+ ext = extension_for(content_type)
222
+ name = "download_#{Time.now.to_i}.#{ext}"
223
+ end
224
+
225
+ name
226
+ end
227
+
228
+ def extension_for(mime_type)
229
+ EXTENSIONS[mime_type] || "bin"
230
+ end
231
+
232
+ MIME_TYPES = {
233
+ ".jpg" => "image/jpeg",
234
+ ".jpeg" => "image/jpeg",
235
+ ".png" => "image/png",
236
+ ".gif" => "image/gif",
237
+ ".webp" => "image/webp",
238
+ ".mp3" => "audio/mpeg",
239
+ ".ogg" => "audio/ogg",
240
+ ".wav" => "audio/wav",
241
+ ".m4a" => "audio/m4a",
242
+ ".mp4" => "video/mp4",
243
+ ".webm" => "video/webm",
244
+ ".pdf" => "application/pdf",
245
+ ".txt" => "text/plain",
246
+ ".json" => "application/json"
247
+ }.freeze
248
+
249
+ EXTENSIONS = MIME_TYPES.invert.merge(
250
+ "image/jpeg" => "jpg",
251
+ "audio/mpeg" => "mp3",
252
+ "audio/ogg" => "ogg"
253
+ ).freeze
254
+ end
255
+
256
+ class MediaError < Pocketrb::Error; end
257
+ end
258
+ end