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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Planning
5
+ # Tool for creating and managing execution plans
6
+ class Tool < Tools::Base
7
+ def name
8
+ "plan"
9
+ end
10
+
11
+ def description
12
+ "Create and manage execution plans for complex tasks. Plans help organize multi-step work and track progress."
13
+ end
14
+
15
+ def parameters
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ action: {
20
+ type: "string",
21
+ enum: %w[create update complete fail list show delete],
22
+ description: "Action to perform"
23
+ },
24
+ plan_name: {
25
+ type: "string",
26
+ description: "Name of the plan"
27
+ },
28
+ plan_description: {
29
+ type: "string",
30
+ description: "Description of the plan (for create)"
31
+ },
32
+ steps: {
33
+ type: "array",
34
+ items: { type: "string" },
35
+ description: "Steps to add (for create or update)"
36
+ },
37
+ step_index: {
38
+ type: "integer",
39
+ description: "Step index to update (0-indexed)"
40
+ },
41
+ notes: {
42
+ type: "string",
43
+ description: "Notes for the step completion/failure"
44
+ }
45
+ },
46
+ required: ["action"]
47
+ }
48
+ end
49
+
50
+ def execute(
51
+ action:,
52
+ plan_name: nil,
53
+ plan_description: nil,
54
+ steps: nil,
55
+ step_index: nil,
56
+ notes: nil
57
+ )
58
+ case action
59
+ when "create"
60
+ create_plan(plan_name, plan_description, steps)
61
+ when "update"
62
+ update_plan(plan_name, steps, step_index, notes)
63
+ when "complete"
64
+ complete_step(plan_name, step_index, notes)
65
+ when "fail"
66
+ fail_step(plan_name, step_index, notes)
67
+ when "list"
68
+ list_plans
69
+ when "show"
70
+ show_plan(plan_name)
71
+ when "delete"
72
+ delete_plan(plan_name)
73
+ else
74
+ error("Unknown action: #{action}")
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def manager
81
+ @manager ||= Manager.new(workspace: workspace)
82
+ end
83
+
84
+ def create_plan(name, description, steps)
85
+ return error("Plan name is required") unless name
86
+ return error("At least one step is required") if steps.nil? || steps.empty?
87
+
88
+ plan = manager.create_plan(name: name, description: description, steps: steps)
89
+ plan.activate!
90
+ manager.update_plan(name: name) # Save activated state
91
+
92
+ success("Created and activated plan '#{name}' with #{steps.length} steps\n\n#{plan.to_markdown}")
93
+ rescue Error => e
94
+ error(e.message)
95
+ end
96
+
97
+ def update_plan(name, new_steps, step_index, notes)
98
+ return error("Plan name is required") unless name
99
+
100
+ plan = manager.update_plan(
101
+ name: name,
102
+ completed_step: step_index,
103
+ new_steps: new_steps,
104
+ notes: notes
105
+ )
106
+
107
+ success("Updated plan '#{name}'\n\n#{plan.to_markdown}")
108
+ rescue Error => e
109
+ error(e.message)
110
+ end
111
+
112
+ def complete_step(name, step_index, notes)
113
+ return error("Plan name is required") unless name
114
+ return error("Step index is required") if step_index.nil?
115
+
116
+ plan = manager.update_plan(name: name, completed_step: step_index, notes: notes)
117
+
118
+ if plan.complete?
119
+ success("Completed step #{step_index + 1}. Plan '#{name}' is now complete!\n\n#{plan.to_markdown}")
120
+ else
121
+ next_step = plan.next_step
122
+ success("Completed step #{step_index + 1}. Next: Step #{next_step.index + 1} - #{next_step.description}\n\n#{plan.to_markdown}")
123
+ end
124
+ rescue Error => e
125
+ error(e.message)
126
+ end
127
+
128
+ def fail_step(name, step_index, notes)
129
+ return error("Plan name is required") unless name
130
+ return error("Step index is required") if step_index.nil?
131
+
132
+ plan = manager.fail_step(name: name, step_index: step_index, notes: notes)
133
+ success("Marked step #{step_index + 1} as failed\n\n#{plan.to_markdown}")
134
+ rescue Error => e
135
+ error(e.message)
136
+ end
137
+
138
+ def list_plans
139
+ plans = manager.list_plans
140
+
141
+ return "No plans found" if plans.empty?
142
+
143
+ output = ["# Plans\n"]
144
+ plans.each do |plan|
145
+ status_emoji = case plan.status
146
+ when Plan::PlanStatus::ACTIVE then "🔄"
147
+ when Plan::PlanStatus::COMPLETED then "✅"
148
+ when Plan::PlanStatus::FAILED then "❌"
149
+ when Plan::PlanStatus::CANCELLED then "🚫"
150
+ else "📝"
151
+ end
152
+
153
+ output << "#{status_emoji} **#{plan.name}** - #{plan.progress}% (#{plan.status})"
154
+ end
155
+
156
+ output.join("\n")
157
+ end
158
+
159
+ def show_plan(name)
160
+ return error("Plan name is required") unless name
161
+
162
+ plan = manager.get_plan(name)
163
+ return error("Plan '#{name}' not found") unless plan
164
+
165
+ plan.to_markdown
166
+ end
167
+
168
+ def delete_plan(name)
169
+ return error("Plan name is required") unless name
170
+
171
+ manager.delete_plan(name)
172
+ success("Deleted plan '#{name}'")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Pocketrb
7
+ module Providers
8
+ # Direct Anthropic Claude API provider
9
+ # Supports extended thinking and all Claude-specific features
10
+ class Anthropic < Base
11
+ API_URL = "https://api.anthropic.com/v1"
12
+ API_VERSION = "2023-06-01"
13
+
14
+ MODELS = {
15
+ "claude-opus-4-20250514" => { context: 200_000, output: 32_000 },
16
+ "claude-sonnet-4-20250514" => { context: 200_000, output: 64_000 },
17
+ "claude-3-5-haiku-20241022" => { context: 200_000, output: 8192 }
18
+ }.freeze
19
+
20
+ def name
21
+ :anthropic
22
+ end
23
+
24
+ def default_model
25
+ "claude-sonnet-4-20250514"
26
+ end
27
+
28
+ def available_models
29
+ MODELS.keys
30
+ end
31
+
32
+ def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
33
+ model ||= default_model
34
+ body = build_request_body(messages, tools, model, temperature, max_tokens, thinking)
35
+
36
+ response = client.post("/v1/messages") do |req|
37
+ req.body = body.to_json
38
+ end
39
+
40
+ handle_response(response)
41
+ end
42
+
43
+ def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
44
+ model ||= default_model
45
+ body = build_request_body(messages, tools, model, temperature, max_tokens, false)
46
+ body[:stream] = true
47
+
48
+ accumulated_content = ""
49
+ accumulated_tool_calls = []
50
+ usage = nil
51
+
52
+ client.post("/v1/messages") do |req|
53
+ req.body = body.to_json
54
+ req.options.on_data = proc do |chunk, _|
55
+ process_stream_chunk(chunk, accumulated_content, accumulated_tool_calls, &block)
56
+ end
57
+ end
58
+
59
+ LLMResponse.new(
60
+ content: accumulated_content,
61
+ tool_calls: accumulated_tool_calls,
62
+ usage: usage,
63
+ model: model
64
+ )
65
+ end
66
+
67
+ protected
68
+
69
+ def supported_features
70
+ %i[tools streaming thinking vision]
71
+ end
72
+
73
+ def validate_config!
74
+ return if oauth_token
75
+ return if api_key(:anthropic_api_key)
76
+
77
+ raise ConfigurationError,
78
+ "Either ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY is required for #{self.class.name}"
79
+ end
80
+
81
+ private
82
+
83
+ # Check for OAuth token (Max subscription via `claude setup-token`)
84
+ def oauth_token
85
+ @config[:anthropic_oauth_token] || ENV.fetch("ANTHROPIC_OAUTH_TOKEN", nil)
86
+ end
87
+
88
+ def using_oauth?
89
+ !oauth_token.nil?
90
+ end
91
+
92
+ def client
93
+ @client ||= Faraday.new(url: API_URL) do |f|
94
+ f.headers["Content-Type"] = "application/json"
95
+ f.headers["anthropic-version"] = API_VERSION
96
+
97
+ if using_oauth?
98
+ # OAuth authentication for Max subscription
99
+ # Token generated via: claude setup-token
100
+ f.headers["Authorization"] = "Bearer #{oauth_token}"
101
+ f.headers["anthropic-beta"] = "oauth-2025-04-20"
102
+ else
103
+ # Standard API key authentication
104
+ f.headers["x-api-key"] = api_key(:anthropic_api_key)
105
+ end
106
+
107
+ f.adapter Faraday.default_adapter
108
+ end
109
+ end
110
+
111
+ def build_request_body(messages, tools, model, temperature, max_tokens, thinking)
112
+ system_message = extract_system_message(messages)
113
+ conversation = format_messages(messages.reject { |m| m.role == Role::SYSTEM })
114
+
115
+ body = {
116
+ model: model,
117
+ messages: conversation,
118
+ max_tokens: max_tokens
119
+ }
120
+
121
+ body[:system] = system_message if system_message
122
+ body[:temperature] = temperature unless thinking
123
+ body[:tools] = format_tools(tools) if tools&.any?
124
+
125
+ body[:thinking] = { type: "enabled", budget_tokens: [max_tokens / 2, 10_000].min } if thinking
126
+
127
+ body
128
+ end
129
+
130
+ def extract_system_message(messages)
131
+ system_msg = messages.find { |m| m.role == Role::SYSTEM }
132
+ system_msg&.content
133
+ end
134
+
135
+ def format_message(message)
136
+ case message.role
137
+ when Role::USER
138
+ { role: "user", content: format_user_content(message.content) }
139
+ when Role::ASSISTANT
140
+ { role: "assistant", content: format_assistant_content(message) }
141
+
142
+ when Role::TOOL
143
+ {
144
+ role: "user",
145
+ content: [{
146
+ type: "tool_result",
147
+ tool_use_id: message.tool_call_id,
148
+ content: message.content.to_s
149
+ }]
150
+ }
151
+ else
152
+ raise ArgumentError, "Unknown role: #{message.role}"
153
+ end
154
+ end
155
+
156
+ def format_user_content(content)
157
+ return content if content.is_a?(String)
158
+
159
+ # Handle content blocks array (text + media)
160
+ if content.is_a?(Array)
161
+ content.map do |block|
162
+ if block.is_a?(Hash) && block[:type] == "media"
163
+ format_media_block(block[:media])
164
+ elsif block.is_a?(Hash) && block[:type] == "text"
165
+ { type: "text", text: block[:text] }
166
+ elsif block.is_a?(String)
167
+ { type: "text", text: block }
168
+ else
169
+ block
170
+ end
171
+ end
172
+ else
173
+ content.to_s
174
+ end
175
+ end
176
+
177
+ def format_media_block(media)
178
+ return { type: "text", text: "[unsupported media]" } unless media
179
+
180
+ # Only images are supported for vision
181
+ unless media.image? && Media::Processor::VISION_IMAGE_TYPES.include?(media.mime_type)
182
+ return { type: "text", text: "[Attached: #{media.filename} (#{media.mime_type})]" }
183
+ end
184
+
185
+ # Get base64 data
186
+ data = if media.data
187
+ media.data
188
+ elsif media.path && File.exist?(media.path)
189
+ require "base64"
190
+ Base64.strict_encode64(File.binread(media.path))
191
+ else
192
+ return { type: "text", text: "[Image not available]" }
193
+ end
194
+
195
+ {
196
+ type: "image",
197
+ source: {
198
+ type: "base64",
199
+ media_type: media.mime_type,
200
+ data: data
201
+ }
202
+ }
203
+ end
204
+
205
+ def format_content(content)
206
+ return content if content.is_a?(String)
207
+ return content if content.is_a?(Array)
208
+
209
+ content.to_s
210
+ end
211
+
212
+ def format_assistant_content(message)
213
+ blocks = []
214
+
215
+ blocks << { type: "text", text: message.content } if message.content && !message.content.empty?
216
+
217
+ message.tool_calls&.each do |tc|
218
+ blocks << {
219
+ type: "tool_use",
220
+ id: tc.id,
221
+ name: tc.name,
222
+ input: tc.arguments
223
+ }
224
+ end
225
+
226
+ blocks.empty? ? "" : blocks
227
+ end
228
+
229
+ def format_tools(tools)
230
+ return nil if tools.nil? || tools.empty?
231
+
232
+ tools.map do |tool|
233
+ if tool[:function]
234
+ # OpenAI-style format
235
+ {
236
+ name: tool[:function][:name],
237
+ description: tool[:function][:description],
238
+ input_schema: tool[:function][:parameters] || { type: "object", properties: {} }
239
+ }
240
+ else
241
+ # Already in Anthropic format
242
+ tool
243
+ end
244
+ end
245
+ end
246
+
247
+ def handle_response(response)
248
+ unless response.success?
249
+ error_body = begin
250
+ JSON.parse(response.body)
251
+ rescue StandardError
252
+ { "error" => response.body }
253
+ end
254
+ raise ProviderError, "Anthropic API error: #{error_body["error"]}"
255
+ end
256
+
257
+ data = JSON.parse(response.body)
258
+ parse_response(data)
259
+ end
260
+
261
+ def parse_response(data)
262
+ content = ""
263
+ thinking = nil
264
+ tool_calls = []
265
+
266
+ data["content"]&.each do |block|
267
+ case block["type"]
268
+ when "text"
269
+ content += block["text"]
270
+ when "thinking"
271
+ thinking = block["thinking"]
272
+ when "tool_use"
273
+ tool_calls << ToolCall.new(
274
+ id: block["id"],
275
+ name: block["name"],
276
+ arguments: block["input"]
277
+ )
278
+ end
279
+ end
280
+
281
+ usage_data = data["usage"] || {}
282
+ usage = Usage.new(
283
+ input_tokens: usage_data["input_tokens"] || 0,
284
+ output_tokens: usage_data["output_tokens"] || 0,
285
+ cache_read: usage_data["cache_read_input_tokens"],
286
+ cache_write: usage_data["cache_creation_input_tokens"]
287
+ )
288
+
289
+ stop_reason = case data["stop_reason"]
290
+ when "end_turn" then :end_turn
291
+ when "tool_use" then :tool_use
292
+ when "max_tokens" then :max_tokens
293
+ when "stop_sequence" then :stop_sequence
294
+ else :end_turn
295
+ end
296
+
297
+ LLMResponse.new(
298
+ content: content.empty? ? nil : content,
299
+ tool_calls: tool_calls,
300
+ usage: usage,
301
+ stop_reason: stop_reason,
302
+ model: data["model"],
303
+ thinking: thinking
304
+ )
305
+ end
306
+
307
+ def process_stream_chunk(chunk, accumulated_content, _accumulated_tool_calls, &block)
308
+ chunk.split("\n").each do |line|
309
+ next unless line.start_with?("data: ")
310
+
311
+ data = begin
312
+ JSON.parse(line[6..])
313
+ rescue StandardError
314
+ next
315
+ end
316
+
317
+ case data["type"]
318
+ when "content_block_delta"
319
+ if data.dig("delta", "type") == "text_delta"
320
+ text = data.dig("delta", "text")
321
+ accumulated_content << text if text
322
+ block&.call(text)
323
+ end
324
+ when "content_block_start"
325
+ if data.dig("content_block", "type") == "tool_use"
326
+ # Tool use block starting
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Providers
5
+ # Base class for LLM providers
6
+ class Base
7
+ attr_reader :config
8
+
9
+ def initialize(config = {})
10
+ @config = config
11
+ validate_config!
12
+ end
13
+
14
+ # Send a chat completion request
15
+ # @param messages [Array<Message>] Conversation history
16
+ # @param tools [Array<Hash>|nil] Tool definitions
17
+ # @param model [String|nil] Model to use (defaults to provider default)
18
+ # @param temperature [Float] Sampling temperature
19
+ # @param max_tokens [Integer] Maximum tokens to generate
20
+ # @param thinking [Boolean] Enable extended thinking (Claude only)
21
+ # @return [LLMResponse]
22
+ def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
23
+ raise NotImplementedError, "#{self.class}#chat must be implemented"
24
+ end
25
+
26
+ # Stream a chat completion request
27
+ # @yield [String|ToolCall] Chunks of content or tool calls
28
+ # @return [LLMResponse]
29
+ def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
30
+ raise NotImplementedError, "#{self.class}#chat_stream must be implemented"
31
+ end
32
+
33
+ # Get the default model for this provider
34
+ # @return [String]
35
+ def default_model
36
+ raise NotImplementedError, "#{self.class}#default_model must be implemented"
37
+ end
38
+
39
+ # List available models
40
+ # @return [Array<String>]
41
+ def available_models
42
+ raise NotImplementedError, "#{self.class}#available_models must be implemented"
43
+ end
44
+
45
+ # Provider name
46
+ # @return [Symbol]
47
+ def name
48
+ raise NotImplementedError, "#{self.class}#name must be implemented"
49
+ end
50
+
51
+ # Check if provider supports a feature
52
+ # @param feature [Symbol] :tools, :streaming, :thinking, :vision
53
+ # @return [Boolean]
54
+ def supports?(feature)
55
+ supported_features.include?(feature)
56
+ end
57
+
58
+ protected
59
+
60
+ def supported_features
61
+ %i[tools streaming]
62
+ end
63
+
64
+ def validate_config!
65
+ # Override in subclasses to validate required config
66
+ end
67
+
68
+ def require_api_key!(key_name)
69
+ return if @config[key_name] || ENV[key_name.to_s.upcase]
70
+
71
+ raise ConfigurationError, "#{key_name} is required for #{self.class.name}"
72
+ end
73
+
74
+ def api_key(key_name)
75
+ @config[key_name] || ENV.fetch(key_name.to_s.upcase, nil)
76
+ end
77
+
78
+ # Convert internal message format to provider-specific format
79
+ def format_messages(messages)
80
+ messages.map { |msg| format_message(msg) }
81
+ end
82
+
83
+ def format_message(message)
84
+ raise NotImplementedError
85
+ end
86
+
87
+ # Convert provider response to internal format
88
+ def parse_response(response)
89
+ raise NotImplementedError
90
+ end
91
+
92
+ # Convert tool definitions to provider-specific format
93
+ def format_tools(tools)
94
+ tools
95
+ end
96
+ end
97
+ end
98
+ end