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
|
@@ -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-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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.
|
|
146
|
+
chat.with_instructions(message.content, append: true)
|
|
106
147
|
when Role::USER
|
|
107
|
-
chat
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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.
|
|
129
|
-
output_tokens: response.
|
|
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
|
-
|
|
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
|
|
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?
|