pocketrb 0.1.0 → 0.3.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +98 -17
  4. data/lib/pocketrb/agent/compaction.rb +235 -22
  5. data/lib/pocketrb/agent/context.rb +26 -3
  6. data/lib/pocketrb/agent/loop.rb +53 -5
  7. data/lib/pocketrb/agent/spawn_tool.rb +14 -0
  8. data/lib/pocketrb/agent/subagent_manager.rb +24 -2
  9. data/lib/pocketrb/bus/events.rb +42 -1
  10. data/lib/pocketrb/bus/message_bus.rb +37 -0
  11. data/lib/pocketrb/channels/base.rb +13 -0
  12. data/lib/pocketrb/channels/cli.rb +16 -1
  13. data/lib/pocketrb/channels/telegram.rb +70 -4
  14. data/lib/pocketrb/channels/whatsapp.rb +49 -7
  15. data/lib/pocketrb/cli/chat.rb +7 -0
  16. data/lib/pocketrb/cli/config.rb +4 -0
  17. data/lib/pocketrb/cli/cron.rb +6 -0
  18. data/lib/pocketrb/cli.rb +0 -1
  19. data/lib/pocketrb/config.rb +55 -6
  20. data/lib/pocketrb/cron/job.rb +41 -0
  21. data/lib/pocketrb/cron/service.rb +44 -15
  22. data/lib/pocketrb/heartbeat/service.rb +21 -0
  23. data/lib/pocketrb/mcp/client.rb +41 -3
  24. data/lib/pocketrb/mcp/memory_tool.rb +33 -0
  25. data/lib/pocketrb/media/processor.rb +32 -1
  26. data/lib/pocketrb/memory.rb +12 -3
  27. data/lib/pocketrb/planning/manager.rb +29 -1
  28. data/lib/pocketrb/planning/plan.rb +52 -0
  29. data/lib/pocketrb/planning/tool.rb +45 -0
  30. data/lib/pocketrb/providers/anthropic.rb +113 -9
  31. data/lib/pocketrb/providers/base.rb +39 -4
  32. data/lib/pocketrb/providers/claude_cli.rb +79 -7
  33. data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
  34. data/lib/pocketrb/providers/openai_compatible.rb +79 -0
  35. data/lib/pocketrb/providers/openrouter.rb +72 -12
  36. data/lib/pocketrb/providers/registry.rb +24 -0
  37. data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
  38. data/lib/pocketrb/providers/types.rb +51 -1
  39. data/lib/pocketrb/session/manager.rb +34 -2
  40. data/lib/pocketrb/session/session.rb +53 -11
  41. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
  42. data/lib/pocketrb/skills/create_tool.rb +28 -0
  43. data/lib/pocketrb/skills/loader.rb +22 -1
  44. data/lib/pocketrb/skills/modify_tool.rb +22 -0
  45. data/lib/pocketrb/skills/skill.rb +19 -3
  46. data/lib/pocketrb/tools/background_job_manager.rb +29 -0
  47. data/lib/pocketrb/tools/base.rb +16 -1
  48. data/lib/pocketrb/tools/browser.rb +40 -0
  49. data/lib/pocketrb/tools/browser_advanced.rb +94 -4
  50. data/lib/pocketrb/tools/browser_session.rb +18 -0
  51. data/lib/pocketrb/tools/cron.rb +46 -0
  52. data/lib/pocketrb/tools/edit_file.rb +16 -0
  53. data/lib/pocketrb/tools/exec.rb +46 -0
  54. data/lib/pocketrb/tools/jobs.rb +26 -2
  55. data/lib/pocketrb/tools/list_dir.rb +19 -0
  56. data/lib/pocketrb/tools/memory.rb +34 -0
  57. data/lib/pocketrb/tools/message.rb +15 -0
  58. data/lib/pocketrb/tools/para_memory.rb +47 -0
  59. data/lib/pocketrb/tools/read_file.rb +11 -0
  60. data/lib/pocketrb/tools/registry.rb +12 -4
  61. data/lib/pocketrb/tools/send_file.rb +29 -0
  62. data/lib/pocketrb/tools/think.rb +9 -0
  63. data/lib/pocketrb/tools/web_fetch.rb +58 -3
  64. data/lib/pocketrb/tools/web_search.rb +25 -0
  65. data/lib/pocketrb/tools/write_file.rb +10 -0
  66. data/lib/pocketrb/version.rb +2 -1
  67. data/lib/pocketrb.rb +4 -1
  68. data/pocketrb.gemspec +6 -8
  69. metadata +24 -44
@@ -5,30 +5,60 @@ require "json"
5
5
 
6
6
  module Pocketrb
7
7
  module Providers
8
- # Direct Anthropic Claude API provider
9
- # Supports extended thinking and all Claude-specific features
8
+ # Direct Anthropic Claude API provider. Supports extended thinking and all Claude-specific features
10
9
  class Anthropic < Base
10
+ # Anthropic API base URL
11
11
  API_URL = "https://api.anthropic.com/v1"
12
+ # Anthropic API version header value
12
13
  API_VERSION = "2023-06-01"
13
14
 
15
+ # Supported Claude models with context and output token limits
14
16
  MODELS = {
15
- "claude-opus-4-20250514" => { context: 200_000, output: 32_000 },
16
- "claude-sonnet-4-20250514" => { context: 200_000, output: 64_000 },
17
- "claude-3-5-haiku-20241022" => { context: 200_000, output: 8192 }
17
+ "claude-opus-5-5" => { context: 1_000_000, output: 128_000 },
18
+ "claude-sonnet-5-5" => { context: 1_000_000, output: 128_000 },
19
+ "claude-haiku-5-5" => { context: 1_000_000, output: 128_000 }
18
20
  }.freeze
19
21
 
22
+ # First [major, minor] version per Claude family that rejects temperature with HTTP 400
23
+ SAMPLING_REJECTED_SINCE = { "opus" => [4, 7], "sonnet" => [5, 0], "haiku" => [5, 0] }.freeze
24
+
25
+ # Claude families that reject temperature in every version
26
+ SAMPLING_REJECTED_FAMILIES = %w[fable mythos].freeze
27
+
28
+ # Provider name
29
+ # @return [Symbol]
20
30
  def name
21
31
  :anthropic
22
32
  end
23
33
 
34
+ # Get the context window size for a model
35
+ # @param model [String, nil] Model name (defaults to default_model)
36
+ # @return [Integer] Context window size in tokens
37
+ def context_window(model: nil)
38
+ model ||= default_model
39
+ MODELS.dig(model, :context) || super
40
+ end
41
+
42
+ # Default model to use
43
+ # @return [String]
24
44
  def default_model
25
- "claude-sonnet-4-20250514"
45
+ "claude-sonnet-5-5"
26
46
  end
27
47
 
48
+ # List of available models
49
+ # @return [Array<String>]
28
50
  def available_models
29
51
  MODELS.keys
30
52
  end
31
53
 
54
+ # Send chat completion request
55
+ # @param messages [Array<Message>] Conversation messages
56
+ # @param tools [Array<Hash>, nil] Tool definitions
57
+ # @param model [String, nil] Model name (defaults to default_model)
58
+ # @param temperature [Float] Sampling temperature (0.0-1.0)
59
+ # @param max_tokens [Integer] Maximum tokens to generate
60
+ # @param thinking [Boolean] Whether to enable extended thinking mode
61
+ # @return [LLMResponse] Parsed response with content and tool calls
32
62
  def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
33
63
  model ||= default_model
34
64
  body = build_request_body(messages, tools, model, temperature, max_tokens, thinking)
@@ -40,7 +70,15 @@ module Pocketrb
40
70
  handle_response(response)
41
71
  end
42
72
 
43
- def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
73
+ # Send streaming chat completion request
74
+ # @param messages [Array<Message>] Conversation messages
75
+ # @param tools [Array<Hash>, nil] Tool definitions
76
+ # @param model [String, nil] Model name (defaults to default_model)
77
+ # @param temperature [Float] Sampling temperature (0.0-1.0)
78
+ # @param max_tokens [Integer] Maximum tokens to generate
79
+ # @yieldparam chunk [Hash] Streamed response chunk
80
+ # @return [LLMResponse] Final response with accumulated content
81
+ def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &)
44
82
  model ||= default_model
45
83
  body = build_request_body(messages, tools, model, temperature, max_tokens, false)
46
84
  body[:stream] = true
@@ -52,7 +90,7 @@ module Pocketrb
52
90
  client.post("/v1/messages") do |req|
53
91
  req.body = body.to_json
54
92
  req.options.on_data = proc do |chunk, _|
55
- process_stream_chunk(chunk, accumulated_content, accumulated_tool_calls, &block)
93
+ process_stream_chunk(chunk, accumulated_content, accumulated_tool_calls, &)
56
94
  end
57
95
  end
58
96
 
@@ -66,10 +104,15 @@ module Pocketrb
66
104
 
67
105
  protected
68
106
 
107
+ # Supported provider features
108
+ # @return [Array<Symbol>]
69
109
  def supported_features
70
110
  %i[tools streaming thinking vision]
71
111
  end
72
112
 
113
+ # Validate that required API credentials are present
114
+ # @return [void]
115
+ # @raise [ConfigurationError] if no credentials are configured
73
116
  def validate_config!
74
117
  return if oauth_token
75
118
  return if api_key(:anthropic_api_key)
@@ -81,14 +124,19 @@ module Pocketrb
81
124
  private
82
125
 
83
126
  # Check for OAuth token (Max subscription via `claude setup-token`)
127
+ # @return [String, nil] OAuth token if configured
84
128
  def oauth_token
85
129
  @config[:anthropic_oauth_token] || ENV.fetch("ANTHROPIC_OAUTH_TOKEN", nil)
86
130
  end
87
131
 
132
+ # Whether OAuth authentication is being used
133
+ # @return [Boolean]
88
134
  def using_oauth?
89
135
  !oauth_token.nil?
90
136
  end
91
137
 
138
+ # Build Faraday HTTP client with appropriate authentication headers
139
+ # @return [Faraday::Connection]
92
140
  def client
93
141
  @client ||= Faraday.new(url: API_URL) do |f|
94
142
  f.headers["Content-Type"] = "application/json"
@@ -108,6 +156,14 @@ module Pocketrb
108
156
  end
109
157
  end
110
158
 
159
+ # Build the API request body for Anthropic messages endpoint
160
+ # @param messages [Array<Message>] conversation messages
161
+ # @param tools [Array<Hash>, nil] tool definitions
162
+ # @param model [String] model name
163
+ # @param temperature [Float] sampling temperature
164
+ # @param max_tokens [Integer] maximum tokens to generate
165
+ # @param thinking [Boolean] whether to enable extended thinking
166
+ # @return [Hash] request body
111
167
  def build_request_body(messages, tools, model, temperature, max_tokens, thinking)
112
168
  system_message = extract_system_message(messages)
113
169
  conversation = format_messages(messages.reject { |m| m.role == Role::SYSTEM })
@@ -119,7 +175,7 @@ module Pocketrb
119
175
  }
120
176
 
121
177
  body[:system] = system_message if system_message
122
- body[:temperature] = temperature unless thinking
178
+ body[:temperature] = temperature unless thinking || rejects_sampling?(model)
123
179
  body[:tools] = format_tools(tools) if tools&.any?
124
180
 
125
181
  body[:thinking] = { type: "enabled", budget_tokens: [max_tokens / 2, 10_000].min } if thinking
@@ -127,11 +183,31 @@ module Pocketrb
127
183
  body
128
184
  end
129
185
 
186
+ # Whether the model rejects temperature. Matches dated and unlisted IDs by family and version
187
+ # @param model [String] model name
188
+ # @return [Boolean]
189
+ def rejects_sampling?(model)
190
+ family, major, minor = model.match(/\Aclaude-([a-z]+)-(?:(\d+)(?:-(\d{1,2}))?(?:-|\z))?/)&.captures
191
+ return false unless family
192
+ return true if SAMPLING_REJECTED_FAMILIES.include?(family)
193
+
194
+ since = SAMPLING_REJECTED_SINCE[family]
195
+ return false unless since && major
196
+
197
+ ([major.to_i, minor.to_i] <=> since) >= 0
198
+ end
199
+
200
+ # Extract system message content from the message list
201
+ # @param messages [Array<Message>] conversation messages
202
+ # @return [String, nil] system message content
130
203
  def extract_system_message(messages)
131
204
  system_msg = messages.find { |m| m.role == Role::SYSTEM }
132
205
  system_msg&.content
133
206
  end
134
207
 
208
+ # Format a single message for the Anthropic API
209
+ # @param message [Message] conversation message with role, content, and optional tool data
210
+ # @return [Hash] Anthropic-formatted message
135
211
  def format_message(message)
136
212
  case message.role
137
213
  when Role::USER
@@ -153,6 +229,9 @@ module Pocketrb
153
229
  end
154
230
  end
155
231
 
232
+ # Format user message content, handling text and media blocks
233
+ # @param content [String, Array, Object] raw user content
234
+ # @return [String, Array<Hash>] formatted content blocks
156
235
  def format_user_content(content)
157
236
  return content if content.is_a?(String)
158
237
 
@@ -174,6 +253,9 @@ module Pocketrb
174
253
  end
175
254
  end
176
255
 
256
+ # Format a media attachment as an Anthropic image source block
257
+ # @param media [Media] attachment to convert to a base64 image source or text fallback
258
+ # @return [Hash] Anthropic image or text fallback block
177
259
  def format_media_block(media)
178
260
  return { type: "text", text: "[unsupported media]" } unless media
179
261
 
@@ -202,6 +284,9 @@ module Pocketrb
202
284
  }
203
285
  end
204
286
 
287
+ # Normalize content to a string or array
288
+ # @param content [String, Array, Object] raw message content to pass through or coerce to string
289
+ # @return [String, Array] normalized content
205
290
  def format_content(content)
206
291
  return content if content.is_a?(String)
207
292
  return content if content.is_a?(Array)
@@ -209,6 +294,9 @@ module Pocketrb
209
294
  content.to_s
210
295
  end
211
296
 
297
+ # Format assistant message content including tool use blocks
298
+ # @param message [Message] assistant message with optional tool calls
299
+ # @return [String, Array<Hash>] formatted content blocks
212
300
  def format_assistant_content(message)
213
301
  blocks = []
214
302
 
@@ -226,6 +314,9 @@ module Pocketrb
226
314
  blocks.empty? ? "" : blocks
227
315
  end
228
316
 
317
+ # Convert tool definitions to Anthropic format
318
+ # @param tools [Array<Hash>, nil] tool definitions (OpenAI or Anthropic format)
319
+ # @return [Array<Hash>, nil] Anthropic-formatted tool definitions
229
320
  def format_tools(tools)
230
321
  return nil if tools.nil? || tools.empty?
231
322
 
@@ -244,6 +335,10 @@ module Pocketrb
244
335
  end
245
336
  end
246
337
 
338
+ # Handle HTTP response, raising on errors
339
+ # @param response [Faraday::Response] HTTP response
340
+ # @return [LLMResponse] parsed response
341
+ # @raise [ProviderError] on non-success HTTP status
247
342
  def handle_response(response)
248
343
  unless response.success?
249
344
  error_body = begin
@@ -258,6 +353,9 @@ module Pocketrb
258
353
  parse_response(data)
259
354
  end
260
355
 
356
+ # Parse Anthropic API response into an LLMResponse
357
+ # @param data [Hash] parsed JSON response body
358
+ # @return [LLMResponse] structured response with content, tool calls, and usage
261
359
  def parse_response(data)
262
360
  content = ""
263
361
  thinking = nil
@@ -304,6 +402,12 @@ module Pocketrb
304
402
  )
305
403
  end
306
404
 
405
+ # Process a single SSE chunk from the streaming response
406
+ # @param chunk [String] raw SSE data chunk
407
+ # @param accumulated_content [String] buffer for accumulated text content
408
+ # @param _accumulated_tool_calls [Array<ToolCall>] buffer for tool calls (unused)
409
+ # @param block [Proc] callback receiving each text delta
410
+ # @return [void]
307
411
  def process_stream_chunk(chunk, accumulated_content, _accumulated_tool_calls, &block)
308
412
  chunk.split("\n").each do |line|
309
413
  next unless line.start_with?("data: ")
@@ -6,6 +6,8 @@ module Pocketrb
6
6
  class Base
7
7
  attr_reader :config
8
8
 
9
+ # Initialize provider
10
+ # @param config [Hash] Provider configuration options (defaults to empty hash)
9
11
  def initialize(config = {})
10
12
  @config = config
11
13
  validate_config!
@@ -13,8 +15,8 @@ module Pocketrb
13
15
 
14
16
  # Send a chat completion request
15
17
  # @param messages [Array<Message>] Conversation history
16
- # @param tools [Array<Hash>|nil] Tool definitions
17
- # @param model [String|nil] Model to use (defaults to provider default)
18
+ # @param tools [Array<Hash>, nil] Tool definitions
19
+ # @param model [String, nil] Model to use (defaults to provider default)
18
20
  # @param temperature [Float] Sampling temperature
19
21
  # @param max_tokens [Integer] Maximum tokens to generate
20
22
  # @param thinking [Boolean] Enable extended thinking (Claude only)
@@ -24,8 +26,14 @@ module Pocketrb
24
26
  end
25
27
 
26
28
  # Stream a chat completion request
27
- # @yield [String|ToolCall] Chunks of content or tool calls
28
- # @return [LLMResponse]
29
+ # @param messages [Array<Message>] Conversation history
30
+ # @param tools [Array<Hash>, nil] Tool definitions
31
+ # @param model [String, nil] Model to use (defaults to provider default)
32
+ # @param temperature [Float] Sampling temperature
33
+ # @param max_tokens [Integer] Maximum tokens to generate
34
+ # @param block [Proc] Block to receive streaming chunks
35
+ # @yieldparam chunk [String] Text chunk from streaming response
36
+ # @return [LLMResponse] Final complete response
29
37
  def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
30
38
  raise NotImplementedError, "#{self.class}#chat_stream must be implemented"
31
39
  end
@@ -48,6 +56,13 @@ module Pocketrb
48
56
  raise NotImplementedError, "#{self.class}#name must be implemented"
49
57
  end
50
58
 
59
+ # Get the context window size for a model
60
+ # @param model [String, nil] Model name (defaults to default_model)
61
+ # @return [Integer] Context window size in tokens
62
+ def context_window(model: nil)
63
+ 200_000
64
+ end
65
+
51
66
  # Check if provider supports a feature
52
67
  # @param feature [Symbol] :tools, :streaming, :thinking, :vision
53
68
  # @return [Boolean]
@@ -57,39 +72,59 @@ module Pocketrb
57
72
 
58
73
  protected
59
74
 
75
+ # Supported provider features
76
+ # @return [Array<Symbol>]
60
77
  def supported_features
61
78
  %i[tools streaming]
62
79
  end
63
80
 
81
+ # Validate provider configuration
82
+ # @return [void]
64
83
  def validate_config!
65
84
  # Override in subclasses to validate required config
66
85
  end
67
86
 
87
+ # Raise error if API key is missing from config and environment
88
+ # @param key_name [Symbol] configuration key name
89
+ # @return [void]
90
+ # @raise [ConfigurationError] if API key is not found
68
91
  def require_api_key!(key_name)
69
92
  return if @config[key_name] || ENV[key_name.to_s.upcase]
70
93
 
71
94
  raise ConfigurationError, "#{key_name} is required for #{self.class.name}"
72
95
  end
73
96
 
97
+ # Fetch API key from config or environment
98
+ # @param key_name [Symbol] configuration key name
99
+ # @return [String, nil] API key value
74
100
  def api_key(key_name)
75
101
  @config[key_name] || ENV.fetch(key_name.to_s.upcase, nil)
76
102
  end
77
103
 
78
104
  # Convert internal message format to provider-specific format
105
+ # @param messages [Array<Message>] conversation history to convert for the provider API
106
+ # @return [Array<Hash>] provider-formatted messages
79
107
  def format_messages(messages)
80
108
  messages.map { |msg| format_message(msg) }
81
109
  end
82
110
 
111
+ # Format a single message for the provider
112
+ # @param message [Message] conversation message with role, content, and optional tool data
113
+ # @return [Hash] provider-formatted message
83
114
  def format_message(message)
84
115
  raise NotImplementedError
85
116
  end
86
117
 
87
118
  # Convert provider response to internal format
119
+ # @param response [Hash] raw provider response
120
+ # @return [LLMResponse] parsed response
88
121
  def parse_response(response)
89
122
  raise NotImplementedError
90
123
  end
91
124
 
92
125
  # Convert tool definitions to provider-specific format
126
+ # @param tools [Array<Hash>] tool definitions
127
+ # @return [Array<Hash>] provider-formatted tools
93
128
  def format_tools(tools)
94
129
  tools
95
130
  end
@@ -4,20 +4,27 @@ require "json"
4
4
  require "open3"
5
5
 
6
6
  module Pocketrb
7
+ # LLM provider implementations
7
8
  module Providers
8
9
  # Claude CLI provider - uses the `claude` command as a subprocess
9
10
  # This allows using Claude Max/Pro subscription authentication
10
11
  class ClaudeCLI < Base
12
+ # Supported model aliases mapped to full model names
11
13
  MODELS = {
12
- "opus" => "claude-opus-4-20250514",
13
- "sonnet" => "claude-sonnet-4-20250514",
14
- "haiku" => "claude-3-5-haiku-20241022"
14
+ "opus" => "claude-opus-5-5",
15
+ "sonnet" => "claude-sonnet-5-5",
16
+ "haiku" => "claude-haiku-5-5"
15
17
  }.freeze
16
18
 
19
+ # Default model to use when none specified
17
20
  DEFAULT_MODEL = "sonnet"
21
+ # Read timeout for CLI responses in seconds
18
22
  READ_TIMEOUT = 60
23
+ # Total timeout for CLI operations in seconds
19
24
  TOTAL_TIMEOUT = 300
20
25
 
26
+ # Initialize Claude CLI provider
27
+ # @param config [Hash] Configuration options
21
28
  def initialize(config = {})
22
29
  @config = config
23
30
  @stdin = nil
@@ -29,18 +36,32 @@ module Pocketrb
29
36
  validate_config!
30
37
  end
31
38
 
39
+ # Provider name
40
+ # @return [Symbol]
32
41
  def name
33
42
  :claude_cli
34
43
  end
35
44
 
45
+ # Default model
46
+ # @return [String]
36
47
  def default_model
37
48
  DEFAULT_MODEL
38
49
  end
39
50
 
51
+ # Available models
52
+ # @return [Array<String>]
40
53
  def available_models
41
54
  MODELS.keys
42
55
  end
43
56
 
57
+ # Send chat completion request
58
+ # @param messages [Array<Message>] Conversation messages
59
+ # @param tools [Array<Hash>, nil] Tool definitions
60
+ # @param model [String, nil] Model alias (opus, sonnet, haiku)
61
+ # @param temperature [Float] Sampling temperature
62
+ # @param max_tokens [Integer] Maximum tokens to generate
63
+ # @param thinking [Boolean] Enable extended thinking (not supported in CLI)
64
+ # @return [LLMResponse] Parsed response
44
65
  def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
45
66
  model ||= default_model
46
67
 
@@ -58,7 +79,15 @@ module Pocketrb
58
79
  raise ProviderError, "Claude CLI error: #{e.message}"
59
80
  end
60
81
 
61
- def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
82
+ # Send streaming chat completion request
83
+ # @param messages [Array<Message>] Conversation messages
84
+ # @param tools [Array<Hash>, nil] Tool definitions
85
+ # @param model [String, nil] Model alias
86
+ # @param temperature [Float] Sampling temperature
87
+ # @param max_tokens [Integer] Maximum tokens to generate
88
+ # @yieldparam chunk [String] Streamed text chunk
89
+ # @return [LLMResponse] Final response
90
+ def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &)
62
91
  model ||= default_model
63
92
 
64
93
  @mutex.synchronize do
@@ -66,7 +95,7 @@ module Pocketrb
66
95
 
67
96
  prompt = build_prompt(messages, tools)
68
97
  send_message(prompt)
69
- response = read_response_streaming(&block)
98
+ response = read_response_streaming(&)
70
99
 
71
100
  parse_cli_response(response, model)
72
101
  end
@@ -75,6 +104,8 @@ module Pocketrb
75
104
  raise ProviderError, "Claude CLI error: #{e.message}"
76
105
  end
77
106
 
107
+ # Start the Claude CLI subprocess
108
+ # @return [void]
78
109
  def start!
79
110
  return if running?
80
111
 
@@ -115,6 +146,8 @@ module Pocketrb
115
146
  end
116
147
  end
117
148
 
149
+ # Check if autonomous mode is enabled
150
+ # @return [Boolean] True if autonomous mode is enabled via config or env
118
151
  def autonomous_mode?
119
152
  @config[:autonomous] ||
120
153
  @config[:dangerously_skip_permissions] ||
@@ -122,6 +155,8 @@ module Pocketrb
122
155
  ENV["POCKETRB_AUTONOMOUS"] == "true"
123
156
  end
124
157
 
158
+ # Stop the Claude CLI subprocess
159
+ # @return [void]
125
160
  def stop!
126
161
  @stdin&.close
127
162
  @stdout&.close
@@ -131,16 +166,23 @@ module Pocketrb
131
166
  @stdin = @stdout = @stderr = @wait_thread = @stderr_thread = nil
132
167
  end
133
168
 
169
+ # Check if CLI subprocess is running
170
+ # @return [Boolean] True if subprocess is alive
134
171
  def running?
135
172
  @wait_thread&.alive? || false
136
173
  end
137
174
 
138
175
  protected
139
176
 
177
+ # Supported provider features
178
+ # @return [Array<Symbol>]
140
179
  def supported_features
141
180
  %i[tools streaming thinking]
142
181
  end
143
182
 
183
+ # Validate that the claude CLI binary is available
184
+ # @return [void]
185
+ # @raise [ConfigurationError] if claude CLI is not found
144
186
  def validate_config!
145
187
  # Check if claude CLI is available
146
188
  return if system("which claude > /dev/null 2>&1")
@@ -150,6 +192,10 @@ module Pocketrb
150
192
 
151
193
  private
152
194
 
195
+ # Build a prompt string or content blocks from messages and tools
196
+ # @param messages [Array<Message>] conversation messages
197
+ # @param tools [Array<Hash>, nil] tool definitions
198
+ # @return [String, Array<Hash>] prompt text or content blocks with images
153
199
  def build_prompt(messages, tools)
154
200
  # Build content blocks from messages, preserving images
155
201
  content_blocks = []
@@ -215,6 +261,9 @@ module Pocketrb
215
261
  end
216
262
  end
217
263
 
264
+ # Format a media object as a base64 image block for the CLI
265
+ # @param media [Media] image attachment to encode as base64 for the CLI protocol
266
+ # @return [Hash, nil] image content block or nil if unavailable
218
267
  def format_image_for_cli(media)
219
268
  return nil unless media&.image?
220
269
 
@@ -238,6 +287,9 @@ module Pocketrb
238
287
  }
239
288
  end
240
289
 
290
+ # Send a message to the CLI subprocess via stdin
291
+ # @param content [String, Array<Hash>] message content to send
292
+ # @return [void]
241
293
  def send_message(content)
242
294
  message = {
243
295
  type: "user",
@@ -247,6 +299,8 @@ module Pocketrb
247
299
  @stdin.flush
248
300
  end
249
301
 
302
+ # Read a complete response from the CLI subprocess
303
+ # @return [Hash] response with :content and :usage keys
250
304
  def read_response
251
305
  result_text = ""
252
306
  usage_data = {}
@@ -288,6 +342,9 @@ module Pocketrb
288
342
  { content: result_text, usage: usage_data }
289
343
  end
290
344
 
345
+ # Read response from CLI subprocess, yielding chunks as they arrive
346
+ # @param block [Proc] block to receive text chunks
347
+ # @return [Hash] response with :content and :usage keys
291
348
  def read_response_streaming(&block)
292
349
  result_text = ""
293
350
  usage_data = {}
@@ -319,12 +376,17 @@ module Pocketrb
319
376
  { content: result_text, usage: usage_data }
320
377
  end
321
378
 
379
+ # Read a single line from stdout with a timeout
380
+ # @return [String, nil] line read or nil on timeout
322
381
  def read_line_with_timeout
323
382
  Timeout.timeout(READ_TIMEOUT) { @stdout.gets }
324
383
  rescue Timeout::Error
325
384
  nil
326
385
  end
327
386
 
387
+ # Parse a JSON event line from the CLI output
388
+ # @param line [String, nil] raw JSON line
389
+ # @return [Hash, nil] parsed event or nil on parse failure
328
390
  def parse_event(line)
329
391
  return nil if line.nil? || line.strip.empty?
330
392
 
@@ -333,6 +395,9 @@ module Pocketrb
333
395
  nil
334
396
  end
335
397
 
398
+ # Extract text content from a CLI assistant event
399
+ # @param event [Hash] parsed CLI event
400
+ # @return [String] extracted text content
336
401
  def extract_text_from_event(event)
337
402
  text = ""
338
403
  if event.dig("message", "content")
@@ -343,6 +408,10 @@ module Pocketrb
343
408
  text
344
409
  end
345
410
 
411
+ # Parse the CLI response hash into an LLMResponse
412
+ # @param response [Hash] raw response with :content and :usage
413
+ # @param model [String] model alias used for the request
414
+ # @return [LLMResponse] structured response
346
415
  def parse_cli_response(response, model)
347
416
  content = response[:content]
348
417
  usage_data = response[:usage] || {}
@@ -370,6 +439,9 @@ module Pocketrb
370
439
  )
371
440
  end
372
441
 
442
+ # Extract tool call JSON blocks from response text
443
+ # @param text [String, nil] response text possibly containing tool call JSON
444
+ # @return [Array<ToolCall>] parsed tool calls
373
445
  def extract_tool_calls(text)
374
446
  return [] unless text
375
447
 
@@ -377,7 +449,7 @@ module Pocketrb
377
449
 
378
450
  # Look for JSON tool calls in code blocks
379
451
  text.scan(/```json\s*(\{.*?"tool".*?\})\s*```/m) do |match|
380
- parsed = JSON.parse(match[0])
452
+ parsed = JSON.parse(match[0], allow_duplicate_key: true)
381
453
  if parsed["tool"]
382
454
  tool_calls << ToolCall.new(
383
455
  id: "cli_#{SecureRandom.hex(8)}",
@@ -392,7 +464,7 @@ module Pocketrb
392
464
  # Also try inline JSON
393
465
  if tool_calls.empty?
394
466
  text.scan(/\{"tool"\s*:\s*"\w+"[^}]*\}/m) do |match|
395
- parsed = JSON.parse(match)
467
+ parsed = JSON.parse(match, allow_duplicate_key: true)
396
468
  if parsed["tool"]
397
469
  tool_calls << ToolCall.new(
398
470
  id: "cli_#{SecureRandom.hex(8)}",