pocketrb 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +98 -17
- data/lib/pocketrb/agent/compaction.rb +235 -22
- data/lib/pocketrb/agent/context.rb +26 -3
- data/lib/pocketrb/agent/loop.rb +53 -5
- data/lib/pocketrb/agent/spawn_tool.rb +14 -0
- data/lib/pocketrb/agent/subagent_manager.rb +24 -2
- data/lib/pocketrb/bus/events.rb +42 -1
- data/lib/pocketrb/bus/message_bus.rb +37 -0
- data/lib/pocketrb/channels/base.rb +13 -0
- data/lib/pocketrb/channels/cli.rb +16 -1
- data/lib/pocketrb/channels/telegram.rb +70 -4
- data/lib/pocketrb/channels/whatsapp.rb +49 -7
- data/lib/pocketrb/cli/chat.rb +7 -0
- data/lib/pocketrb/cli/config.rb +4 -0
- data/lib/pocketrb/cli/cron.rb +6 -0
- data/lib/pocketrb/cli.rb +0 -1
- data/lib/pocketrb/config.rb +55 -6
- data/lib/pocketrb/cron/job.rb +41 -0
- data/lib/pocketrb/cron/service.rb +44 -15
- data/lib/pocketrb/heartbeat/service.rb +21 -0
- data/lib/pocketrb/mcp/client.rb +41 -3
- data/lib/pocketrb/mcp/memory_tool.rb +33 -0
- data/lib/pocketrb/media/processor.rb +32 -1
- data/lib/pocketrb/memory.rb +12 -3
- data/lib/pocketrb/planning/manager.rb +29 -1
- data/lib/pocketrb/planning/plan.rb +52 -0
- data/lib/pocketrb/planning/tool.rb +45 -0
- data/lib/pocketrb/providers/anthropic.rb +113 -9
- data/lib/pocketrb/providers/base.rb +39 -4
- data/lib/pocketrb/providers/claude_cli.rb +79 -7
- data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
- data/lib/pocketrb/providers/openai_compatible.rb +79 -0
- data/lib/pocketrb/providers/openrouter.rb +72 -12
- data/lib/pocketrb/providers/registry.rb +24 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
- data/lib/pocketrb/providers/types.rb +51 -1
- data/lib/pocketrb/session/manager.rb +34 -2
- data/lib/pocketrb/session/session.rb +53 -11
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
- data/lib/pocketrb/skills/create_tool.rb +28 -0
- data/lib/pocketrb/skills/loader.rb +22 -1
- data/lib/pocketrb/skills/modify_tool.rb +22 -0
- data/lib/pocketrb/skills/skill.rb +19 -3
- data/lib/pocketrb/tools/background_job_manager.rb +29 -0
- data/lib/pocketrb/tools/base.rb +16 -1
- data/lib/pocketrb/tools/browser.rb +40 -0
- data/lib/pocketrb/tools/browser_advanced.rb +94 -4
- data/lib/pocketrb/tools/browser_session.rb +18 -0
- data/lib/pocketrb/tools/cron.rb +46 -0
- data/lib/pocketrb/tools/edit_file.rb +16 -0
- data/lib/pocketrb/tools/exec.rb +46 -0
- data/lib/pocketrb/tools/jobs.rb +26 -2
- data/lib/pocketrb/tools/list_dir.rb +19 -0
- data/lib/pocketrb/tools/memory.rb +34 -0
- data/lib/pocketrb/tools/message.rb +15 -0
- data/lib/pocketrb/tools/para_memory.rb +47 -0
- data/lib/pocketrb/tools/read_file.rb +11 -0
- data/lib/pocketrb/tools/registry.rb +12 -4
- data/lib/pocketrb/tools/send_file.rb +29 -0
- data/lib/pocketrb/tools/think.rb +9 -0
- data/lib/pocketrb/tools/web_fetch.rb +58 -3
- data/lib/pocketrb/tools/web_search.rb +25 -0
- data/lib/pocketrb/tools/write_file.rb +10 -0
- data/lib/pocketrb/version.rb +2 -1
- data/lib/pocketrb.rb +4 -1
- data/pocketrb.gemspec +6 -8
- metadata +24 -44
data/lib/pocketrb/config.rb
CHANGED
|
@@ -5,12 +5,17 @@ require "yaml"
|
|
|
5
5
|
module Pocketrb
|
|
6
6
|
# Configuration management
|
|
7
7
|
class Config
|
|
8
|
+
# Default configuration filename
|
|
8
9
|
CONFIG_FILE = "config.yml"
|
|
10
|
+
# Configuration directory name
|
|
9
11
|
CONFIG_DIR = ".pocketrb"
|
|
10
12
|
|
|
13
|
+
# Default configuration values
|
|
11
14
|
DEFAULTS = {
|
|
12
15
|
provider: "anthropic",
|
|
13
|
-
model: "claude-sonnet-
|
|
16
|
+
model: "claude-sonnet-5-5",
|
|
17
|
+
compaction_model: nil,
|
|
18
|
+
compaction_pressure: nil,
|
|
14
19
|
max_iterations: 50,
|
|
15
20
|
heartbeat_interval: 1800, # 30 minutes (1800s)
|
|
16
21
|
mcp_endpoint: "http://localhost:7878",
|
|
@@ -21,6 +26,8 @@ module Pocketrb
|
|
|
21
26
|
|
|
22
27
|
attr_reader :workspace, :data
|
|
23
28
|
|
|
29
|
+
# Initialize configuration
|
|
30
|
+
# @param workspace [String, Pathname, nil] Workspace directory path for loading workspace-specific config
|
|
24
31
|
def initialize(workspace: nil)
|
|
25
32
|
@workspace = workspace ? Pathname.new(workspace) : nil
|
|
26
33
|
@data = DEFAULTS.dup
|
|
@@ -28,32 +35,46 @@ module Pocketrb
|
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
# Get a config value
|
|
38
|
+
# @param key [String, Symbol] Configuration key to retrieve
|
|
39
|
+
# @return [Object, nil] Configuration value or nil if not found
|
|
31
40
|
def [](key)
|
|
32
41
|
@data[key.to_sym] || @data[key.to_s]
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
# Set a config value
|
|
45
|
+
# @param key [String, Symbol] Configuration key to set
|
|
46
|
+
# @param value [Object] Configuration value (string, number, boolean, hash, etc.)
|
|
47
|
+
# @return [Object] The stored value
|
|
36
48
|
def []=(key, value)
|
|
37
49
|
@data[key.to_sym] = value
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
# Get with default
|
|
53
|
+
# @param key [String, Symbol] Configuration key to retrieve
|
|
54
|
+
# @param default [Object, nil] Default value if key not found
|
|
55
|
+
# @return [Object] Configuration value or default
|
|
41
56
|
def get(key, default = nil)
|
|
42
57
|
self[key] || default
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# Set a value and save
|
|
61
|
+
# @param key [String, Symbol] Configuration key to set
|
|
62
|
+
# @param value [Object] Configuration value to persist to disk
|
|
63
|
+
# @return [void]
|
|
46
64
|
def set(key, value)
|
|
47
65
|
self[key] = value
|
|
48
66
|
save!
|
|
49
67
|
end
|
|
50
68
|
|
|
51
69
|
# Check if key exists
|
|
70
|
+
# @param key [String, Symbol] Configuration key to check
|
|
71
|
+
# @return [Boolean] true if key exists in configuration
|
|
52
72
|
def key?(key)
|
|
53
73
|
@data.key?(key.to_sym) || @data.key?(key.to_s)
|
|
54
74
|
end
|
|
55
75
|
|
|
56
|
-
# Get provider configuration
|
|
76
|
+
# Get provider configuration with API keys and model settings
|
|
77
|
+
# @return [Hash] provider configuration hash
|
|
57
78
|
def provider_config
|
|
58
79
|
# Warn about deprecated API key environment variables
|
|
59
80
|
warn_env_deprecated("ANTHROPIC_API_KEY", "anthropic_api_key") if ENV["ANTHROPIC_API_KEY"]
|
|
@@ -65,6 +86,8 @@ module Pocketrb
|
|
|
65
86
|
anthropic_api_key: ENV["ANTHROPIC_API_KEY"] || self[:anthropic_api_key],
|
|
66
87
|
openrouter_api_key: ENV["OPENROUTER_API_KEY"] || self[:openrouter_api_key],
|
|
67
88
|
openai_api_key: ENV["OPENAI_API_KEY"] || self[:openai_api_key],
|
|
89
|
+
openai_compatible_base_url: self[:openai_compatible_base_url],
|
|
90
|
+
openai_compatible_api_key: self[:openai_compatible_api_key],
|
|
68
91
|
brave_api_key: ENV["BRAVE_API_KEY"] || self[:brave_api_key],
|
|
69
92
|
model: self[:model],
|
|
70
93
|
autonomous: self[:autonomous],
|
|
@@ -74,7 +97,8 @@ module Pocketrb
|
|
|
74
97
|
}.compact
|
|
75
98
|
end
|
|
76
99
|
|
|
77
|
-
# Save configuration
|
|
100
|
+
# Save configuration to workspace config file
|
|
101
|
+
# @return [void]
|
|
78
102
|
def save!
|
|
79
103
|
return unless @workspace
|
|
80
104
|
|
|
@@ -87,42 +111,53 @@ module Pocketrb
|
|
|
87
111
|
Pocketrb.logger.debug("Saved config to #{config_file}")
|
|
88
112
|
end
|
|
89
113
|
|
|
90
|
-
# Reload configuration
|
|
114
|
+
# Reload configuration from defaults, workspace, global, and env
|
|
115
|
+
# @return [void]
|
|
91
116
|
def reload!
|
|
92
117
|
@data = DEFAULTS.dup
|
|
93
118
|
load_config!
|
|
94
119
|
end
|
|
95
120
|
|
|
96
121
|
# Merge configuration
|
|
122
|
+
# @param hash [Hash] Hash of configuration values to merge
|
|
123
|
+
# @return [void]
|
|
97
124
|
def merge!(hash)
|
|
98
125
|
hash.each do |key, value|
|
|
99
126
|
@data[key.to_sym] = value
|
|
100
127
|
end
|
|
101
128
|
end
|
|
102
129
|
|
|
103
|
-
# Convert to hash
|
|
130
|
+
# Convert configuration to hash
|
|
131
|
+
# @return [Hash] copy of configuration data
|
|
104
132
|
def to_h
|
|
105
133
|
@data.dup
|
|
106
134
|
end
|
|
107
135
|
|
|
108
136
|
# Class method to load config
|
|
137
|
+
# @param workspace [String, Pathname] Workspace directory path
|
|
138
|
+
# @return [Config] New configuration instance
|
|
109
139
|
def self.load(workspace)
|
|
110
140
|
new(workspace: workspace)
|
|
111
141
|
end
|
|
112
142
|
|
|
113
|
-
# Global default config
|
|
143
|
+
# Global default config without workspace
|
|
144
|
+
# @return [Config] default configuration instance
|
|
114
145
|
def self.default
|
|
115
146
|
@default ||= new
|
|
116
147
|
end
|
|
117
148
|
|
|
118
149
|
private
|
|
119
150
|
|
|
151
|
+
# Load all configuration sources in priority order
|
|
152
|
+
# @return [void]
|
|
120
153
|
def load_config!
|
|
121
154
|
load_workspace_config if @workspace
|
|
122
155
|
load_global_config
|
|
123
156
|
load_env_overrides
|
|
124
157
|
end
|
|
125
158
|
|
|
159
|
+
# Load workspace-specific configuration from .pocketrb/config.yml
|
|
160
|
+
# @return [void]
|
|
126
161
|
def load_workspace_config
|
|
127
162
|
config_file = @workspace.join(CONFIG_DIR, CONFIG_FILE)
|
|
128
163
|
return unless config_file.exist?
|
|
@@ -133,6 +168,8 @@ module Pocketrb
|
|
|
133
168
|
Pocketrb.logger.warn("Failed to load workspace config: #{e.message}")
|
|
134
169
|
end
|
|
135
170
|
|
|
171
|
+
# Load global configuration from ~/.pocketrb/config.yml
|
|
172
|
+
# @return [void]
|
|
136
173
|
def load_global_config
|
|
137
174
|
global_config = Pathname.new(Dir.home).join(".pocketrb", CONFIG_FILE)
|
|
138
175
|
return unless global_config.exist?
|
|
@@ -144,6 +181,8 @@ module Pocketrb
|
|
|
144
181
|
Pocketrb.logger.warn("Failed to load global config: #{e.message}")
|
|
145
182
|
end
|
|
146
183
|
|
|
184
|
+
# Apply environment variable overrides to configuration
|
|
185
|
+
# @return [void]
|
|
147
186
|
def load_env_overrides
|
|
148
187
|
# Environment variables override config file
|
|
149
188
|
warn_env_deprecated("POCKETRB_PROVIDER", "provider") if ENV["POCKETRB_PROVIDER"]
|
|
@@ -160,6 +199,16 @@ module Pocketrb
|
|
|
160
199
|
warn_env_deprecated("MCP_ENDPOINT", "mcp_endpoint") if ENV["MCP_ENDPOINT"]
|
|
161
200
|
@data[:mcp_endpoint] = ENV["MCP_ENDPOINT"] if ENV["MCP_ENDPOINT"]
|
|
162
201
|
|
|
202
|
+
if ENV["POCKETRB_COMPACTION_MODEL"]
|
|
203
|
+
warn_env_deprecated("POCKETRB_COMPACTION_MODEL", "compaction_model")
|
|
204
|
+
@data[:compaction_model] = ENV["POCKETRB_COMPACTION_MODEL"]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
if ENV["POCKETRB_COMPACTION_PRESSURE"]
|
|
208
|
+
warn_env_deprecated("POCKETRB_COMPACTION_PRESSURE", "compaction_pressure")
|
|
209
|
+
@data[:compaction_pressure] = ENV["POCKETRB_COMPACTION_PRESSURE"].to_f
|
|
210
|
+
end
|
|
211
|
+
|
|
163
212
|
# Autonomous mode (for sandboxed environments)
|
|
164
213
|
if %w[1 true].include?(ENV["POCKETRB_AUTONOMOUS"])
|
|
165
214
|
warn_env_deprecated("POCKETRB_AUTONOMOUS", "autonomous")
|
data/lib/pocketrb/cron/job.rb
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Pocketrb
|
|
4
|
+
# Cron job scheduling system
|
|
4
5
|
module Cron
|
|
5
6
|
# Schedule types
|
|
6
7
|
# - :at - one-time execution at specific timestamp
|
|
7
8
|
# - :every - interval-based execution (every N seconds)
|
|
8
9
|
# - :cron - cron expression-based execution
|
|
9
10
|
Schedule = Data.define(:kind, :at_ms, :every_ms, :expr, :tz) do
|
|
11
|
+
# Initialize schedule
|
|
12
|
+
# @param kind [Symbol] Schedule type (:at, :every, or :cron)
|
|
13
|
+
# @param at_ms [Integer, nil] Timestamp in milliseconds for one-time execution
|
|
14
|
+
# @param every_ms [Integer, nil] Interval in milliseconds for recurring execution
|
|
15
|
+
# @param expr [String, nil] Cron expression for cron-based scheduling
|
|
16
|
+
# @param tz [String, nil] Timezone identifier (e.g., "America/New_York")
|
|
10
17
|
def initialize(kind:, at_ms: nil, every_ms: nil, expr: nil, tz: nil)
|
|
11
18
|
super
|
|
12
19
|
end
|
|
13
20
|
|
|
21
|
+
# Check if schedule is one-time execution
|
|
22
|
+
# @return [Boolean] True if schedule type is :at
|
|
14
23
|
def one_time?
|
|
15
24
|
kind == :at
|
|
16
25
|
end
|
|
17
26
|
|
|
27
|
+
# Check if schedule is interval-based
|
|
28
|
+
# @return [Boolean] True if schedule type is :every
|
|
18
29
|
def interval?
|
|
19
30
|
kind == :every
|
|
20
31
|
end
|
|
21
32
|
|
|
33
|
+
# Check if schedule is cron expression-based
|
|
34
|
+
# @return [Boolean] True if schedule type is :cron
|
|
22
35
|
def cron?
|
|
23
36
|
kind == :cron
|
|
24
37
|
end
|
|
@@ -26,6 +39,11 @@ module Pocketrb
|
|
|
26
39
|
|
|
27
40
|
# Payload for job execution
|
|
28
41
|
Payload = Data.define(:message, :deliver, :channel, :to) do
|
|
42
|
+
# Initialize payload
|
|
43
|
+
# @param message [String] Content to send or task description when job executes
|
|
44
|
+
# @param deliver [Boolean] Whether to deliver via messaging channel (defaults to false)
|
|
45
|
+
# @param channel [Symbol, nil] Channel identifier for delivery (:telegram, :cli, etc.)
|
|
46
|
+
# @param to [String, nil] Recipient identifier for channel delivery
|
|
29
47
|
def initialize(message:, deliver: false, channel: nil, to: nil)
|
|
30
48
|
super
|
|
31
49
|
end
|
|
@@ -33,6 +51,11 @@ module Pocketrb
|
|
|
33
51
|
|
|
34
52
|
# Job execution state
|
|
35
53
|
JobState = Data.define(:next_run_at_ms, :last_run_at_ms, :last_status, :last_error) do
|
|
54
|
+
# Initialize job state
|
|
55
|
+
# @param next_run_at_ms [Integer, nil] Timestamp in milliseconds for next scheduled run
|
|
56
|
+
# @param last_run_at_ms [Integer, nil] Timestamp in milliseconds of last execution
|
|
57
|
+
# @param last_status [String, nil] Status of last execution (success, failed, etc.)
|
|
58
|
+
# @param last_error [String, nil] Error message from last failed execution
|
|
36
59
|
def initialize(next_run_at_ms: nil, last_run_at_ms: nil, last_status: nil, last_error: nil)
|
|
37
60
|
super
|
|
38
61
|
end
|
|
@@ -50,6 +73,16 @@ module Pocketrb
|
|
|
50
73
|
:updated_at_ms,
|
|
51
74
|
:delete_after_run
|
|
52
75
|
) do
|
|
76
|
+
# Initialize cron job
|
|
77
|
+
# @param id [String] Unique job identifier
|
|
78
|
+
# @param name [String] Human-readable job name
|
|
79
|
+
# @param schedule [Schedule] Job schedule configuration
|
|
80
|
+
# @param payload [Payload] Job execution payload
|
|
81
|
+
# @param enabled [Boolean] Whether job is enabled for execution (defaults to true)
|
|
82
|
+
# @param state [JobState] Job execution state (defaults to new JobState)
|
|
83
|
+
# @param created_at_ms [Integer, nil] Creation timestamp in milliseconds
|
|
84
|
+
# @param updated_at_ms [Integer, nil] Last update timestamp in milliseconds
|
|
85
|
+
# @param delete_after_run [Boolean] Whether to delete job after execution (defaults to false)
|
|
53
86
|
def initialize(
|
|
54
87
|
id:,
|
|
55
88
|
name:,
|
|
@@ -74,6 +107,9 @@ module Pocketrb
|
|
|
74
107
|
)
|
|
75
108
|
end
|
|
76
109
|
|
|
110
|
+
# Check if job is due to run
|
|
111
|
+
# @param now_ms [Integer, nil] Current timestamp in milliseconds (defaults to Time.now)
|
|
112
|
+
# @return [Boolean] True if job should execute now
|
|
77
113
|
def due?(now_ms = nil)
|
|
78
114
|
return false unless enabled
|
|
79
115
|
return false unless state.next_run_at_ms
|
|
@@ -82,6 +118,8 @@ module Pocketrb
|
|
|
82
118
|
state.next_run_at_ms <= now_ms
|
|
83
119
|
end
|
|
84
120
|
|
|
121
|
+
# Convert job to hash for serialization
|
|
122
|
+
# @return [Hash] Job data as hash
|
|
85
123
|
def to_h
|
|
86
124
|
{
|
|
87
125
|
"id" => id,
|
|
@@ -112,6 +150,9 @@ module Pocketrb
|
|
|
112
150
|
}
|
|
113
151
|
end
|
|
114
152
|
|
|
153
|
+
# Create job from hash representation
|
|
154
|
+
# @param hash [Hash] Hash containing job data
|
|
155
|
+
# @return [Job] New job instance
|
|
115
156
|
def self.from_h(hash)
|
|
116
157
|
schedule_h = hash["schedule"]
|
|
117
158
|
schedule = Schedule.new(
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require "fugit"
|
|
6
7
|
require_relative "job"
|
|
7
8
|
|
|
8
9
|
module Pocketrb
|
|
@@ -11,6 +12,9 @@ module Pocketrb
|
|
|
11
12
|
class Service
|
|
12
13
|
attr_reader :jobs
|
|
13
14
|
|
|
15
|
+
# Initialize the cron service
|
|
16
|
+
# @param store_path [String, Pathname] Directory path for persisting job state
|
|
17
|
+
# @param on_job [Proc] Callback invoked when a job executes, receives the job object
|
|
14
18
|
def initialize(store_path:, on_job:)
|
|
15
19
|
@store_path = Pathname.new(store_path)
|
|
16
20
|
@on_job = on_job
|
|
@@ -24,6 +28,7 @@ module Pocketrb
|
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
# Start the cron service
|
|
31
|
+
# @return [void]
|
|
27
32
|
def start
|
|
28
33
|
return if @running
|
|
29
34
|
|
|
@@ -35,6 +40,7 @@ module Pocketrb
|
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
# Stop the cron service
|
|
43
|
+
# @return [void]
|
|
38
44
|
def stop
|
|
39
45
|
@running = false
|
|
40
46
|
@timer_thread&.kill
|
|
@@ -57,8 +63,8 @@ module Pocketrb
|
|
|
57
63
|
# Add a new job
|
|
58
64
|
# @param name [String] Job name
|
|
59
65
|
# @param schedule [Schedule] Schedule configuration
|
|
60
|
-
# @param message [String]
|
|
61
|
-
# @param deliver [Boolean]
|
|
66
|
+
# @param message [String] Content to send or task description when job executes
|
|
67
|
+
# @param deliver [Boolean] Whether to send directly to channel (true) or process as agent task (false)
|
|
62
68
|
# @param channel [String, nil] Target channel
|
|
63
69
|
# @param to [String, nil] Target chat ID
|
|
64
70
|
# @return [Job] Created job
|
|
@@ -96,8 +102,8 @@ module Pocketrb
|
|
|
96
102
|
# Add a job with interval schedule
|
|
97
103
|
# @param name [String] Job name
|
|
98
104
|
# @param every [Integer] Interval in seconds
|
|
99
|
-
# @param message [String]
|
|
100
|
-
# @param deliver [Boolean]
|
|
105
|
+
# @param message [String] Content to send or task description when job executes
|
|
106
|
+
# @param deliver [Boolean] Whether to send directly to channel (true) or process as agent task (false)
|
|
101
107
|
# @param channel [String, nil] Target channel
|
|
102
108
|
# @param to [String, nil] Target chat ID
|
|
103
109
|
# @return [Job]
|
|
@@ -109,9 +115,9 @@ module Pocketrb
|
|
|
109
115
|
# Add a job with cron expression
|
|
110
116
|
# @param name [String] Job name
|
|
111
117
|
# @param cron [String] Cron expression
|
|
112
|
-
# @param message [String]
|
|
118
|
+
# @param message [String] Content to send or task description when job executes
|
|
113
119
|
# @param tz [String, nil] Timezone
|
|
114
|
-
# @param deliver [Boolean]
|
|
120
|
+
# @param deliver [Boolean] Whether to send directly to channel (true) or process as agent task (false)
|
|
115
121
|
# @param channel [String, nil] Target channel
|
|
116
122
|
# @param to [String, nil] Target chat ID
|
|
117
123
|
# @return [Job]
|
|
@@ -123,8 +129,8 @@ module Pocketrb
|
|
|
123
129
|
# Add a one-time job
|
|
124
130
|
# @param name [String] Job name
|
|
125
131
|
# @param at [Time, Integer] Execution time (Time or Unix timestamp)
|
|
126
|
-
# @param message [String]
|
|
127
|
-
# @param deliver [Boolean]
|
|
132
|
+
# @param message [String] Content to send or task description when job executes
|
|
133
|
+
# @param deliver [Boolean] Whether to send directly to channel (true) or process as agent task (false)
|
|
128
134
|
# @param channel [String, nil] Target channel
|
|
129
135
|
# @param to [String, nil] Target chat ID
|
|
130
136
|
# @return [Job]
|
|
@@ -204,10 +210,14 @@ module Pocketrb
|
|
|
204
210
|
|
|
205
211
|
private
|
|
206
212
|
|
|
213
|
+
# Ensure the store directory exists
|
|
214
|
+
# @return [void]
|
|
207
215
|
def ensure_store_dir!
|
|
208
216
|
FileUtils.mkdir_p(@store_path.dirname)
|
|
209
217
|
end
|
|
210
218
|
|
|
219
|
+
# Load persisted jobs from the store file
|
|
220
|
+
# @return [void]
|
|
211
221
|
def load_jobs!
|
|
212
222
|
return unless @store_path.exist?
|
|
213
223
|
|
|
@@ -219,6 +229,8 @@ module Pocketrb
|
|
|
219
229
|
@jobs = {}
|
|
220
230
|
end
|
|
221
231
|
|
|
232
|
+
# Persist all jobs to the store file
|
|
233
|
+
# @return [void]
|
|
222
234
|
def save_jobs!
|
|
223
235
|
@mutex.synchronize do
|
|
224
236
|
data = @jobs.transform_values(&:to_h)
|
|
@@ -226,6 +238,8 @@ module Pocketrb
|
|
|
226
238
|
end
|
|
227
239
|
end
|
|
228
240
|
|
|
241
|
+
# Recompute next run times for all enabled jobs
|
|
242
|
+
# @return [void]
|
|
229
243
|
def compute_next_runs!
|
|
230
244
|
now_ms = (Time.now.to_f * 1000).to_i
|
|
231
245
|
|
|
@@ -241,6 +255,9 @@ module Pocketrb
|
|
|
241
255
|
save_jobs!
|
|
242
256
|
end
|
|
243
257
|
|
|
258
|
+
# Compute the next run time for a job based on its schedule
|
|
259
|
+
# @param job [Job] job to compute next run for
|
|
260
|
+
# @return [Job] new job instance with updated next_run_at_ms
|
|
244
261
|
def compute_next_run(job)
|
|
245
262
|
now_ms = (Time.now.to_f * 1000).to_i
|
|
246
263
|
next_ms = case job.schedule.kind
|
|
@@ -273,10 +290,11 @@ module Pocketrb
|
|
|
273
290
|
)
|
|
274
291
|
end
|
|
275
292
|
|
|
293
|
+
# Compute the next run time from a cron expression
|
|
294
|
+
# @param expr [String] cron expression to parse
|
|
295
|
+
# @param tz [String, nil] timezone for cron evaluation
|
|
296
|
+
# @return [Integer, nil] next run time in milliseconds since epoch
|
|
276
297
|
def compute_cron_next(expr, tz = nil)
|
|
277
|
-
# Try to use fugit if available
|
|
278
|
-
|
|
279
|
-
require "fugit"
|
|
280
298
|
cron = Fugit.parse_cron(expr)
|
|
281
299
|
return nil unless cron
|
|
282
300
|
|
|
@@ -285,12 +303,10 @@ module Pocketrb
|
|
|
285
303
|
|
|
286
304
|
next_time = cron.next_time(now)
|
|
287
305
|
(next_time.to_f * 1000).to_i
|
|
288
|
-
rescue LoadError
|
|
289
|
-
# Fallback: simple minute-based scheduling without fugit
|
|
290
|
-
Pocketrb.logger.warn("Fugit gem not available, cron expressions may not work correctly")
|
|
291
|
-
((Time.now.to_f * 1000) + 60_000).to_i
|
|
292
306
|
end
|
|
293
307
|
|
|
308
|
+
# Arm the timer thread for the next earliest job
|
|
309
|
+
# @return [void]
|
|
294
310
|
def arm_timer!
|
|
295
311
|
return unless @running
|
|
296
312
|
|
|
@@ -319,6 +335,8 @@ module Pocketrb
|
|
|
319
335
|
Pocketrb.logger.debug("Cron timer armed for #{delay_s}s")
|
|
320
336
|
end
|
|
321
337
|
|
|
338
|
+
# Process all due jobs and re-arm the timer
|
|
339
|
+
# @return [void]
|
|
322
340
|
def tick
|
|
323
341
|
now_ms = (Time.now.to_f * 1000).to_i
|
|
324
342
|
due_jobs = @mutex.synchronize do
|
|
@@ -332,6 +350,9 @@ module Pocketrb
|
|
|
332
350
|
arm_timer!
|
|
333
351
|
end
|
|
334
352
|
|
|
353
|
+
# Execute a job and update its state
|
|
354
|
+
# @param job [Job] scheduled cron entry whose callback will be invoked
|
|
355
|
+
# @return [void]
|
|
335
356
|
def execute_job(job)
|
|
336
357
|
Pocketrb.logger.info("Executing cron job '#{job.name}' (ID: #{job.id})")
|
|
337
358
|
|
|
@@ -352,6 +373,11 @@ module Pocketrb
|
|
|
352
373
|
end
|
|
353
374
|
end
|
|
354
375
|
|
|
376
|
+
# Update a job's execution state after a run
|
|
377
|
+
# @param job_id [String] job identifier
|
|
378
|
+
# @param status [String] execution status ("success" or "failed")
|
|
379
|
+
# @param error [String, nil] error message if failed
|
|
380
|
+
# @return [void]
|
|
355
381
|
def update_job_state(job_id, status:, error: nil)
|
|
356
382
|
@mutex.synchronize do
|
|
357
383
|
job = @jobs[job_id]
|
|
@@ -380,6 +406,9 @@ module Pocketrb
|
|
|
380
406
|
save_jobs!
|
|
381
407
|
end
|
|
382
408
|
|
|
409
|
+
# Recompute and persist the next run time for a job
|
|
410
|
+
# @param job_id [String] job identifier
|
|
411
|
+
# @return [void]
|
|
383
412
|
def update_job_next_run(job_id)
|
|
384
413
|
@mutex.synchronize do
|
|
385
414
|
job = @jobs[job_id]
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Pocketrb
|
|
4
|
+
# Heartbeat system for periodic agent check-ins
|
|
4
5
|
module Heartbeat
|
|
5
6
|
# Periodic wake-up service that checks HEARTBEAT.md for pending tasks
|
|
6
7
|
class Service
|
|
7
8
|
DEFAULT_INTERVAL = 30 * 60 # 30 minutes
|
|
9
|
+
# Filename for heartbeat tasks
|
|
8
10
|
HEARTBEAT_FILE = "HEARTBEAT.md"
|
|
9
11
|
|
|
12
|
+
# Prompt sent to agent on each heartbeat
|
|
10
13
|
HEARTBEAT_PROMPT = <<~PROMPT
|
|
11
14
|
Read HEARTBEAT.md in your workspace. Follow any instructions or tasks listed there.
|
|
12
15
|
If nothing needs attention, reply with: HEARTBEAT_OK
|
|
@@ -20,6 +23,11 @@ module Pocketrb
|
|
|
20
23
|
|
|
21
24
|
attr_reader :interval, :enabled, :last_run_at
|
|
22
25
|
|
|
26
|
+
# Initialize heartbeat service
|
|
27
|
+
# @param workspace [String, Pathname] Workspace directory containing HEARTBEAT.md
|
|
28
|
+
# @param on_heartbeat [Proc] Callback to invoke on each heartbeat with message content
|
|
29
|
+
# @param interval [Integer] Heartbeat interval in seconds (defaults to 3600)
|
|
30
|
+
# @param enabled [Boolean] Whether heartbeat is enabled (defaults to true)
|
|
23
31
|
def initialize(workspace:, on_heartbeat:, interval: DEFAULT_INTERVAL, enabled: true)
|
|
24
32
|
@workspace = Pathname.new(workspace)
|
|
25
33
|
@on_heartbeat = on_heartbeat
|
|
@@ -32,6 +40,7 @@ module Pocketrb
|
|
|
32
40
|
end
|
|
33
41
|
|
|
34
42
|
# Start the heartbeat service
|
|
43
|
+
# @return [void]
|
|
35
44
|
def start
|
|
36
45
|
return unless @enabled
|
|
37
46
|
return if @running
|
|
@@ -43,6 +52,7 @@ module Pocketrb
|
|
|
43
52
|
end
|
|
44
53
|
|
|
45
54
|
# Stop the heartbeat service
|
|
55
|
+
# @return [void]
|
|
46
56
|
def stop
|
|
47
57
|
@running = false
|
|
48
58
|
@timer_thread&.kill
|
|
@@ -117,6 +127,8 @@ module Pocketrb
|
|
|
117
127
|
|
|
118
128
|
private
|
|
119
129
|
|
|
130
|
+
# Arm the periodic timer thread
|
|
131
|
+
# @return [void]
|
|
120
132
|
def arm_timer!
|
|
121
133
|
return unless @running && @enabled
|
|
122
134
|
|
|
@@ -131,10 +143,14 @@ module Pocketrb
|
|
|
131
143
|
end
|
|
132
144
|
end
|
|
133
145
|
|
|
146
|
+
# Path to the HEARTBEAT.md file in the workspace
|
|
147
|
+
# @return [Pathname] heartbeat file path
|
|
134
148
|
def heartbeat_file
|
|
135
149
|
@workspace.join(HEARTBEAT_FILE)
|
|
136
150
|
end
|
|
137
151
|
|
|
152
|
+
# Read heartbeat file contents
|
|
153
|
+
# @return [String, nil] file contents or nil if not found
|
|
138
154
|
def read_heartbeat_file
|
|
139
155
|
return nil unless heartbeat_file.exist?
|
|
140
156
|
|
|
@@ -144,6 +160,9 @@ module Pocketrb
|
|
|
144
160
|
nil
|
|
145
161
|
end
|
|
146
162
|
|
|
163
|
+
# Check if heartbeat content has no actionable items
|
|
164
|
+
# @param content [String, nil] heartbeat file content
|
|
165
|
+
# @return [Boolean] true if content is empty or has no tasks
|
|
147
166
|
def empty_heartbeat?(content)
|
|
148
167
|
return true if content.nil? || content.strip.empty?
|
|
149
168
|
|
|
@@ -163,6 +182,8 @@ module Pocketrb
|
|
|
163
182
|
end
|
|
164
183
|
end
|
|
165
184
|
|
|
185
|
+
# Seconds remaining until next heartbeat
|
|
186
|
+
# @return [Integer, nil] seconds until next run or nil
|
|
166
187
|
def next_run_in
|
|
167
188
|
return nil unless @running && @enabled && @last_run_at
|
|
168
189
|
|
data/lib/pocketrb/mcp/client.rb
CHANGED
|
@@ -5,15 +5,19 @@ require "json"
|
|
|
5
5
|
require "securerandom"
|
|
6
6
|
|
|
7
7
|
module Pocketrb
|
|
8
|
+
# Model Context Protocol (MCP) integration for memory and tool servers
|
|
8
9
|
module MCP
|
|
9
|
-
# MCP client for connecting to MCP HTTP Bridge
|
|
10
|
-
# Implements JSON-RPC 2.0 protocol for MCP communication
|
|
10
|
+
# MCP client for connecting to MCP HTTP Bridge. Implements JSON-RPC 2.0 protocol for MCP communication
|
|
11
11
|
class Client
|
|
12
|
+
# Default MCP server endpoint URL
|
|
12
13
|
DEFAULT_ENDPOINT = "http://localhost:7878"
|
|
14
|
+
# Request timeout in seconds
|
|
13
15
|
TIMEOUT = 30
|
|
14
16
|
|
|
15
17
|
attr_reader :endpoint, :connected
|
|
16
18
|
|
|
19
|
+
# Initialize MCP client
|
|
20
|
+
# @param endpoint [String, nil] MCP server endpoint URL (defaults to ENV['MCP_ENDPOINT'] or DEFAULT_ENDPOINT)
|
|
17
21
|
def initialize(endpoint: nil)
|
|
18
22
|
@endpoint = endpoint || ENV["MCP_ENDPOINT"] || DEFAULT_ENDPOINT
|
|
19
23
|
@connected = false
|
|
@@ -21,6 +25,7 @@ module Pocketrb
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
# Initialize the MCP connection
|
|
28
|
+
# @return [Boolean] true if connection succeeded
|
|
24
29
|
def connect
|
|
25
30
|
response = rpc_call("initialize", {
|
|
26
31
|
protocolVersion: "2024-11-05",
|
|
@@ -45,6 +50,7 @@ module Pocketrb
|
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
# List available tools from MCP server
|
|
53
|
+
# @return [Array<Hash>] list of tool definitions
|
|
48
54
|
def list_tools
|
|
49
55
|
ensure_connected!
|
|
50
56
|
|
|
@@ -53,6 +59,10 @@ module Pocketrb
|
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
# Call a tool on the MCP server
|
|
62
|
+
# @param name [String] Tool name to invoke
|
|
63
|
+
# @param arguments [Hash] Tool arguments
|
|
64
|
+
# @return [String] Tool execution result
|
|
65
|
+
# @raise [MCPError] if tool call fails
|
|
56
66
|
def call_tool(name:, arguments: {})
|
|
57
67
|
ensure_connected!
|
|
58
68
|
|
|
@@ -67,6 +77,9 @@ module Pocketrb
|
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
# Search memory via MCP
|
|
80
|
+
# @param query [String] Search query string
|
|
81
|
+
# @param limit [Integer] Maximum number of results to return
|
|
82
|
+
# @return [String] Search results
|
|
70
83
|
def search(query:, limit: 10)
|
|
71
84
|
# Try MCP tool first
|
|
72
85
|
if tool_available?("memory_search")
|
|
@@ -78,6 +91,9 @@ module Pocketrb
|
|
|
78
91
|
end
|
|
79
92
|
|
|
80
93
|
# Store to memory via MCP
|
|
94
|
+
# @param content [String] Content to store in memory
|
|
95
|
+
# @param metadata [Hash] Additional metadata for the stored content
|
|
96
|
+
# @return [String] Storage confirmation
|
|
81
97
|
def store(content:, metadata: {})
|
|
82
98
|
# Try MCP tool first
|
|
83
99
|
if tool_available?("memory_store")
|
|
@@ -89,17 +105,22 @@ module Pocketrb
|
|
|
89
105
|
end
|
|
90
106
|
|
|
91
107
|
# Check if connected
|
|
108
|
+
# @return [Boolean] true if connected to MCP server
|
|
92
109
|
def connected?
|
|
93
110
|
@connected
|
|
94
111
|
end
|
|
95
112
|
|
|
96
113
|
# Disconnect from server
|
|
114
|
+
# @return [void]
|
|
97
115
|
def disconnect
|
|
98
116
|
@connected = false
|
|
99
117
|
end
|
|
100
118
|
|
|
101
119
|
private
|
|
102
120
|
|
|
121
|
+
# Ensure MCP connection is established, connecting if needed
|
|
122
|
+
# @return [void]
|
|
123
|
+
# @raise [MCPError] if connection cannot be established
|
|
103
124
|
def ensure_connected!
|
|
104
125
|
return if @connected
|
|
105
126
|
|
|
@@ -107,6 +128,11 @@ module Pocketrb
|
|
|
107
128
|
raise MCPError, "Not connected to MCP server" unless @connected
|
|
108
129
|
end
|
|
109
130
|
|
|
131
|
+
# Execute a JSON-RPC 2.0 call to the MCP server
|
|
132
|
+
# @param method [String] RPC method name
|
|
133
|
+
# @param params [Hash] RPC parameters
|
|
134
|
+
# @return [Hash] parsed JSON response
|
|
135
|
+
# @raise [MCPError] on connection or HTTP errors
|
|
110
136
|
def rpc_call(method, params)
|
|
111
137
|
@request_id += 1
|
|
112
138
|
|
|
@@ -128,6 +154,8 @@ module Pocketrb
|
|
|
128
154
|
raise MCPError, "RPC connection error: #{e.message}"
|
|
129
155
|
end
|
|
130
156
|
|
|
157
|
+
# Faraday HTTP client instance
|
|
158
|
+
# @return [Faraday::Connection] configured HTTP client
|
|
131
159
|
def client
|
|
132
160
|
@client ||= Faraday.new(url: @endpoint) do |f|
|
|
133
161
|
f.headers["Content-Type"] = "application/json"
|
|
@@ -136,6 +164,9 @@ module Pocketrb
|
|
|
136
164
|
end
|
|
137
165
|
end
|
|
138
166
|
|
|
167
|
+
# Check if a tool is available on the MCP server
|
|
168
|
+
# @param name [String] tool name to check
|
|
169
|
+
# @return [Boolean] true if tool is available
|
|
139
170
|
def tool_available?(name)
|
|
140
171
|
@tools ||= list_tools
|
|
141
172
|
@tools.any? { |t| t["name"] == name }
|
|
@@ -143,7 +174,10 @@ module Pocketrb
|
|
|
143
174
|
false
|
|
144
175
|
end
|
|
145
176
|
|
|
146
|
-
#
|
|
177
|
+
# Search via direct HTTP endpoint (fallback when MCP tools unavailable)
|
|
178
|
+
# @param query [String] search query
|
|
179
|
+
# @param limit [Integer] maximum results
|
|
180
|
+
# @return [Hash, nil] parsed search results or nil on failure
|
|
147
181
|
def http_search(query, limit)
|
|
148
182
|
response = client.post("/search") do |req|
|
|
149
183
|
req.body = { query: query, limit: limit }.to_json
|
|
@@ -156,6 +190,10 @@ module Pocketrb
|
|
|
156
190
|
nil
|
|
157
191
|
end
|
|
158
192
|
|
|
193
|
+
# Store via direct HTTP endpoint (fallback when MCP tools unavailable)
|
|
194
|
+
# @param content [String] text or fact to persist in the memory backend
|
|
195
|
+
# @param metadata [Hash] metadata to associate with content
|
|
196
|
+
# @return [Boolean] true if stored successfully
|
|
159
197
|
def http_store(content, metadata)
|
|
160
198
|
response = client.post("/store") do |req|
|
|
161
199
|
req.body = { content: content, metadata: metadata }.to_json
|