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,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
|