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,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Pocketrb
7
+ module Providers
8
+ # Claude CLI provider - uses the `claude` command as a subprocess
9
+ # This allows using Claude Max/Pro subscription authentication
10
+ class ClaudeCLI < Base
11
+ MODELS = {
12
+ "opus" => "claude-opus-4-20250514",
13
+ "sonnet" => "claude-sonnet-4-20250514",
14
+ "haiku" => "claude-3-5-haiku-20241022"
15
+ }.freeze
16
+
17
+ DEFAULT_MODEL = "sonnet"
18
+ READ_TIMEOUT = 60
19
+ TOTAL_TIMEOUT = 300
20
+
21
+ def initialize(config = {})
22
+ @config = config
23
+ @stdin = nil
24
+ @stdout = nil
25
+ @stderr = nil
26
+ @wait_thread = nil
27
+ @stderr_thread = nil
28
+ @mutex = Mutex.new
29
+ validate_config!
30
+ end
31
+
32
+ def name
33
+ :claude_cli
34
+ end
35
+
36
+ def default_model
37
+ DEFAULT_MODEL
38
+ end
39
+
40
+ def available_models
41
+ MODELS.keys
42
+ end
43
+
44
+ def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
45
+ model ||= default_model
46
+
47
+ @mutex.synchronize do
48
+ start! unless running?
49
+
50
+ prompt = build_prompt(messages, tools)
51
+ send_message(prompt)
52
+ response = read_response
53
+
54
+ parse_cli_response(response, model)
55
+ end
56
+ rescue IOError, Errno::EPIPE => e
57
+ stop!
58
+ raise ProviderError, "Claude CLI error: #{e.message}"
59
+ end
60
+
61
+ def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
62
+ model ||= default_model
63
+
64
+ @mutex.synchronize do
65
+ start! unless running?
66
+
67
+ prompt = build_prompt(messages, tools)
68
+ send_message(prompt)
69
+ response = read_response_streaming(&block)
70
+
71
+ parse_cli_response(response, model)
72
+ end
73
+ rescue IOError, Errno::EPIPE => e
74
+ stop!
75
+ raise ProviderError, "Claude CLI error: #{e.message}"
76
+ end
77
+
78
+ def start!
79
+ return if running?
80
+
81
+ args = [
82
+ "claude",
83
+ "-p",
84
+ "--input-format", "stream-json",
85
+ "--output-format", "stream-json",
86
+ "--model", @config[:model] || DEFAULT_MODEL,
87
+ "--verbose"
88
+ ]
89
+
90
+ # Add autonomous/dangerous flags if configured or via ENV
91
+ # This skips all permission prompts - use only in trusted/sandboxed environments
92
+ if autonomous_mode?
93
+ args << "--allow-dangerously-skip-permissions"
94
+ args << "--dangerously-skip-permissions"
95
+ Pocketrb.logger.info("Autonomous mode enabled - skipping permission prompts")
96
+ end
97
+
98
+ # Custom permission mode if specified
99
+ args += ["--permission-mode", @config[:permission_mode]] if @config[:permission_mode]
100
+
101
+ args += ["--append-system-prompt", @config[:system_prompt]] if @config[:system_prompt]
102
+
103
+ Pocketrb.logger.info("Starting Claude CLI: #{args.join(" ")}")
104
+ Pocketrb.logger.debug("Config: autonomous=#{@config[:autonomous]}, env=#{ENV.fetch("POCKETRB_AUTONOMOUS",
105
+ nil)}")
106
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*args)
107
+
108
+ # Start stderr reader thread to capture errors
109
+ @stderr_thread = Thread.new do
110
+ while (line = @stderr.gets)
111
+ Pocketrb.logger.warn("Claude CLI stderr: #{line.strip}")
112
+ end
113
+ rescue IOError
114
+ # Expected when stderr is closed
115
+ end
116
+ end
117
+
118
+ def autonomous_mode?
119
+ @config[:autonomous] ||
120
+ @config[:dangerously_skip_permissions] ||
121
+ ENV["POCKETRB_AUTONOMOUS"] == "1" ||
122
+ ENV["POCKETRB_AUTONOMOUS"] == "true"
123
+ end
124
+
125
+ def stop!
126
+ @stdin&.close
127
+ @stdout&.close
128
+ @stderr&.close
129
+ @wait_thread&.kill
130
+ @stderr_thread&.kill
131
+ @stdin = @stdout = @stderr = @wait_thread = @stderr_thread = nil
132
+ end
133
+
134
+ def running?
135
+ @wait_thread&.alive? || false
136
+ end
137
+
138
+ protected
139
+
140
+ def supported_features
141
+ %i[tools streaming thinking]
142
+ end
143
+
144
+ def validate_config!
145
+ # Check if claude CLI is available
146
+ return if system("which claude > /dev/null 2>&1")
147
+
148
+ raise ConfigurationError, "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
149
+ end
150
+
151
+ private
152
+
153
+ def build_prompt(messages, tools)
154
+ # Build content blocks from messages, preserving images
155
+ content_blocks = []
156
+
157
+ messages.each do |msg|
158
+ content = msg.content
159
+ if content.is_a?(Array)
160
+ # Handle content blocks (text + media)
161
+ content.each do |block|
162
+ if block.is_a?(Hash) && block[:type] == "media"
163
+ # Convert media to image block
164
+ media = block[:media]
165
+ if media&.image?
166
+ image_block = format_image_for_cli(media)
167
+ content_blocks << image_block if image_block
168
+ else
169
+ content_blocks << { type: "text", text: "[Attached: #{media&.filename || "file"}]" }
170
+ end
171
+ elsif block.is_a?(Hash) && block[:type] == "text"
172
+ content_blocks << { type: "text", text: block[:text] }
173
+ elsif block.is_a?(String)
174
+ content_blocks << { type: "text", text: block }
175
+ end
176
+ end
177
+ elsif content.is_a?(String) && !content.empty?
178
+ content_blocks << { type: "text", text: content }
179
+ end
180
+ end
181
+
182
+ # Add tool descriptions if provided
183
+ if tools && !tools.empty?
184
+ tool_desc = tools.map do |t|
185
+ func = t[:function] || t
186
+ name = func[:name]
187
+ desc = func[:description]
188
+ params = func[:parameters] || func[:input_schema] || {}
189
+ props = params[:properties] || params["properties"] || {}
190
+ param_names = props.keys.join(", ")
191
+ "- #{name}(#{param_names}): #{desc}"
192
+ end.join("\n")
193
+
194
+ tool_prompt = <<~PROMPT
195
+ You have these tools available. To use a tool, respond with a JSON block:
196
+ ```json
197
+ {"tool": "tool_name", "input": {"param": "value"}}
198
+ ```
199
+
200
+ Available tools:
201
+ #{tool_desc}
202
+
203
+ User request:
204
+ PROMPT
205
+
206
+ # Prepend tool instructions to content
207
+ content_blocks.unshift({ type: "text", text: tool_prompt })
208
+ end
209
+
210
+ # Return content blocks (or just text if no images)
211
+ if content_blocks.all? { |b| b[:type] == "text" }
212
+ content_blocks.map { |b| b[:text] }.join("\n\n")
213
+ else
214
+ content_blocks
215
+ end
216
+ end
217
+
218
+ def format_image_for_cli(media)
219
+ return nil unless media&.image?
220
+
221
+ # Get base64 data
222
+ data = if media.data
223
+ media.data
224
+ elsif media.path && File.exist?(media.path)
225
+ require "base64"
226
+ Base64.strict_encode64(File.binread(media.path))
227
+ end
228
+
229
+ return nil unless data
230
+
231
+ {
232
+ type: "image",
233
+ source: {
234
+ type: "base64",
235
+ media_type: media.mime_type,
236
+ data: data
237
+ }
238
+ }
239
+ end
240
+
241
+ def send_message(content)
242
+ message = {
243
+ type: "user",
244
+ message: { role: "user", content: content }
245
+ }
246
+ @stdin.puts(message.to_json)
247
+ @stdin.flush
248
+ end
249
+
250
+ def read_response
251
+ result_text = ""
252
+ usage_data = {}
253
+ start_time = Time.now
254
+ events_received = 0
255
+
256
+ loop do
257
+ elapsed = Time.now - start_time
258
+ if elapsed > TOTAL_TIMEOUT
259
+ Pocketrb.logger.error("Claude CLI timeout after #{elapsed.to_i}s, received #{events_received} events")
260
+ Pocketrb.logger.error("Process alive: #{@wait_thread&.alive?}, partial response: #{result_text[0..200]}")
261
+ break
262
+ end
263
+
264
+ line = read_line_with_timeout
265
+ if line.nil?
266
+ Pocketrb.logger.debug("No more output from Claude CLI after #{events_received} events")
267
+ break
268
+ end
269
+
270
+ event = parse_event(line)
271
+ next unless event
272
+
273
+ events_received += 1
274
+ Pocketrb.logger.debug("Claude CLI event ##{events_received}: #{event["type"]}")
275
+
276
+ case event["type"]
277
+ when "assistant"
278
+ result_text += extract_text_from_event(event)
279
+ when "result"
280
+ result_text = event["result"] if event["result"] && !event["result"].empty?
281
+ usage_data = event["usage"] || {}
282
+ break
283
+ when "error"
284
+ raise ProviderError, "Claude CLI error: #{event["error"] || event["message"]}"
285
+ end
286
+ end
287
+
288
+ { content: result_text, usage: usage_data }
289
+ end
290
+
291
+ def read_response_streaming(&block)
292
+ result_text = ""
293
+ usage_data = {}
294
+ start_time = Time.now
295
+
296
+ loop do
297
+ break if Time.now - start_time > TOTAL_TIMEOUT
298
+
299
+ line = read_line_with_timeout
300
+ break if line.nil?
301
+
302
+ event = parse_event(line)
303
+ next unless event
304
+
305
+ case event["type"]
306
+ when "assistant"
307
+ chunk = extract_text_from_event(event)
308
+ result_text += chunk
309
+ block&.call(chunk) unless chunk.empty?
310
+ when "result"
311
+ result_text = event["result"] if event["result"] && !event["result"].empty?
312
+ usage_data = event["usage"] || {}
313
+ break
314
+ when "error"
315
+ raise ProviderError, "Claude CLI error: #{event["error"] || event["message"]}"
316
+ end
317
+ end
318
+
319
+ { content: result_text, usage: usage_data }
320
+ end
321
+
322
+ def read_line_with_timeout
323
+ Timeout.timeout(READ_TIMEOUT) { @stdout.gets }
324
+ rescue Timeout::Error
325
+ nil
326
+ end
327
+
328
+ def parse_event(line)
329
+ return nil if line.nil? || line.strip.empty?
330
+
331
+ JSON.parse(line.strip)
332
+ rescue JSON::ParserError
333
+ nil
334
+ end
335
+
336
+ def extract_text_from_event(event)
337
+ text = ""
338
+ if event.dig("message", "content")
339
+ event["message"]["content"].each do |block|
340
+ text += block["text"] if block["type"] == "text"
341
+ end
342
+ end
343
+ text
344
+ end
345
+
346
+ def parse_cli_response(response, model)
347
+ content = response[:content]
348
+ usage_data = response[:usage] || {}
349
+
350
+ # Check for tool calls in the response
351
+ tool_calls = extract_tool_calls(content)
352
+
353
+ usage = Usage.new(
354
+ input_tokens: usage_data["input_tokens"] || 0,
355
+ output_tokens: usage_data["output_tokens"] || 0,
356
+ cache_read: nil,
357
+ cache_write: nil
358
+ )
359
+
360
+ # If there are tool calls, remove them from content
361
+ content = content.gsub(/```json\s*\{.*?"tool".*?\}\s*```/m, "").strip if tool_calls.any?
362
+
363
+ LLMResponse.new(
364
+ content: content.empty? ? nil : content,
365
+ tool_calls: tool_calls,
366
+ usage: usage,
367
+ stop_reason: tool_calls.any? ? :tool_use : :end_turn,
368
+ model: MODELS[model] || model,
369
+ thinking: nil
370
+ )
371
+ end
372
+
373
+ def extract_tool_calls(text)
374
+ return [] unless text
375
+
376
+ tool_calls = []
377
+
378
+ # Look for JSON tool calls in code blocks
379
+ text.scan(/```json\s*(\{.*?"tool".*?\})\s*```/m) do |match|
380
+ parsed = JSON.parse(match[0])
381
+ if parsed["tool"]
382
+ tool_calls << ToolCall.new(
383
+ id: "cli_#{SecureRandom.hex(8)}",
384
+ name: parsed["tool"],
385
+ arguments: parsed["input"] || {}
386
+ )
387
+ end
388
+ rescue JSON::ParserError
389
+ # Skip malformed JSON
390
+ end
391
+
392
+ # Also try inline JSON
393
+ if tool_calls.empty?
394
+ text.scan(/\{"tool"\s*:\s*"\w+"[^}]*\}/m) do |match|
395
+ parsed = JSON.parse(match)
396
+ if parsed["tool"]
397
+ tool_calls << ToolCall.new(
398
+ id: "cli_#{SecureRandom.hex(8)}",
399
+ name: parsed["tool"],
400
+ arguments: parsed["input"] || {}
401
+ )
402
+ end
403
+ rescue JSON::ParserError
404
+ # Skip malformed JSON
405
+ end
406
+ end
407
+
408
+ tool_calls
409
+ end
410
+ end
411
+ end
412
+ end