pocketrb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. metadata +327 -0
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Providers
5
+ # Provider using the RubyLLM gem for multi-model support
6
+ # This is an alternative to direct API calls
7
+ class RubyLLMProvider < Base
8
+ def name
9
+ :ruby_llm
10
+ end
11
+
12
+ def default_model
13
+ "claude-sonnet-4-20250514"
14
+ end
15
+
16
+ def available_models
17
+ return [] unless ruby_llm_available?
18
+
19
+ RubyLLM.models.map(&:id)
20
+ end
21
+
22
+ def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
23
+ ensure_ruby_llm!
24
+
25
+ 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) }
35
+
36
+ response = chat_instance.complete
37
+
38
+ parse_ruby_llm_response(response, model)
39
+ end
40
+
41
+ def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
42
+ ensure_ruby_llm!
43
+
44
+ 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) }
52
+
53
+ accumulated = ""
54
+ response = chat_instance.stream do |chunk|
55
+ accumulated << chunk.content if chunk.content
56
+ block&.call(chunk.content)
57
+ end
58
+
59
+ parse_ruby_llm_response(response, model)
60
+ end
61
+
62
+ protected
63
+
64
+ def supported_features
65
+ %i[tools streaming]
66
+ end
67
+
68
+ def validate_config!
69
+ # RubyLLM handles API key validation internally
70
+ end
71
+
72
+ private
73
+
74
+ def ruby_llm_available?
75
+ defined?(RubyLLM)
76
+ end
77
+
78
+ def ensure_ruby_llm!
79
+ return if ruby_llm_available?
80
+
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
87
+ end
88
+
89
+ def configure_ruby_llm
90
+ RubyLLM.configure do |c|
91
+ c.anthropic_api_key = api_key(:anthropic_api_key) if api_key(:anthropic_api_key)
92
+ c.openai_api_key = api_key(:openai_api_key) if api_key(:openai_api_key)
93
+ end
94
+ end
95
+
96
+ 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
100
+ end
101
+
102
+ def add_message_to_chat(chat, message)
103
+ case message.role
104
+ when Role::SYSTEM
105
+ chat.with_system_prompt(message.content)
106
+ when Role::USER
107
+ chat.ask(message.content, stream: false)
108
+ when Role::ASSISTANT
109
+ # RubyLLM manages assistant messages internally
110
+ when Role::TOOL
111
+ # Tool results handled differently in RubyLLM
112
+ end
113
+ end
114
+
115
+ 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
+ )
122
+ end
123
+
124
+ LLMResponse.new(
125
+ content: response.content,
126
+ tool_calls: tool_calls,
127
+ usage: Usage.new(
128
+ input_tokens: response.input_tokens || 0,
129
+ output_tokens: response.output_tokens || 0
130
+ ),
131
+ model: model
132
+ )
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Providers
5
+ # Response from an LLM provider
6
+ LLMResponse = Data.define(
7
+ :content, # String|nil - text content of the response
8
+ :tool_calls, # Array<ToolCall> - tool calls requested by the model
9
+ :usage, # Usage - token usage statistics
10
+ :stop_reason, # Symbol - :end_turn, :tool_use, :max_tokens, :stop_sequence
11
+ :model, # String - model that generated the response
12
+ :thinking # String|nil - extended thinking content (Claude)
13
+ ) do
14
+ def initialize(content:, tool_calls: [], usage: nil, stop_reason: :end_turn, model: nil, thinking: nil)
15
+ super
16
+ end
17
+
18
+ def has_tool_calls?
19
+ tool_calls && !tool_calls.empty?
20
+ end
21
+
22
+ def has_content?
23
+ content && !content.empty?
24
+ end
25
+
26
+ def has_thinking?
27
+ thinking && !thinking.empty?
28
+ end
29
+ end
30
+
31
+ # Tool call from the model
32
+ ToolCall = Data.define(
33
+ :id, # String - unique identifier for this tool call
34
+ :name, # String - name of the tool to execute
35
+ :arguments # Hash - arguments to pass to the tool
36
+ ) do
37
+ def initialize(id:, name:, arguments:)
38
+ args = arguments.is_a?(String) ? JSON.parse(arguments) : arguments
39
+ super(id: id, name: name, arguments: args)
40
+ rescue JSON::ParserError
41
+ super(id: id, name: name, arguments: {})
42
+ end
43
+ end
44
+
45
+ # Token usage statistics
46
+ Usage = Data.define(
47
+ :input_tokens, # Integer - tokens in the input
48
+ :output_tokens, # Integer - tokens in the output
49
+ :cache_read, # Integer|nil - tokens read from cache
50
+ :cache_write # Integer|nil - tokens written to cache
51
+ ) do
52
+ def initialize(input_tokens: 0, output_tokens: 0, cache_read: nil, cache_write: nil)
53
+ super
54
+ end
55
+
56
+ def total_tokens
57
+ input_tokens + output_tokens
58
+ end
59
+ end
60
+
61
+ # Message role for conversation history
62
+ module Role
63
+ SYSTEM = "system"
64
+ USER = "user"
65
+ ASSISTANT = "assistant"
66
+ TOOL = "tool"
67
+ end
68
+
69
+ # Message in conversation history
70
+ Message = Data.define(
71
+ :role, # String - Role::SYSTEM, USER, ASSISTANT, or TOOL
72
+ :content, # String|Array - text content or content blocks
73
+ :name, # String|nil - for tool role, the tool name
74
+ :tool_call_id, # String|nil - for tool role, the tool call this responds to
75
+ :tool_calls # Array<ToolCall>|nil - for assistant role, tool calls made
76
+ ) do
77
+ def initialize(role:, content:, name: nil, tool_call_id: nil, tool_calls: nil)
78
+ super
79
+ end
80
+
81
+ def self.system(content)
82
+ new(role: Role::SYSTEM, content: content)
83
+ end
84
+
85
+ def self.user(content, media: nil)
86
+ if media && !media.empty?
87
+ # Build content blocks array with text and images
88
+ blocks = []
89
+ blocks << { type: "text", text: content } if content && !content.empty?
90
+
91
+ media.each do |m|
92
+ # Media will be formatted by the provider
93
+ blocks << { type: "media", media: m }
94
+ end
95
+
96
+ new(role: Role::USER, content: blocks)
97
+ else
98
+ new(role: Role::USER, content: content)
99
+ end
100
+ end
101
+
102
+ def self.assistant(content, tool_calls: nil)
103
+ new(role: Role::ASSISTANT, content: content, tool_calls: tool_calls)
104
+ end
105
+
106
+ def self.tool_result(tool_call_id:, name:, content:)
107
+ new(role: Role::TOOL, content: content, name: name, tool_call_id: tool_call_id)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pocketrb
6
+ module Session
7
+ # Manages session persistence using JSONL files
8
+ class Manager
9
+ attr_reader :storage_dir
10
+
11
+ def initialize(storage_dir:)
12
+ @storage_dir = Pathname.new(storage_dir)
13
+ @sessions = {}
14
+ @mutex = Mutex.new
15
+
16
+ ensure_storage_dir!
17
+ end
18
+
19
+ # Get or create a session
20
+ # @param key [String] Session key
21
+ # @return [Session]
22
+ def get_or_create(key)
23
+ @mutex.synchronize do
24
+ @sessions[key] ||= load_or_create(key)
25
+ end
26
+ end
27
+
28
+ # Get an existing session
29
+ # @param key [String] Session key
30
+ # @return [Session|nil]
31
+ def get(key)
32
+ @mutex.synchronize do
33
+ @sessions[key] || load_session(key)
34
+ end
35
+ end
36
+
37
+ # Save a session
38
+ # @param session [Session]
39
+ def save(session)
40
+ @mutex.synchronize do
41
+ @sessions[session.key] = session
42
+ persist_session(session)
43
+ end
44
+ end
45
+
46
+ # Delete a session
47
+ # @param key [String] Session key
48
+ def delete(key)
49
+ @mutex.synchronize do
50
+ @sessions.delete(key)
51
+ delete_session_file(key)
52
+ end
53
+ end
54
+
55
+ # List all session keys
56
+ # @return [Array<String>]
57
+ def list_keys
58
+ @mutex.synchronize do
59
+ loaded = @sessions.keys
60
+ persisted = Dir.glob(@storage_dir.join("*.jsonl")).map do |f|
61
+ File.basename(f, ".jsonl")
62
+ end
63
+ (loaded + persisted).uniq
64
+ end
65
+ end
66
+
67
+ # Append a message to a session's JSONL file (real-time logging)
68
+ # @param key [String] Session key
69
+ # @param message [Message]
70
+ def append_message(key, message)
71
+ @mutex.synchronize do
72
+ file = session_file(key)
73
+ File.open(file, "a") do |f|
74
+ f.puts(message.to_h.to_json)
75
+ end
76
+ end
77
+ end
78
+
79
+ # Clear all sessions
80
+ def clear_all!
81
+ @mutex.synchronize do
82
+ @sessions.clear
83
+ Dir.glob(@storage_dir.join("*.jsonl")).each { |f| File.delete(f) }
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def ensure_storage_dir!
90
+ FileUtils.mkdir_p(@storage_dir) unless @storage_dir.exist?
91
+ end
92
+
93
+ def session_file(key)
94
+ # Sanitize key for filename
95
+ safe_key = key.gsub(/[^a-zA-Z0-9_-]/, "_")
96
+ @storage_dir.join("#{safe_key}.jsonl")
97
+ end
98
+
99
+ def load_or_create(key)
100
+ load_session(key) || Session.new(key: key)
101
+ end
102
+
103
+ def load_session(key)
104
+ file = session_file(key)
105
+ return nil unless file.exist?
106
+
107
+ messages = []
108
+ File.foreach(file) do |line|
109
+ next if line.strip.empty?
110
+
111
+ data = JSON.parse(line.strip)
112
+ messages << Providers::Message.new(
113
+ role: data["role"],
114
+ content: data["content"],
115
+ name: data["name"],
116
+ tool_call_id: data["tool_call_id"],
117
+ tool_calls: parse_tool_calls(data["tool_calls"])
118
+ )
119
+ end
120
+
121
+ Session.new(key: key, messages: messages)
122
+ rescue JSON::ParserError => e
123
+ Pocketrb.logger.error("Failed to parse session #{key}: #{e.message}")
124
+ Session.new(key: key)
125
+ end
126
+
127
+ def parse_tool_calls(tool_calls)
128
+ return nil unless tool_calls
129
+
130
+ tool_calls.map do |tc|
131
+ Providers::ToolCall.new(
132
+ id: tc["id"],
133
+ name: tc["name"],
134
+ arguments: tc["arguments"]
135
+ )
136
+ end
137
+ end
138
+
139
+ def persist_session(session)
140
+ file = session_file(session.key)
141
+ File.open(file, "w") do |f|
142
+ session.messages.each do |msg|
143
+ f.puts(message_to_json(msg))
144
+ end
145
+ end
146
+ end
147
+
148
+ def message_to_json(message)
149
+ {
150
+ role: message.role,
151
+ content: sanitize_content(message.content),
152
+ name: message.name,
153
+ tool_call_id: message.tool_call_id,
154
+ tool_calls: message.tool_calls&.map do |tc|
155
+ { id: tc.id, name: tc.name, arguments: tc.arguments }
156
+ end
157
+ }.compact.to_json
158
+ end
159
+
160
+ # Sanitize content to ensure valid UTF-8 for JSON encoding
161
+ def sanitize_content(content)
162
+ return nil if content.nil?
163
+
164
+ if content.is_a?(String)
165
+ # Replace invalid UTF-8 bytes with replacement character
166
+ content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\uFFFD")
167
+ elsif content.is_a?(Array)
168
+ content.map { |block| sanitize_content_block(block) }
169
+ else
170
+ content
171
+ end
172
+ end
173
+
174
+ def sanitize_content_block(block)
175
+ return block unless block.is_a?(Hash)
176
+
177
+ block.transform_values do |v|
178
+ if v.is_a?(String)
179
+ v.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\uFFFD")
180
+ else
181
+ v
182
+ end
183
+ end
184
+ end
185
+
186
+ def delete_session_file(key)
187
+ file = session_file(key)
188
+ File.delete(file) if file.exist?
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Session
5
+ # Represents a conversation session with history
6
+ class Session
7
+ attr_reader :key, :metadata, :created_at
8
+ attr_accessor :messages
9
+
10
+ def initialize(key:, messages: [], metadata: {})
11
+ @key = key
12
+ @messages = messages
13
+ @metadata = metadata
14
+ @created_at = Time.now
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ # Add a message to the session
19
+ # @param role [String] Message role
20
+ # @param content [String] Message content
21
+ # @param kwargs [Hash] Additional message attributes
22
+ def add_message(role:, content:, **kwargs)
23
+ @mutex.synchronize do
24
+ message = Providers::Message.new(
25
+ role: role,
26
+ content: content,
27
+ **kwargs
28
+ )
29
+ @messages << message
30
+ save_to_log(message)
31
+ end
32
+ end
33
+
34
+ # Add a user message (with optional media)
35
+ # @param content [String] Text content
36
+ # @param media [Array<Bus::Media>] Media attachments
37
+ def add_user_message(content, media: nil)
38
+ if media && !media.empty?
39
+ # Build content blocks with text and media references
40
+ # Note: We store media metadata, not the actual data
41
+ blocks = []
42
+ blocks << { type: "text", text: content } if content && !content.empty?
43
+
44
+ media.each do |m|
45
+ blocks << {
46
+ type: "media",
47
+ media: {
48
+ type: m.type,
49
+ path: m.path.to_s,
50
+ mime_type: m.mime_type,
51
+ filename: m.filename
52
+ # Don't store base64 data in session - too large
53
+ }
54
+ }
55
+ end
56
+
57
+ add_message(role: Providers::Role::USER, content: blocks)
58
+ else
59
+ add_message(role: Providers::Role::USER, content: content)
60
+ end
61
+ end
62
+
63
+ # Add an assistant message
64
+ def add_assistant_message(content, tool_calls: nil)
65
+ # Truncate large tool call arguments to prevent context bloat
66
+ sanitized_calls = sanitize_tool_calls(tool_calls) if tool_calls
67
+ add_message(role: Providers::Role::ASSISTANT, content: content, tool_calls: sanitized_calls)
68
+ end
69
+
70
+ # Add a tool result message
71
+ MAX_TOOL_RESULT_LENGTH = 2000
72
+
73
+ def add_tool_result(tool_call_id:, name:, content:)
74
+ # Truncate large tool results to prevent context bloat
75
+ truncated_content = if content.is_a?(String) && content.length > MAX_TOOL_RESULT_LENGTH
76
+ "#{content[0...MAX_TOOL_RESULT_LENGTH]}... [truncated #{content.length - MAX_TOOL_RESULT_LENGTH} chars]"
77
+ else
78
+ content
79
+ end
80
+
81
+ add_message(
82
+ role: Providers::Role::TOOL,
83
+ content: truncated_content,
84
+ name: name,
85
+ tool_call_id: tool_call_id
86
+ )
87
+ end
88
+
89
+ # Get message history (optionally limited)
90
+ # @param max_messages [Integer|nil] Maximum messages to return
91
+ # @return [Array<Message>]
92
+ def get_history(max_messages: nil)
93
+ @mutex.synchronize do
94
+ return @messages.dup if max_messages.nil?
95
+
96
+ @messages.last(max_messages)
97
+ end
98
+ end
99
+
100
+ # Clear all messages
101
+ def clear
102
+ @mutex.synchronize do
103
+ @messages.clear
104
+ end
105
+ end
106
+
107
+ # Get the last message
108
+ def last_message
109
+ @mutex.synchronize { @messages.last }
110
+ end
111
+
112
+ # Number of messages
113
+ def message_count
114
+ @mutex.synchronize { @messages.size }
115
+ end
116
+
117
+ # Check if session is empty
118
+ def empty?
119
+ @mutex.synchronize { @messages.empty? }
120
+ end
121
+
122
+ # Set metadata value
123
+ def set_meta(key, value)
124
+ @mutex.synchronize { @metadata[key] = value }
125
+ end
126
+
127
+ # Get metadata value
128
+ def get_meta(key)
129
+ @mutex.synchronize { @metadata[key] }
130
+ end
131
+
132
+ # Convert to hash for serialization
133
+ def to_h
134
+ @mutex.synchronize do
135
+ {
136
+ key: @key,
137
+ messages: @messages.map(&:to_h),
138
+ metadata: @metadata,
139
+ created_at: @created_at.iso8601
140
+ }
141
+ end
142
+ end
143
+
144
+ # Create from hash
145
+ def self.from_h(hash)
146
+ messages = (hash[:messages] || hash["messages"] || []).map do |m|
147
+ Providers::Message.new(
148
+ role: m[:role] || m["role"],
149
+ content: m[:content] || m["content"],
150
+ name: m[:name] || m["name"],
151
+ tool_call_id: m[:tool_call_id] || m["tool_call_id"],
152
+ tool_calls: m[:tool_calls] || m["tool_calls"]
153
+ )
154
+ end
155
+
156
+ session = new(
157
+ key: hash[:key] || hash["key"],
158
+ messages: messages,
159
+ metadata: hash[:metadata] || hash["metadata"] || {}
160
+ )
161
+ session.instance_variable_set(
162
+ :@created_at,
163
+ Time.parse(hash[:created_at] || hash["created_at"])
164
+ )
165
+ session
166
+ rescue StandardError => e
167
+ Pocketrb.logger.error("Failed to load session: #{e.message}")
168
+ new(key: hash[:key] || hash["key"])
169
+ end
170
+
171
+ private
172
+
173
+ # Truncate large arguments in tool calls to prevent context bloat
174
+ # Large content (scripts, files) shouldn't be stored in session history
175
+ MAX_ARG_LENGTH = 500
176
+
177
+ def sanitize_tool_calls(tool_calls)
178
+ return nil if tool_calls.nil?
179
+
180
+ tool_calls.map do |tc|
181
+ sanitized_args = tc.arguments.transform_values do |v|
182
+ if v.is_a?(String) && v.length > MAX_ARG_LENGTH
183
+ "#{v[0...MAX_ARG_LENGTH]}... [truncated #{v.length - MAX_ARG_LENGTH} chars]"
184
+ else
185
+ v
186
+ end
187
+ end
188
+
189
+ # Create new tool call with sanitized arguments
190
+ Providers::ToolCall.new(
191
+ id: tc.id,
192
+ name: tc.name,
193
+ arguments: sanitized_args
194
+ )
195
+ end
196
+ end
197
+
198
+ def save_to_log(message)
199
+ # Session manager handles persistence via JSONL
200
+ # This is a hook for real-time logging if needed
201
+ end
202
+ end
203
+ end
204
+ end