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,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Pocketrb
|
|
8
|
+
module MCP
|
|
9
|
+
# MCP client for connecting to MCP HTTP Bridge
|
|
10
|
+
# Implements JSON-RPC 2.0 protocol for MCP communication
|
|
11
|
+
class Client
|
|
12
|
+
DEFAULT_ENDPOINT = "http://localhost:7878"
|
|
13
|
+
TIMEOUT = 30
|
|
14
|
+
|
|
15
|
+
attr_reader :endpoint, :connected
|
|
16
|
+
|
|
17
|
+
def initialize(endpoint: nil)
|
|
18
|
+
@endpoint = endpoint || ENV["MCP_ENDPOINT"] || DEFAULT_ENDPOINT
|
|
19
|
+
@connected = false
|
|
20
|
+
@request_id = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Initialize the MCP connection
|
|
24
|
+
def connect
|
|
25
|
+
response = rpc_call("initialize", {
|
|
26
|
+
protocolVersion: "2024-11-05",
|
|
27
|
+
capabilities: {
|
|
28
|
+
tools: {}
|
|
29
|
+
},
|
|
30
|
+
clientInfo: {
|
|
31
|
+
name: "pocketrb",
|
|
32
|
+
version: Pocketrb::VERSION
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
@connected = !response["result"].nil?
|
|
37
|
+
@server_info = response.dig("result", "serverInfo")
|
|
38
|
+
@capabilities = response.dig("result", "capabilities")
|
|
39
|
+
|
|
40
|
+
Pocketrb.logger.info("MCP connected to #{@server_info&.dig("name") || "server"}")
|
|
41
|
+
@connected
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Pocketrb.logger.warn("MCP connection failed: #{e.message}")
|
|
44
|
+
@connected = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# List available tools from MCP server
|
|
48
|
+
def list_tools
|
|
49
|
+
ensure_connected!
|
|
50
|
+
|
|
51
|
+
response = rpc_call("tools/list", {})
|
|
52
|
+
response.dig("result", "tools") || []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Call a tool on the MCP server
|
|
56
|
+
def call_tool(name:, arguments: {})
|
|
57
|
+
ensure_connected!
|
|
58
|
+
|
|
59
|
+
response = rpc_call("tools/call", {
|
|
60
|
+
name: name,
|
|
61
|
+
arguments: arguments
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
raise MCPError, "Tool call failed: #{response["error"]["message"]}" if response["error"]
|
|
65
|
+
|
|
66
|
+
response.dig("result", "content")&.first&.dig("text")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Search memory via MCP
|
|
70
|
+
def search(query:, limit: 10)
|
|
71
|
+
# Try MCP tool first
|
|
72
|
+
if tool_available?("memory_search")
|
|
73
|
+
call_tool(name: "memory_search", arguments: { query: query, limit: limit })
|
|
74
|
+
else
|
|
75
|
+
# Fall back to direct HTTP endpoint
|
|
76
|
+
http_search(query, limit)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Store to memory via MCP
|
|
81
|
+
def store(content:, metadata: {})
|
|
82
|
+
# Try MCP tool first
|
|
83
|
+
if tool_available?("memory_store")
|
|
84
|
+
call_tool(name: "memory_store", arguments: { content: content, metadata: metadata })
|
|
85
|
+
else
|
|
86
|
+
# Fall back to direct HTTP endpoint
|
|
87
|
+
http_store(content, metadata)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if connected
|
|
92
|
+
def connected?
|
|
93
|
+
@connected
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Disconnect from server
|
|
97
|
+
def disconnect
|
|
98
|
+
@connected = false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def ensure_connected!
|
|
104
|
+
return if @connected
|
|
105
|
+
|
|
106
|
+
connect
|
|
107
|
+
raise MCPError, "Not connected to MCP server" unless @connected
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def rpc_call(method, params)
|
|
111
|
+
@request_id += 1
|
|
112
|
+
|
|
113
|
+
request_body = {
|
|
114
|
+
jsonrpc: "2.0",
|
|
115
|
+
id: @request_id,
|
|
116
|
+
method: method,
|
|
117
|
+
params: params
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
response = client.post("/rpc") do |req|
|
|
121
|
+
req.body = request_body.to_json
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
raise MCPError, "RPC call failed: HTTP #{response.status}" unless response.success?
|
|
125
|
+
|
|
126
|
+
JSON.parse(response.body)
|
|
127
|
+
rescue Faraday::Error => e
|
|
128
|
+
raise MCPError, "RPC connection error: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def client
|
|
132
|
+
@client ||= Faraday.new(url: @endpoint) do |f|
|
|
133
|
+
f.headers["Content-Type"] = "application/json"
|
|
134
|
+
f.options.timeout = TIMEOUT
|
|
135
|
+
f.adapter Faraday.default_adapter
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def tool_available?(name)
|
|
140
|
+
@tools ||= list_tools
|
|
141
|
+
@tools.any? { |t| t["name"] == name }
|
|
142
|
+
rescue MCPError
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Direct HTTP endpoints (fallback when not using MCP tools)
|
|
147
|
+
def http_search(query, limit)
|
|
148
|
+
response = client.post("/search") do |req|
|
|
149
|
+
req.body = { query: query, limit: limit }.to_json
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
return nil unless response.success?
|
|
153
|
+
|
|
154
|
+
JSON.parse(response.body)
|
|
155
|
+
rescue StandardError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def http_store(content, metadata)
|
|
160
|
+
response = client.post("/store") do |req|
|
|
161
|
+
req.body = { content: content, metadata: metadata }.to_json
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
return false unless response.success?
|
|
165
|
+
|
|
166
|
+
true
|
|
167
|
+
rescue StandardError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module MCP
|
|
5
|
+
# Tool for interacting with memory via MCP
|
|
6
|
+
class MemoryTool < Tools::Base
|
|
7
|
+
def initialize(context = {})
|
|
8
|
+
super
|
|
9
|
+
@client = context[:mcp_client] || Client.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def name
|
|
13
|
+
"memory"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def description
|
|
17
|
+
"Interact with long-term memory. Search for relevant information or store new knowledge for future reference."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parameters
|
|
21
|
+
{
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
action: {
|
|
25
|
+
type: "string",
|
|
26
|
+
enum: %w[search store],
|
|
27
|
+
description: "Action to perform: 'search' to find information, 'store' to save new knowledge"
|
|
28
|
+
},
|
|
29
|
+
query: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "For search: the query to search for"
|
|
32
|
+
},
|
|
33
|
+
content: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "For store: the content to save to memory"
|
|
36
|
+
},
|
|
37
|
+
tags: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: { type: "string" },
|
|
40
|
+
description: "For store: tags to associate with the content"
|
|
41
|
+
},
|
|
42
|
+
limit: {
|
|
43
|
+
type: "integer",
|
|
44
|
+
description: "For search: maximum number of results (default: 5)"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
required: ["action"]
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def available?
|
|
52
|
+
@client.connected? || @client.connect
|
|
53
|
+
rescue StandardError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execute(action:, query: nil, content: nil, tags: nil, limit: 5)
|
|
58
|
+
case action
|
|
59
|
+
when "search"
|
|
60
|
+
execute_search(query, limit)
|
|
61
|
+
when "store"
|
|
62
|
+
execute_store(content, tags)
|
|
63
|
+
else
|
|
64
|
+
error("Unknown action: #{action}. Use 'search' or 'store'.")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def execute_search(query, limit)
|
|
71
|
+
return error("Query is required for search") if query.nil? || query.empty?
|
|
72
|
+
|
|
73
|
+
results = @client.search(query: query, limit: limit)
|
|
74
|
+
|
|
75
|
+
return "No relevant memories found for: #{query}" if results.nil? || results.empty?
|
|
76
|
+
|
|
77
|
+
format_search_results(results, query)
|
|
78
|
+
rescue MCPError => e
|
|
79
|
+
error("Memory search failed: #{e.message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def execute_store(content, tags)
|
|
83
|
+
return error("Content is required for store") if content.nil? || content.empty?
|
|
84
|
+
|
|
85
|
+
metadata = {}
|
|
86
|
+
metadata[:tags] = tags if tags && !tags.empty?
|
|
87
|
+
metadata[:timestamp] = Time.now.iso8601
|
|
88
|
+
metadata[:source] = "pocketrb"
|
|
89
|
+
|
|
90
|
+
result = @client.store(content: content, metadata: metadata)
|
|
91
|
+
|
|
92
|
+
if result
|
|
93
|
+
success("Stored to memory: #{content[0..100]}#{"..." if content.length > 100}")
|
|
94
|
+
else
|
|
95
|
+
error("Failed to store to memory")
|
|
96
|
+
end
|
|
97
|
+
rescue MCPError => e
|
|
98
|
+
error("Memory store failed: #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def format_search_results(results, query)
|
|
102
|
+
output = ["Memory search results for: #{query}\n"]
|
|
103
|
+
|
|
104
|
+
if results.is_a?(String)
|
|
105
|
+
# Direct text response from MCP
|
|
106
|
+
output << results
|
|
107
|
+
elsif results.is_a?(Array)
|
|
108
|
+
results.each_with_index do |result, idx|
|
|
109
|
+
output << format_result(result, idx + 1)
|
|
110
|
+
end
|
|
111
|
+
elsif results.is_a?(Hash) && results["results"]
|
|
112
|
+
results["results"].each_with_index do |result, idx|
|
|
113
|
+
output << format_result(result, idx + 1)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
output.join("\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def format_result(result, index)
|
|
121
|
+
content = result["content"] || result["text"] || result.to_s
|
|
122
|
+
score = result["score"]
|
|
123
|
+
tags = result["tags"] || result.dig("metadata", "tags")
|
|
124
|
+
|
|
125
|
+
parts = ["#{index}. #{content[0..500]}"]
|
|
126
|
+
parts << " Score: #{score.round(3)}" if score
|
|
127
|
+
parts << " Tags: #{tags.join(", ")}" if tags && !tags.empty?
|
|
128
|
+
|
|
129
|
+
parts.join("\n")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Pocketrb
|
|
8
|
+
module Media
|
|
9
|
+
# Handles media processing: downloading, encoding, type detection
|
|
10
|
+
class Processor
|
|
11
|
+
# Supported image MIME types for vision models
|
|
12
|
+
VISION_IMAGE_TYPES = %w[
|
|
13
|
+
image/jpeg
|
|
14
|
+
image/png
|
|
15
|
+
image/gif
|
|
16
|
+
image/webp
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# Audio types that can be transcribed
|
|
20
|
+
AUDIO_TYPES = %w[
|
|
21
|
+
audio/ogg
|
|
22
|
+
audio/mpeg
|
|
23
|
+
audio/mp3
|
|
24
|
+
audio/wav
|
|
25
|
+
audio/webm
|
|
26
|
+
audio/m4a
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Max file size for inline encoding (5MB)
|
|
30
|
+
MAX_INLINE_SIZE = 5 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
attr_reader :cache_dir
|
|
33
|
+
|
|
34
|
+
def initialize(cache_dir: nil)
|
|
35
|
+
@cache_dir = cache_dir || default_cache_dir
|
|
36
|
+
ensure_cache_dir!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Download media from URL and return Media object
|
|
40
|
+
# @param url [String] URL to download
|
|
41
|
+
# @param filename [String, nil] Original filename
|
|
42
|
+
# @param mime_type [String, nil] Known MIME type
|
|
43
|
+
# @return [Bus::Media]
|
|
44
|
+
def download(url, filename: nil, mime_type: nil)
|
|
45
|
+
require "faraday"
|
|
46
|
+
|
|
47
|
+
response = Faraday.get(url)
|
|
48
|
+
raise MediaError, "Failed to download: HTTP #{response.status}" unless response.success?
|
|
49
|
+
|
|
50
|
+
content_type = mime_type || response.headers["content-type"]&.split(";")&.first || "application/octet-stream"
|
|
51
|
+
filename ||= extract_filename(url, content_type)
|
|
52
|
+
|
|
53
|
+
# Save to cache
|
|
54
|
+
cache_path = cache_file_path(filename)
|
|
55
|
+
File.binwrite(cache_path, response.body)
|
|
56
|
+
|
|
57
|
+
type = detect_type(content_type)
|
|
58
|
+
data = encode_if_small(response.body, content_type)
|
|
59
|
+
|
|
60
|
+
Bus::Media.new(
|
|
61
|
+
type: type,
|
|
62
|
+
path: cache_path,
|
|
63
|
+
mime_type: content_type,
|
|
64
|
+
filename: filename,
|
|
65
|
+
data: data
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Process a local file into a Media object
|
|
70
|
+
# @param path [String] File path
|
|
71
|
+
# @param mime_type [String, nil] Override MIME type
|
|
72
|
+
# @return [Bus::Media]
|
|
73
|
+
def from_file(path, mime_type: nil)
|
|
74
|
+
raise MediaError, "File not found: #{path}" unless File.exist?(path)
|
|
75
|
+
|
|
76
|
+
content = File.binread(path)
|
|
77
|
+
content_type = mime_type || detect_mime_type(path)
|
|
78
|
+
filename = File.basename(path)
|
|
79
|
+
type = detect_type(content_type)
|
|
80
|
+
data = encode_if_small(content, content_type)
|
|
81
|
+
|
|
82
|
+
Bus::Media.new(
|
|
83
|
+
type: type,
|
|
84
|
+
path: path,
|
|
85
|
+
mime_type: content_type,
|
|
86
|
+
filename: filename,
|
|
87
|
+
data: data
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Encode raw bytes into a Media object
|
|
92
|
+
# @param bytes [String] Raw binary data
|
|
93
|
+
# @param mime_type [String] MIME type
|
|
94
|
+
# @param filename [String, nil] Filename
|
|
95
|
+
# @return [Bus::Media]
|
|
96
|
+
def from_bytes(bytes, mime_type:, filename: nil)
|
|
97
|
+
type = detect_type(mime_type)
|
|
98
|
+
filename ||= "media_#{Time.now.to_i}.#{extension_for(mime_type)}"
|
|
99
|
+
|
|
100
|
+
# Save to cache
|
|
101
|
+
cache_path = cache_file_path(filename)
|
|
102
|
+
File.binwrite(cache_path, bytes)
|
|
103
|
+
|
|
104
|
+
data = encode_if_small(bytes, mime_type)
|
|
105
|
+
|
|
106
|
+
Bus::Media.new(
|
|
107
|
+
type: type,
|
|
108
|
+
path: cache_path,
|
|
109
|
+
mime_type: mime_type,
|
|
110
|
+
filename: filename,
|
|
111
|
+
data: data
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check if media is a vision-compatible image
|
|
116
|
+
# @param media [Bus::Media]
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def vision_compatible?(media)
|
|
119
|
+
VISION_IMAGE_TYPES.include?(media.mime_type)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if media is audio that can be transcribed
|
|
123
|
+
# @param media [Bus::Media]
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def audio?(media)
|
|
126
|
+
AUDIO_TYPES.include?(media.mime_type) || media.type == :audio
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get base64 data for media (loads from file if needed)
|
|
130
|
+
# @param media [Bus::Media]
|
|
131
|
+
# @return [String] Base64 encoded data
|
|
132
|
+
def get_base64(media)
|
|
133
|
+
return media.data if media.data
|
|
134
|
+
|
|
135
|
+
content = File.binread(media.path)
|
|
136
|
+
Base64.strict_encode64(content)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Format media for Anthropic vision API
|
|
140
|
+
# @param media [Bus::Media]
|
|
141
|
+
# @return [Hash] Anthropic image content block
|
|
142
|
+
def format_for_anthropic(media)
|
|
143
|
+
unless vision_compatible?(media)
|
|
144
|
+
return { type: "text", text: "[Attached file: #{media.filename} (#{media.mime_type})]" }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
type: "image",
|
|
149
|
+
source: {
|
|
150
|
+
type: "base64",
|
|
151
|
+
media_type: media.mime_type,
|
|
152
|
+
data: get_base64(media)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Clean up old cached files
|
|
158
|
+
# @param older_than [Integer] Age in seconds
|
|
159
|
+
def cleanup_cache(older_than: 86_400)
|
|
160
|
+
return unless @cache_dir.exist?
|
|
161
|
+
|
|
162
|
+
cutoff = Time.now - older_than
|
|
163
|
+
Dir.glob(@cache_dir.join("*")).each do |file|
|
|
164
|
+
File.delete(file) if File.mtime(file) < cutoff
|
|
165
|
+
rescue StandardError
|
|
166
|
+
# Ignore cleanup errors
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def default_cache_dir
|
|
173
|
+
Pathname.new(Dir.tmpdir).join("pocketrb-media")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def ensure_cache_dir!
|
|
177
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def cache_file_path(filename)
|
|
181
|
+
# Sanitize filename and add timestamp to avoid conflicts
|
|
182
|
+
safe_name = filename.gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
183
|
+
@cache_dir.join("#{Time.now.to_i}_#{safe_name}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def detect_type(mime_type)
|
|
187
|
+
case mime_type
|
|
188
|
+
when %r{^image/}
|
|
189
|
+
:image
|
|
190
|
+
when %r{^audio/}
|
|
191
|
+
:audio
|
|
192
|
+
when %r{^video/}
|
|
193
|
+
:video
|
|
194
|
+
else
|
|
195
|
+
:file
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def detect_mime_type(path)
|
|
200
|
+
ext = File.extname(path).downcase
|
|
201
|
+
MIME_TYPES[ext] || "application/octet-stream"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def encode_if_small(content, mime_type)
|
|
205
|
+
return nil if content.bytesize > MAX_INLINE_SIZE
|
|
206
|
+
return nil unless VISION_IMAGE_TYPES.include?(mime_type)
|
|
207
|
+
|
|
208
|
+
Base64.strict_encode64(content)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def extract_filename(url, content_type)
|
|
212
|
+
# Try to get filename from URL
|
|
213
|
+
uri_path = begin
|
|
214
|
+
URI.parse(url).path
|
|
215
|
+
rescue StandardError
|
|
216
|
+
""
|
|
217
|
+
end
|
|
218
|
+
name = File.basename(uri_path)
|
|
219
|
+
|
|
220
|
+
if name.empty? || !name.include?(".")
|
|
221
|
+
ext = extension_for(content_type)
|
|
222
|
+
name = "download_#{Time.now.to_i}.#{ext}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
name
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def extension_for(mime_type)
|
|
229
|
+
EXTENSIONS[mime_type] || "bin"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
MIME_TYPES = {
|
|
233
|
+
".jpg" => "image/jpeg",
|
|
234
|
+
".jpeg" => "image/jpeg",
|
|
235
|
+
".png" => "image/png",
|
|
236
|
+
".gif" => "image/gif",
|
|
237
|
+
".webp" => "image/webp",
|
|
238
|
+
".mp3" => "audio/mpeg",
|
|
239
|
+
".ogg" => "audio/ogg",
|
|
240
|
+
".wav" => "audio/wav",
|
|
241
|
+
".m4a" => "audio/m4a",
|
|
242
|
+
".mp4" => "video/mp4",
|
|
243
|
+
".webm" => "video/webm",
|
|
244
|
+
".pdf" => "application/pdf",
|
|
245
|
+
".txt" => "text/plain",
|
|
246
|
+
".json" => "application/json"
|
|
247
|
+
}.freeze
|
|
248
|
+
|
|
249
|
+
EXTENSIONS = MIME_TYPES.invert.merge(
|
|
250
|
+
"image/jpeg" => "jpg",
|
|
251
|
+
"audio/mpeg" => "mp3",
|
|
252
|
+
"audio/ogg" => "ogg"
|
|
253
|
+
).freeze
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
class MediaError < Pocketrb::Error; end
|
|
257
|
+
end
|
|
258
|
+
end
|