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
@@ -2,58 +2,58 @@
2
2
 
3
3
  module Pocketrb
4
4
  module Providers
5
- # Provider using the RubyLLM gem for multi-model support
6
- # This is an alternative to direct API calls
5
+ # Provider using the RubyLLM gem for multi-model support. This is an alternative to direct API calls
7
6
  class RubyLLMProvider < Base
7
+ # Provider name
8
+ # @return [Symbol]
8
9
  def name
9
10
  :ruby_llm
10
11
  end
11
12
 
13
+ # Default model
14
+ # @return [String]
12
15
  def default_model
13
- "claude-sonnet-4-20250514"
16
+ "claude-sonnet-5-5"
14
17
  end
15
18
 
19
+ # Available models
20
+ # @return [Array<String>]
16
21
  def available_models
17
22
  return [] unless ruby_llm_available?
18
23
 
19
24
  RubyLLM.models.map(&:id)
20
25
  end
21
26
 
27
+ # Send chat completion request
28
+ # @param messages [Array<Message>] Conversation messages
29
+ # @param tools [Array<Hash>, nil] Tool definitions
30
+ # @param model [String, nil] Model name
31
+ # @param temperature [Float] Sampling temperature
32
+ # @param max_tokens [Integer] Maximum tokens to generate
33
+ # @param thinking [Boolean] Enable extended thinking
34
+ # @return [LLMResponse] Parsed response
22
35
  def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
23
- ensure_ruby_llm!
24
-
25
36
  model ||= default_model
26
- chat_instance = RubyLLM.chat(model: model)
27
-
28
- # Add tools if provided
29
- tools&.each do |tool|
30
- chat_instance.with_tool(build_ruby_llm_tool(tool))
31
- end
32
-
33
- # Configure and send
34
- messages.each { |msg| add_message_to_chat(chat_instance, msg) }
37
+ chat_instance = build_chat(messages, tools, model, temperature, max_tokens, thinking)
35
38
 
36
- response = chat_instance.complete
37
-
38
- parse_ruby_llm_response(response, model)
39
+ parse_ruby_llm_response(chat_instance.generate, model)
39
40
  end
40
41
 
42
+ # Send streaming chat completion request
43
+ # @param messages [Array<Message>] Conversation messages
44
+ # @param tools [Array<Hash>, nil] Tool definitions
45
+ # @param model [String, nil] Model name
46
+ # @param temperature [Float] Sampling temperature
47
+ # @param max_tokens [Integer] Maximum tokens to generate
48
+ # @param block [Proc] Block to receive streaming chunks
49
+ # @yieldparam chunk [String] Streamed text chunk
50
+ # @return [LLMResponse] Final response
41
51
  def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
42
- ensure_ruby_llm!
43
-
44
52
  model ||= default_model
45
- chat_instance = RubyLLM.chat(model: model)
46
-
47
- tools&.each do |tool|
48
- chat_instance.with_tool(build_ruby_llm_tool(tool))
49
- end
50
-
51
- messages.each { |msg| add_message_to_chat(chat_instance, msg) }
53
+ chat_instance = build_chat(messages, tools, model, temperature, max_tokens, false)
52
54
 
53
- accumulated = ""
54
- response = chat_instance.stream do |chunk|
55
- accumulated << chunk.content if chunk.content
56
- block&.call(chunk.content)
55
+ response = chat_instance.generate do |chunk|
56
+ block&.call(chunk.content) if chunk.content && !chunk.content.empty?
57
57
  end
58
58
 
59
59
  parse_ruby_llm_response(response, model)
@@ -61,76 +61,190 @@ module Pocketrb
61
61
 
62
62
  protected
63
63
 
64
+ # Supported provider features
65
+ # @return [Array<Symbol>]
64
66
  def supported_features
65
67
  %i[tools streaming]
66
68
  end
67
69
 
70
+ # Validate provider configuration (delegated to RubyLLM)
71
+ # @return [void]
68
72
  def validate_config!
69
73
  # RubyLLM handles API key validation internally
70
74
  end
71
75
 
72
76
  private
73
77
 
78
+ # Check if the RubyLLM gem is loaded
79
+ # @return [Boolean]
74
80
  def ruby_llm_available?
75
81
  defined?(RubyLLM)
76
82
  end
77
83
 
84
+ # Ensure RubyLLM is loaded, attempting to require it if needed
85
+ # @return [void]
86
+ # @raise [ConfigurationError] if the gem is not installed
78
87
  def ensure_ruby_llm!
79
88
  return if ruby_llm_available?
80
89
 
81
- begin
82
- require "ruby_llm"
83
- configure_ruby_llm
84
- rescue LoadError
85
- raise ConfigurationError, "ruby_llm gem is not installed. Add it to your Gemfile."
86
- end
90
+ require "ruby_llm"
91
+ rescue LoadError
92
+ raise ConfigurationError, "ruby_llm gem is not installed. Add it to your Gemfile."
87
93
  end
88
94
 
89
- def configure_ruby_llm
90
- RubyLLM.configure do |c|
95
+ # RubyLLM context holding this provider's API keys, leaving the global RubyLLM config untouched
96
+ # @return [RubyLLM::Context]
97
+ def ruby_llm_context
98
+ ensure_ruby_llm!
99
+
100
+ @ruby_llm_context ||= RubyLLM.context do |c|
91
101
  c.anthropic_api_key = api_key(:anthropic_api_key) if api_key(:anthropic_api_key)
92
102
  c.openai_api_key = api_key(:openai_api_key) if api_key(:openai_api_key)
93
103
  end
94
104
  end
95
105
 
106
+ # Build a RubyLLM chat loaded with the conversation, without sending a request
107
+ # @param messages [Array<Message>] conversation messages
108
+ # @param tools [Array<Hash>, nil] tool definitions
109
+ # @param model [String] model name
110
+ # @param temperature [Float] sampling temperature
111
+ # @param max_tokens [Integer] maximum tokens to generate
112
+ # @param thinking [Boolean] enable extended thinking
113
+ # @return [RubyLLM::Chat] configured chat instance
114
+ def build_chat(messages, tools, model, temperature, max_tokens, thinking)
115
+ chat = ruby_llm_context.chat(model: model)
116
+ chat.with_max_output_tokens(max_tokens)
117
+ chat.with_temperature(temperature) unless thinking || chat.model.metadata[:temperature] == false
118
+ chat.with_thinking if thinking
119
+ tools&.each { |tool| chat.with_tools(build_ruby_llm_tool(tool)) }
120
+ messages.each { |msg| add_message_to_chat(chat, msg) }
121
+ chat
122
+ end
123
+
124
+ # Wrap a tool definition as a RubyLLM tool. Pocketrb runs tools itself, so it is never executed here
125
+ # @param tool [Hash] tool definition with optional :function key
126
+ # @return [Class<RubyLLM::Tool>] RubyLLM tool class with the definition's name and schema
96
127
  def build_ruby_llm_tool(tool)
97
- tool[:function] || tool
98
- # RubyLLM uses a different tool format - this would need adaptation
99
- # based on actual RubyLLM API
128
+ definition = tool[:function] || tool
129
+ tool_name = definition[:name].to_s
130
+ schema = definition[:parameters] || definition[:input_schema]
131
+
132
+ Class.new(RubyLLM::Tool) do
133
+ description definition[:description]
134
+ parameters schema if schema
135
+ define_singleton_method(:tool_name) { tool_name }
136
+ end
100
137
  end
101
138
 
139
+ # Add a message to the RubyLLM chat instance based on its role
140
+ # @param chat [RubyLLM::Chat] RubyLLM chat instance
141
+ # @param message [Message] conversation message dispatched by role (system, user, assistant, tool)
142
+ # @return [void]
102
143
  def add_message_to_chat(chat, message)
103
144
  case message.role
104
145
  when Role::SYSTEM
105
- chat.with_system_prompt(message.content)
146
+ chat.with_instructions(message.content, append: true)
106
147
  when Role::USER
107
- chat.ask(message.content, stream: false)
148
+ text, attachments = split_user_content(chat, message.content)
149
+ chat.add_message(role: :user, content: text, attachments: attachments)
108
150
  when Role::ASSISTANT
109
- # RubyLLM manages assistant messages internally
151
+ chat.add_message(
152
+ role: :assistant,
153
+ content: message.content.to_s.empty? ? nil : message.content,
154
+ tool_calls: build_ruby_llm_tool_calls(message.tool_calls)
155
+ )
110
156
  when Role::TOOL
111
- # Tool results handled differently in RubyLLM
157
+ chat.add_message(role: :tool, content: message.content.to_s, tool_call_id: message.tool_call_id)
112
158
  end
113
159
  end
114
160
 
161
+ # Split user content into text and RubyLLM attachments. Media the model cannot read, or that is missing,
162
+ # becomes placeholder text, as RubyLLM raises on it
163
+ # @param chat [RubyLLM::Chat] chat whose model decides which images it accepts
164
+ # @param content [String, Array<Hash>] text or content blocks with text and media
165
+ # @return [Array(String, Array<RubyLLM::Attachment>)] text and attachments
166
+ def split_user_content(chat, content)
167
+ return [content, []] unless content.is_a?(Array)
168
+
169
+ vision = chat.model.modalities.input.include?("image")
170
+ texts = []
171
+ attachments = []
172
+
173
+ content.each do |block|
174
+ next texts << block[:text] if block[:type] == "text"
175
+ next unless block[:type] == "media"
176
+
177
+ attachment = build_attachment(block[:media], vision)
178
+ attachment.is_a?(String) ? texts << attachment : attachments << attachment
179
+ end
180
+
181
+ [texts.join("\n"), attachments]
182
+ end
183
+
184
+ # Convert a media item to a RubyLLM attachment, or to placeholder text when it cannot be sent
185
+ # @param media [Bus::Media, nil] media with a file path or base64 data
186
+ # @param vision [Boolean] whether the model accepts image input
187
+ # @return [RubyLLM::Attachment, String] attachment or placeholder text
188
+ def build_attachment(media, vision)
189
+ return "[unsupported media]" unless media
190
+ unless vision && media.image? && Media::Processor::VISION_IMAGE_TYPES.include?(media.mime_type)
191
+ return "[Attached: #{media.filename} (#{media.mime_type})]"
192
+ end
193
+
194
+ filename = media.filename || File.basename(media.path.to_s)
195
+ if media.data
196
+ RubyLLM::Attachment.new(StringIO.new(Base64.decode64(media.data)), filename: filename)
197
+ elsif media.path && File.exist?(media.path)
198
+ RubyLLM::Attachment.new(media.path, filename: filename)
199
+ else
200
+ "[Image not available]"
201
+ end
202
+ end
203
+
204
+ # Convert pocketrb tool calls to RubyLLM tool calls keyed by ID
205
+ # @param tool_calls [Array<ToolCall>, nil] tool calls made by the assistant
206
+ # @return [Hash{String => RubyLLM::ToolCall}, nil] RubyLLM tool calls
207
+ def build_ruby_llm_tool_calls(tool_calls)
208
+ return if tool_calls.nil? || tool_calls.empty?
209
+
210
+ tool_calls.to_h do |tc|
211
+ [tc.id, RubyLLM::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)]
212
+ end
213
+ end
214
+
215
+ # Parse a RubyLLM response into an LLMResponse
216
+ # @param response [RubyLLM::Message] RubyLLM assistant message
217
+ # @param model [String] model name used
218
+ # @return [LLMResponse] structured response
115
219
  def parse_ruby_llm_response(response, model)
116
- tool_calls = (response.tool_calls || []).map do |tc|
117
- ToolCall.new(
118
- id: tc.id,
119
- name: tc.name,
120
- arguments: tc.arguments
121
- )
220
+ tool_calls = (response.tool_calls || {}).values.map do |tc|
221
+ ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)
122
222
  end
123
223
 
124
224
  LLMResponse.new(
125
225
  content: response.content,
126
226
  tool_calls: tool_calls,
127
227
  usage: Usage.new(
128
- input_tokens: response.input_tokens || 0,
129
- output_tokens: response.output_tokens || 0
228
+ input_tokens: response.tokens.input || 0,
229
+ output_tokens: response.tokens.output || 0,
230
+ cache_read: response.tokens.cache_read,
231
+ cache_write: response.tokens.cache_write
130
232
  ),
131
- model: model
233
+ stop_reason: stop_reason(response),
234
+ model: model,
235
+ thinking: response.thinking&.text
132
236
  )
133
237
  end
238
+
239
+ # Map a RubyLLM finish reason to an LLMResponse stop reason
240
+ # @param response [RubyLLM::Message] RubyLLM assistant message
241
+ # @return [Symbol] :tool_use, :max_tokens or :end_turn
242
+ def stop_reason(response)
243
+ return :tool_use if response.tool_call?
244
+ return :max_tokens if response.max_tokens?
245
+
246
+ :end_turn
247
+ end
134
248
  end
135
249
  end
136
250
  end
@@ -11,18 +11,31 @@ module Pocketrb
11
11
  :model, # String - model that generated the response
12
12
  :thinking # String|nil - extended thinking content (Claude)
13
13
  ) do
14
+ # Initialize LLM response
15
+ # @param content [String, nil] Text content of the response
16
+ # @param tool_calls [Array<ToolCall>] Tool calls requested by the model (defaults to empty)
17
+ # @param usage [Usage, nil] Token usage statistics
18
+ # @param stop_reason [Symbol] Reason for stopping (:end_turn, :tool_use, :max_tokens, :stop_sequence)
19
+ # @param model [String, nil] Model that generated the response
20
+ # @param thinking [String, nil] Extended thinking content (Claude)
14
21
  def initialize(content:, tool_calls: [], usage: nil, stop_reason: :end_turn, model: nil, thinking: nil)
15
22
  super
16
23
  end
17
24
 
25
+ # Check if response has tool calls
26
+ # @return [Boolean] True if tool calls are present
18
27
  def has_tool_calls?
19
28
  tool_calls && !tool_calls.empty?
20
29
  end
21
30
 
31
+ # Check if response has text content
32
+ # @return [Boolean] True if content is present
22
33
  def has_content?
23
34
  content && !content.empty?
24
35
  end
25
36
 
37
+ # Check if response has thinking content
38
+ # @return [Boolean] True if thinking is present
26
39
  def has_thinking?
27
40
  thinking && !thinking.empty?
28
41
  end
@@ -34,8 +47,12 @@ module Pocketrb
34
47
  :name, # String - name of the tool to execute
35
48
  :arguments # Hash - arguments to pass to the tool
36
49
  ) do
50
+ # Initialize tool call
51
+ # @param id [String] Unique identifier for this tool call
52
+ # @param name [String] Name of the tool to execute
53
+ # @param arguments [Hash, String] Arguments to pass to the tool (JSON string or hash)
37
54
  def initialize(id:, name:, arguments:)
38
- args = arguments.is_a?(String) ? JSON.parse(arguments) : arguments
55
+ args = arguments.is_a?(String) ? JSON.parse(arguments, allow_duplicate_key: true) : arguments
39
56
  super(id: id, name: name, arguments: args)
40
57
  rescue JSON::ParserError
41
58
  super(id: id, name: name, arguments: {})
@@ -49,10 +66,17 @@ module Pocketrb
49
66
  :cache_read, # Integer|nil - tokens read from cache
50
67
  :cache_write # Integer|nil - tokens written to cache
51
68
  ) do
69
+ # Initialize token usage statistics
70
+ # @param input_tokens [Integer] Tokens in the input (defaults to 0)
71
+ # @param output_tokens [Integer] Tokens in the output (defaults to 0)
72
+ # @param cache_read [Integer, nil] Tokens read from cache
73
+ # @param cache_write [Integer, nil] Tokens written to cache
52
74
  def initialize(input_tokens: 0, output_tokens: 0, cache_read: nil, cache_write: nil)
53
75
  super
54
76
  end
55
77
 
78
+ # Calculate total tokens used
79
+ # @return [Integer] Sum of input and output tokens
56
80
  def total_tokens
57
81
  input_tokens + output_tokens
58
82
  end
@@ -60,9 +84,13 @@ module Pocketrb
60
84
 
61
85
  # Message role for conversation history
62
86
  module Role
87
+ # System message role
63
88
  SYSTEM = "system"
89
+ # User message role
64
90
  USER = "user"
91
+ # Assistant message role
65
92
  ASSISTANT = "assistant"
93
+ # Tool result message role
66
94
  TOOL = "tool"
67
95
  end
68
96
 
@@ -74,14 +102,27 @@ module Pocketrb
74
102
  :tool_call_id, # String|nil - for tool role, the tool call this responds to
75
103
  :tool_calls # Array<ToolCall>|nil - for assistant role, tool calls made
76
104
  ) do
105
+ # Initialize message
106
+ # @param role [String] Message role (system, user, assistant, or tool)
107
+ # @param content [String, Array] Text content or content blocks
108
+ # @param name [String, nil] For tool role, the tool name
109
+ # @param tool_call_id [String, nil] For tool role, the tool call this responds to
110
+ # @param tool_calls [Array<ToolCall>, nil] For assistant role, tool calls made
77
111
  def initialize(role:, content:, name: nil, tool_call_id: nil, tool_calls: nil)
78
112
  super
79
113
  end
80
114
 
115
+ # Create a system message
116
+ # @param content [String] System message content
117
+ # @return [Message] New system message
81
118
  def self.system(content)
82
119
  new(role: Role::SYSTEM, content: content)
83
120
  end
84
121
 
122
+ # Create a user message
123
+ # @param content [String] User message content
124
+ # @param media [Array<Media>, nil] Media attachments (images, files)
125
+ # @return [Message] New user message
85
126
  def self.user(content, media: nil)
86
127
  if media && !media.empty?
87
128
  # Build content blocks array with text and images
@@ -99,10 +140,19 @@ module Pocketrb
99
140
  end
100
141
  end
101
142
 
143
+ # Create an assistant message
144
+ # @param content [String] Assistant message content
145
+ # @param tool_calls [Array<ToolCall>, nil] Tool calls made by the assistant
146
+ # @return [Message] New assistant message
102
147
  def self.assistant(content, tool_calls: nil)
103
148
  new(role: Role::ASSISTANT, content: content, tool_calls: tool_calls)
104
149
  end
105
150
 
151
+ # Create a tool result message
152
+ # @param tool_call_id [String] Tool call identifier this result responds to
153
+ # @param name [String] Name of the tool that was executed
154
+ # @param content [String] Tool execution result content
155
+ # @return [Message] New tool result message
106
156
  def self.tool_result(tool_call_id:, name:, content:)
107
157
  new(role: Role::TOOL, content: content, name: name, tool_call_id: tool_call_id)
108
158
  end
@@ -3,11 +3,14 @@
3
3
  require "json"
4
4
 
5
5
  module Pocketrb
6
+ # Session management for conversation history
6
7
  module Session
7
8
  # Manages session persistence using JSONL files
8
9
  class Manager
9
10
  attr_reader :storage_dir
10
11
 
12
+ # Initialize session manager
13
+ # @param storage_dir [String, Pathname] Directory path for storing session JSONL files
11
14
  def initialize(storage_dir:)
12
15
  @storage_dir = Pathname.new(storage_dir)
13
16
  @sessions = {}
@@ -27,7 +30,7 @@ module Pocketrb
27
30
 
28
31
  # Get an existing session
29
32
  # @param key [String] Session key
30
- # @return [Session|nil]
33
+ # @return [Session, nil]
31
34
  def get(key)
32
35
  @mutex.synchronize do
33
36
  @sessions[key] || load_session(key)
@@ -76,7 +79,8 @@ module Pocketrb
76
79
  end
77
80
  end
78
81
 
79
- # Clear all sessions
82
+ # Clear all sessions from memory and disk
83
+ # @return [void]
80
84
  def clear_all!
81
85
  @mutex.synchronize do
82
86
  @sessions.clear
@@ -86,20 +90,31 @@ module Pocketrb
86
90
 
87
91
  private
88
92
 
93
+ # Ensure the storage directory exists
94
+ # @return [void]
89
95
  def ensure_storage_dir!
90
96
  FileUtils.mkdir_p(@storage_dir) unless @storage_dir.exist?
91
97
  end
92
98
 
99
+ # Build the JSONL file path for a session key
100
+ # @param key [String] session key
101
+ # @return [Pathname] sanitized session file path
93
102
  def session_file(key)
94
103
  # Sanitize key for filename
95
104
  safe_key = key.gsub(/[^a-zA-Z0-9_-]/, "_")
96
105
  @storage_dir.join("#{safe_key}.jsonl")
97
106
  end
98
107
 
108
+ # Load an existing session or create a new one
109
+ # @param key [String] session key
110
+ # @return [Session] loaded or newly created session
99
111
  def load_or_create(key)
100
112
  load_session(key) || Session.new(key: key)
101
113
  end
102
114
 
115
+ # Load a session from its JSONL file
116
+ # @param key [String] session key
117
+ # @return [Session, nil] loaded session or nil if file does not exist
103
118
  def load_session(key)
104
119
  file = session_file(key)
105
120
  return nil unless file.exist?
@@ -124,6 +139,9 @@ module Pocketrb
124
139
  Session.new(key: key)
125
140
  end
126
141
 
142
+ # Parse raw tool call hashes into ToolCall objects
143
+ # @param tool_calls [Array<Hash>, nil] raw tool call data
144
+ # @return [Array<Providers::ToolCall>, nil] parsed tool calls or nil
127
145
  def parse_tool_calls(tool_calls)
128
146
  return nil unless tool_calls
129
147
 
@@ -136,6 +154,9 @@ module Pocketrb
136
154
  end
137
155
  end
138
156
 
157
+ # Write all session messages to a JSONL file
158
+ # @param session [Session] conversation session whose messages will be written to JSONL
159
+ # @return [void]
139
160
  def persist_session(session)
140
161
  file = session_file(session.key)
141
162
  File.open(file, "w") do |f|
@@ -145,6 +166,9 @@ module Pocketrb
145
166
  end
146
167
  end
147
168
 
169
+ # Serialize a message to a JSON string
170
+ # @param message [Providers::Message] conversation turn to convert into a compact JSON line
171
+ # @return [String] JSON string
148
172
  def message_to_json(message)
149
173
  {
150
174
  role: message.role,
@@ -158,6 +182,8 @@ module Pocketrb
158
182
  end
159
183
 
160
184
  # Sanitize content to ensure valid UTF-8 for JSON encoding
185
+ # @param content [String, Array, nil] message content to sanitize
186
+ # @return [String, Array, nil] sanitized content
161
187
  def sanitize_content(content)
162
188
  return nil if content.nil?
163
189
 
@@ -171,6 +197,9 @@ module Pocketrb
171
197
  end
172
198
  end
173
199
 
200
+ # Sanitize a single content block's string values to valid UTF-8
201
+ # @param block [Hash] content block hash
202
+ # @return [Hash] sanitized content block
174
203
  def sanitize_content_block(block)
175
204
  return block unless block.is_a?(Hash)
176
205
 
@@ -183,6 +212,9 @@ module Pocketrb
183
212
  end
184
213
  end
185
214
 
215
+ # Delete the JSONL file for a session
216
+ # @param key [String] session key
217
+ # @return [void]
186
218
  def delete_session_file(key)
187
219
  file = session_file(key)
188
220
  File.delete(file) if file.exist?