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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +98 -17
- data/lib/pocketrb/agent/compaction.rb +235 -22
- data/lib/pocketrb/agent/context.rb +26 -3
- data/lib/pocketrb/agent/loop.rb +53 -5
- data/lib/pocketrb/agent/spawn_tool.rb +14 -0
- data/lib/pocketrb/agent/subagent_manager.rb +24 -2
- data/lib/pocketrb/bus/events.rb +42 -1
- data/lib/pocketrb/bus/message_bus.rb +37 -0
- data/lib/pocketrb/channels/base.rb +13 -0
- data/lib/pocketrb/channels/cli.rb +16 -1
- data/lib/pocketrb/channels/telegram.rb +70 -4
- data/lib/pocketrb/channels/whatsapp.rb +49 -7
- data/lib/pocketrb/cli/chat.rb +7 -0
- data/lib/pocketrb/cli/config.rb +4 -0
- data/lib/pocketrb/cli/cron.rb +6 -0
- data/lib/pocketrb/cli.rb +0 -1
- data/lib/pocketrb/config.rb +55 -6
- data/lib/pocketrb/cron/job.rb +41 -0
- data/lib/pocketrb/cron/service.rb +44 -15
- data/lib/pocketrb/heartbeat/service.rb +21 -0
- data/lib/pocketrb/mcp/client.rb +41 -3
- data/lib/pocketrb/mcp/memory_tool.rb +33 -0
- data/lib/pocketrb/media/processor.rb +32 -1
- data/lib/pocketrb/memory.rb +12 -3
- data/lib/pocketrb/planning/manager.rb +29 -1
- data/lib/pocketrb/planning/plan.rb +52 -0
- data/lib/pocketrb/planning/tool.rb +45 -0
- data/lib/pocketrb/providers/anthropic.rb +113 -9
- data/lib/pocketrb/providers/base.rb +39 -4
- data/lib/pocketrb/providers/claude_cli.rb +79 -7
- data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
- data/lib/pocketrb/providers/openai_compatible.rb +79 -0
- data/lib/pocketrb/providers/openrouter.rb +72 -12
- data/lib/pocketrb/providers/registry.rb +24 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
- data/lib/pocketrb/providers/types.rb +51 -1
- data/lib/pocketrb/session/manager.rb +34 -2
- data/lib/pocketrb/session/session.rb +53 -11
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
- data/lib/pocketrb/skills/create_tool.rb +28 -0
- data/lib/pocketrb/skills/loader.rb +22 -1
- data/lib/pocketrb/skills/modify_tool.rb +22 -0
- data/lib/pocketrb/skills/skill.rb +19 -3
- data/lib/pocketrb/tools/background_job_manager.rb +29 -0
- data/lib/pocketrb/tools/base.rb +16 -1
- data/lib/pocketrb/tools/browser.rb +40 -0
- data/lib/pocketrb/tools/browser_advanced.rb +94 -4
- data/lib/pocketrb/tools/browser_session.rb +18 -0
- data/lib/pocketrb/tools/cron.rb +46 -0
- data/lib/pocketrb/tools/edit_file.rb +16 -0
- data/lib/pocketrb/tools/exec.rb +46 -0
- data/lib/pocketrb/tools/jobs.rb +26 -2
- data/lib/pocketrb/tools/list_dir.rb +19 -0
- data/lib/pocketrb/tools/memory.rb +34 -0
- data/lib/pocketrb/tools/message.rb +15 -0
- data/lib/pocketrb/tools/para_memory.rb +47 -0
- data/lib/pocketrb/tools/read_file.rb +11 -0
- data/lib/pocketrb/tools/registry.rb +12 -4
- data/lib/pocketrb/tools/send_file.rb +29 -0
- data/lib/pocketrb/tools/think.rb +9 -0
- data/lib/pocketrb/tools/web_fetch.rb +58 -3
- data/lib/pocketrb/tools/web_search.rb +25 -0
- data/lib/pocketrb/tools/write_file.rb +10 -0
- data/lib/pocketrb/version.rb +2 -1
- data/lib/pocketrb.rb +4 -1
- data/pocketrb.gemspec +6 -8
- 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-
|
|
16
|
-
"claude-sonnet-
|
|
17
|
-
"claude-
|
|
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-
|
|
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
|
-
|
|
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, &
|
|
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
|
|
17
|
-
# @param model [String
|
|
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
|
-
# @
|
|
28
|
-
# @
|
|
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-
|
|
13
|
-
"sonnet" => "claude-sonnet-
|
|
14
|
-
"haiku" => "claude-
|
|
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
|
-
|
|
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(&
|
|
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)}",
|