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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- 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(" ", " ")
|
|
119
|
+
.gsub("&", "&")
|
|
120
|
+
.gsub("<", "<")
|
|
121
|
+
.gsub(">", ">")
|
|
122
|
+
.gsub(""", '"')
|
|
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
|