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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # Registry for managing available tools
6
+ class Registry
7
+ attr_reader :context
8
+
9
+ def initialize(context = {})
10
+ @tools = {}
11
+ @context = context
12
+ end
13
+
14
+ # Register a tool instance
15
+ # @param tool [Base] Tool instance
16
+ def register(tool)
17
+ raise ArgumentError, "Tool must inherit from Tools::Base" unless tool.is_a?(Base)
18
+
19
+ @tools[tool.name] = tool
20
+ Pocketrb.logger.debug("Registered tool: #{tool.name}")
21
+ end
22
+
23
+ # Register a tool class (will be instantiated with context)
24
+ # @param klass [Class] Tool class
25
+ def register_class(klass)
26
+ tool = klass.new(@context)
27
+ register(tool)
28
+ end
29
+
30
+ # Unregister a tool
31
+ # @param name [String] Tool name
32
+ def unregister(name)
33
+ @tools.delete(name)
34
+ end
35
+
36
+ # Get a tool by name
37
+ # @param name [String] Tool name
38
+ # @return [Base|nil]
39
+ def get(name)
40
+ @tools[name]
41
+ end
42
+
43
+ # Check if a tool exists
44
+ # @param name [String] Tool name
45
+ # @return [Boolean]
46
+ def exists?(name)
47
+ @tools.key?(name)
48
+ end
49
+
50
+ # Get all tool names
51
+ # @return [Array<String>]
52
+ def names
53
+ @tools.keys
54
+ end
55
+
56
+ # Get all available tool definitions (for LLM)
57
+ # @param filter_unavailable [Boolean] Exclude unavailable tools
58
+ # @return [Array<Hash>]
59
+ def definitions(filter_unavailable: true)
60
+ tools = filter_unavailable ? available_tools : @tools.values
61
+ tools.map(&:to_definition)
62
+ end
63
+
64
+ # Get Anthropic-format definitions
65
+ # @return [Array<Hash>]
66
+ def anthropic_definitions(filter_unavailable: true)
67
+ tools = filter_unavailable ? available_tools : @tools.values
68
+ tools.map(&:to_anthropic_definition)
69
+ end
70
+
71
+ # Execute a tool
72
+ # @param name [String] Tool name
73
+ # @param arguments [Hash] Tool arguments
74
+ # @return [String] Result
75
+ def execute(name, arguments)
76
+ tool = get(name)
77
+ raise ToolError, "Unknown tool: #{name}" unless tool
78
+ raise ToolError, "Tool #{name} is not available" unless tool.available?
79
+
80
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+
82
+ begin
83
+ # Convert string keys to symbols and filter to known parameters
84
+ args = arguments.transform_keys(&:to_sym)
85
+ args = filter_arguments(tool, args)
86
+ result = tool.execute(**args)
87
+
88
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).to_i
89
+ Pocketrb.logger.debug("Tool #{name} executed in #{duration}ms")
90
+
91
+ result
92
+ rescue StandardError => e
93
+ Pocketrb.logger.error("Tool #{name} failed: #{e.message}")
94
+ raise ToolError, "Tool execution failed: #{e.message}"
95
+ end
96
+ end
97
+
98
+ # Get only available tools
99
+ # @return [Array<Base>]
100
+ def available_tools
101
+ @tools.values.select(&:available?)
102
+ end
103
+
104
+ # Number of registered tools
105
+ # @return [Integer]
106
+ def size
107
+ @tools.size
108
+ end
109
+
110
+ # Clear all tools
111
+ def clear!
112
+ @tools.clear
113
+ end
114
+
115
+ # Update context for all tools
116
+ def update_context(new_context)
117
+ @context = @context.merge(new_context)
118
+ @tools.each_value { |tool| tool.instance_variable_set(:@context, @context) }
119
+ end
120
+
121
+ # Register default core tools
122
+ def register_defaults!
123
+ [
124
+ ReadFile,
125
+ WriteFile,
126
+ EditFile,
127
+ ListDir,
128
+ Exec,
129
+ Jobs,
130
+ Cron,
131
+ WebSearch,
132
+ WebFetch,
133
+ Think,
134
+ Message,
135
+ SendFile,
136
+ BrowserAdvanced,
137
+ Memory
138
+ ].each { |klass| register_class(klass) }
139
+ end
140
+
141
+ private
142
+
143
+ # Filter arguments to only include those defined in tool's parameter schema
144
+ # This prevents LLM from passing unexpected arguments like 'description'
145
+ def filter_arguments(tool, args)
146
+ schema = tool.parameters
147
+ return args unless schema.is_a?(Hash) && schema[:properties]
148
+
149
+ allowed_keys = schema[:properties].keys.map(&:to_sym)
150
+ filtered = args.slice(*allowed_keys)
151
+
152
+ # Log filtered out keys for debugging
153
+ removed = args.keys - filtered.keys
154
+ Pocketrb.logger.debug("Filtered out unknown arguments for #{tool.name}: #{removed.join(", ")}") if removed.any?
155
+
156
+ filtered
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # Tool for sending files/images to chat channels
6
+ class SendFile < Base
7
+ ALLOWED_EXTENSIONS = %w[
8
+ .jpg .jpeg .png .gif .webp .bmp
9
+ .pdf .txt .md .json .csv .xml
10
+ .mp3 .ogg .wav .m4a
11
+ .mp4 .webm .mov
12
+ .zip .tar .gz
13
+ ].freeze
14
+
15
+ def name
16
+ "send_file"
17
+ end
18
+
19
+ def description
20
+ "Send a file (image, document, audio, video) to the user via chat. Use this to share generated content, screenshots, reports, or any files."
21
+ end
22
+
23
+ def parameters
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ path: {
28
+ type: "string",
29
+ description: "Path to the file to send"
30
+ },
31
+ caption: {
32
+ type: "string",
33
+ description: "Optional caption/message to send with the file"
34
+ },
35
+ channel: {
36
+ type: "string",
37
+ description: "Target channel (telegram, whatsapp, cli). Uses default if not specified."
38
+ },
39
+ chat_id: {
40
+ type: "string",
41
+ description: "Recipient chat ID. Uses default if not specified."
42
+ }
43
+ },
44
+ required: ["path"]
45
+ }
46
+ end
47
+
48
+ def execute(path:, caption: nil, channel: nil, chat_id: nil)
49
+ # Resolve path
50
+ file_path = resolve_path(path)
51
+ return error("File not found: #{path}") unless File.exist?(file_path)
52
+ return error("Not a file: #{path}") unless File.file?(file_path)
53
+
54
+ # Check file size (Telegram limit is 50MB for bots)
55
+ size = File.size(file_path)
56
+ return error("File too large (#{size / 1_000_000}MB). Max 50MB.") if size > 50_000_000
57
+
58
+ # Check extension
59
+ ext = File.extname(file_path).downcase
60
+ return error("File type not allowed: #{ext}") unless ALLOWED_EXTENSIONS.include?(ext)
61
+
62
+ # Use defaults from context
63
+ channel = (channel || @context[:default_channel])&.to_sym
64
+ chat_id ||= @context[:default_chat_id]
65
+
66
+ return error("No channel specified and no default channel") unless channel
67
+ return error("No chat_id specified and no default chat_id") unless chat_id
68
+ return error("Message bus not available") unless bus
69
+
70
+ # Create media object
71
+ media = create_media(file_path)
72
+
73
+ # Send message with media
74
+ outbound = Bus::OutboundMessage.new(
75
+ channel: channel,
76
+ chat_id: chat_id,
77
+ content: caption || "",
78
+ media: [media]
79
+ )
80
+
81
+ bus.publish_outbound(outbound)
82
+ Pocketrb.logger.info("File sent to #{channel}:#{chat_id}: #{file_path}")
83
+
84
+ success("Sent #{File.basename(file_path)} to #{channel}")
85
+ end
86
+
87
+ def available?
88
+ !bus.nil?
89
+ end
90
+
91
+ private
92
+
93
+ def bus
94
+ @context[:bus]
95
+ end
96
+
97
+ def resolve_path(path)
98
+ return path if Pathname.new(path).absolute?
99
+
100
+ workspace.join(path).to_s
101
+ end
102
+
103
+ def create_media(path)
104
+ ext = File.extname(path).downcase
105
+ mime_type = detect_mime_type(ext)
106
+ type = detect_type(ext)
107
+
108
+ Bus::Media.new(
109
+ type: type,
110
+ path: path,
111
+ mime_type: mime_type,
112
+ filename: File.basename(path),
113
+ data: nil # Will be read from path when sending
114
+ )
115
+ end
116
+
117
+ def detect_type(ext)
118
+ case ext
119
+ when ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"
120
+ :image
121
+ when ".mp3", ".ogg", ".wav", ".m4a"
122
+ :audio
123
+ when ".mp4", ".webm", ".mov"
124
+ :video
125
+ else
126
+ :file
127
+ end
128
+ end
129
+
130
+ def detect_mime_type(ext)
131
+ {
132
+ ".jpg" => "image/jpeg",
133
+ ".jpeg" => "image/jpeg",
134
+ ".png" => "image/png",
135
+ ".gif" => "image/gif",
136
+ ".webp" => "image/webp",
137
+ ".bmp" => "image/bmp",
138
+ ".pdf" => "application/pdf",
139
+ ".txt" => "text/plain",
140
+ ".md" => "text/markdown",
141
+ ".json" => "application/json",
142
+ ".csv" => "text/csv",
143
+ ".xml" => "application/xml",
144
+ ".mp3" => "audio/mpeg",
145
+ ".ogg" => "audio/ogg",
146
+ ".wav" => "audio/wav",
147
+ ".m4a" => "audio/mp4",
148
+ ".mp4" => "video/mp4",
149
+ ".webm" => "video/webm",
150
+ ".mov" => "video/quicktime",
151
+ ".zip" => "application/zip",
152
+ ".tar" => "application/x-tar",
153
+ ".gz" => "application/gzip"
154
+ }[ext] || "application/octet-stream"
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # Internal reasoning tool for complex problem solving
6
+ class Think < Base
7
+ def name
8
+ "think"
9
+ end
10
+
11
+ def description
12
+ "Use this tool to think through complex problems step by step. The content is not shown to the user but helps you reason through the task. Use it when you need to analyze information, plan an approach, or work through logic."
13
+ end
14
+
15
+ def parameters
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ thought: {
20
+ type: "string",
21
+ description: "Your internal reasoning or analysis"
22
+ }
23
+ },
24
+ required: ["thought"]
25
+ }
26
+ end
27
+
28
+ def execute(thought:)
29
+ # The thought is logged but not displayed to the user
30
+ Pocketrb.logger.debug("Agent thought: #{thought[0..200]}...")
31
+ "Thought recorded."
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/follow_redirects"
5
+ require "uri"
6
+
7
+ module Pocketrb
8
+ module Tools
9
+ # Fetch and extract content from web pages
10
+ class WebFetch < Base
11
+ MAX_CONTENT_SIZE = 500_000 # characters
12
+ TIMEOUT = 30 # seconds
13
+
14
+ def name
15
+ "web_fetch"
16
+ end
17
+
18
+ def description
19
+ "Fetch content from a URL. Returns the text content of the page. Use for reading documentation, articles, or other web content."
20
+ end
21
+
22
+ def parameters
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ url: {
27
+ type: "string",
28
+ description: "The URL to fetch"
29
+ },
30
+ selector: {
31
+ type: "string",
32
+ description: "CSS selector to extract specific content (optional)"
33
+ }
34
+ },
35
+ required: ["url"]
36
+ }
37
+ end
38
+
39
+ def execute(url:, selector: nil)
40
+ # Validate URL
41
+ uri = parse_url(url)
42
+ return error("Invalid URL: #{url}") unless uri
43
+
44
+ response = fetch_url(uri)
45
+ return error("Failed to fetch URL: #{response[:error]}") if response[:error]
46
+
47
+ content = extract_content(response[:body], selector)
48
+ truncate_content(content)
49
+ rescue Faraday::Error => e
50
+ error("Request failed: #{e.message}")
51
+ rescue StandardError => e
52
+ error("Error fetching URL: #{e.message}")
53
+ end
54
+
55
+ private
56
+
57
+ def parse_url(url)
58
+ # Add https if no scheme
59
+ url = "https://#{url}" unless url.match?(%r{^https?://})
60
+
61
+ uri = URI.parse(url)
62
+ return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
63
+
64
+ uri
65
+ rescue URI::InvalidURIError
66
+ nil
67
+ end
68
+
69
+ def fetch_url(uri)
70
+ conn = Faraday.new(url: uri.to_s) do |f|
71
+ f.options.timeout = TIMEOUT
72
+ f.options.open_timeout = 10
73
+ f.headers["User-Agent"] = "Pocketrb/#{Pocketrb::VERSION} (Ruby AI Agent)"
74
+ f.headers["Accept"] = "text/html,text/plain,application/json"
75
+ f.response :follow_redirects, limit: 5
76
+ f.adapter Faraday.default_adapter
77
+ end
78
+
79
+ response = conn.get
80
+
81
+ if response.success?
82
+ { body: response.body, content_type: response.headers["content-type"] }
83
+ else
84
+ { error: "HTTP #{response.status}" }
85
+ end
86
+ end
87
+
88
+ def extract_content(body, selector)
89
+ content_type = detect_content_type(body)
90
+
91
+ case content_type
92
+ when :html
93
+ extract_html_content(body, selector)
94
+ when :json
95
+ format_json(body)
96
+ else
97
+ body
98
+ end
99
+ end
100
+
101
+ def detect_content_type(body)
102
+ return :json if body.strip.start_with?("{", "[")
103
+ return :html if body.include?("<html") || body.include?("<body")
104
+
105
+ :text
106
+ end
107
+
108
+ def extract_html_content(html, _selector)
109
+ # Simple HTML to text conversion
110
+ # A full implementation would use nokogiri
111
+ text = html
112
+ .gsub(%r{<script[^>]*>.*?</script>}mi, "")
113
+ .gsub(%r{<style[^>]*>.*?</style>}mi, "")
114
+ .gsub(%r{<head[^>]*>.*?</head>}mi, "")
115
+ .gsub(%r{<nav[^>]*>.*?</nav>}mi, "")
116
+ .gsub(%r{<footer[^>]*>.*?</footer>}mi, "")
117
+ .gsub(/<[^>]+>/, "\n")
118
+ .gsub("&nbsp;", " ")
119
+ .gsub("&amp;", "&")
120
+ .gsub("&lt;", "<")
121
+ .gsub("&gt;", ">")
122
+ .gsub("&quot;", '"')
123
+ .gsub(/&#\d+;/) do |m|
124
+ [m[2..].to_i].pack("U")
125
+ rescue StandardError
126
+ m
127
+ end
128
+ .gsub(/\n{3,}/, "\n\n")
129
+ .strip
130
+
131
+ # Clean up whitespace
132
+ text.lines.map(&:strip).reject(&:empty?).join("\n")
133
+ end
134
+
135
+ def format_json(body)
136
+ data = JSON.parse(body)
137
+ JSON.pretty_generate(data)
138
+ rescue JSON::ParserError
139
+ body
140
+ end
141
+
142
+ def truncate_content(content)
143
+ return content if content.length <= MAX_CONTENT_SIZE
144
+
145
+ truncated = content[0...MAX_CONTENT_SIZE]
146
+ "#{truncated}\n\n... [Content truncated at #{MAX_CONTENT_SIZE} characters]"
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Pocketrb
7
+ module Tools
8
+ # Search the web using Brave Search API
9
+ class WebSearch < Base
10
+ BRAVE_API_URL = "https://api.search.brave.com/res/v1/web/search"
11
+
12
+ def name
13
+ "web_search"
14
+ end
15
+
16
+ def description
17
+ "Search the web for information. Returns relevant search results with titles, URLs, and descriptions."
18
+ end
19
+
20
+ def parameters
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ query: {
25
+ type: "string",
26
+ description: "The search query"
27
+ },
28
+ count: {
29
+ type: "integer",
30
+ description: "Number of results to return (default: 5, max: 20)"
31
+ }
32
+ },
33
+ required: ["query"]
34
+ }
35
+ end
36
+
37
+ def available?
38
+ api_key && !api_key.empty?
39
+ end
40
+
41
+ def execute(query:, count: 5)
42
+ return error("Web search requires BRAVE_API_KEY environment variable") unless api_key
43
+
44
+ count = [[count, 1].max, 20].min
45
+
46
+ response = client.get do |req|
47
+ req.params["q"] = query
48
+ req.params["count"] = count
49
+ end
50
+
51
+ return error("Search failed: #{response.status}") unless response.success?
52
+
53
+ data = JSON.parse(response.body)
54
+ format_results(data, query)
55
+ rescue Faraday::Error => e
56
+ error("Search request failed: #{e.message}")
57
+ rescue JSON::ParserError
58
+ error("Failed to parse search results")
59
+ end
60
+
61
+ private
62
+
63
+ def api_key
64
+ @context[:brave_api_key] || ENV.fetch("BRAVE_API_KEY", nil)
65
+ end
66
+
67
+ def client
68
+ @client ||= Faraday.new(url: BRAVE_API_URL) do |f|
69
+ f.headers["Accept"] = "application/json"
70
+ f.headers["X-Subscription-Token"] = api_key
71
+ f.adapter Faraday.default_adapter
72
+ end
73
+ end
74
+
75
+ def format_results(data, query)
76
+ results = data.dig("web", "results") || []
77
+
78
+ return "No results found for: #{query}" if results.empty?
79
+
80
+ output = ["Search results for: #{query}\n"]
81
+
82
+ results.each_with_index do |result, idx|
83
+ output << format_result(result, idx + 1)
84
+ end
85
+
86
+ output.join("\n")
87
+ end
88
+
89
+ def format_result(result, index)
90
+ title = result["title"] || "Untitled"
91
+ url = result["url"] || ""
92
+ description = result["description"] || ""
93
+
94
+ <<~RESULT
95
+ #{index}. #{title}
96
+ URL: #{url}
97
+ #{description[0..300]}
98
+ RESULT
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # Write content to a file
6
+ class WriteFile < Base
7
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
8
+
9
+ def name
10
+ "write_file"
11
+ end
12
+
13
+ def description
14
+ "Write content to a file. Creates the file if it doesn't exist, or overwrites if it does. Creates parent directories as needed."
15
+ end
16
+
17
+ def parameters
18
+ {
19
+ type: "object",
20
+ properties: {
21
+ path: {
22
+ type: "string",
23
+ description: "Path to the file to write (relative to workspace or absolute)"
24
+ },
25
+ content: {
26
+ type: "string",
27
+ description: "Content to write to the file"
28
+ }
29
+ },
30
+ required: %w[path content]
31
+ }
32
+ end
33
+
34
+ def execute(path:, content:)
35
+ resolved = resolve_path(path)
36
+
37
+ return error("Path is outside workspace: #{path}") unless path_allowed?(path)
38
+
39
+ return error("Content exceeds maximum file size (10MB)") if content.bytesize > MAX_FILE_SIZE
40
+
41
+ # Create parent directories
42
+ FileUtils.mkdir_p(resolved.dirname)
43
+
44
+ # Write file
45
+ File.write(resolved, content)
46
+
47
+ success("Wrote #{content.lines.count} lines (#{content.bytesize} bytes) to #{path}")
48
+ rescue Errno::EACCES
49
+ error("Permission denied: #{path}")
50
+ rescue Errno::ENOSPC
51
+ error("No space left on device")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ VERSION = "0.1.0"
5
+ end