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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +98 -17
  4. data/lib/pocketrb/agent/compaction.rb +235 -22
  5. data/lib/pocketrb/agent/context.rb +26 -3
  6. data/lib/pocketrb/agent/loop.rb +53 -5
  7. data/lib/pocketrb/agent/spawn_tool.rb +14 -0
  8. data/lib/pocketrb/agent/subagent_manager.rb +24 -2
  9. data/lib/pocketrb/bus/events.rb +42 -1
  10. data/lib/pocketrb/bus/message_bus.rb +37 -0
  11. data/lib/pocketrb/channels/base.rb +13 -0
  12. data/lib/pocketrb/channels/cli.rb +16 -1
  13. data/lib/pocketrb/channels/telegram.rb +70 -4
  14. data/lib/pocketrb/channels/whatsapp.rb +49 -7
  15. data/lib/pocketrb/cli/chat.rb +7 -0
  16. data/lib/pocketrb/cli/config.rb +4 -0
  17. data/lib/pocketrb/cli/cron.rb +6 -0
  18. data/lib/pocketrb/cli.rb +0 -1
  19. data/lib/pocketrb/config.rb +55 -6
  20. data/lib/pocketrb/cron/job.rb +41 -0
  21. data/lib/pocketrb/cron/service.rb +44 -15
  22. data/lib/pocketrb/heartbeat/service.rb +21 -0
  23. data/lib/pocketrb/mcp/client.rb +41 -3
  24. data/lib/pocketrb/mcp/memory_tool.rb +33 -0
  25. data/lib/pocketrb/media/processor.rb +32 -1
  26. data/lib/pocketrb/memory.rb +12 -3
  27. data/lib/pocketrb/planning/manager.rb +29 -1
  28. data/lib/pocketrb/planning/plan.rb +52 -0
  29. data/lib/pocketrb/planning/tool.rb +45 -0
  30. data/lib/pocketrb/providers/anthropic.rb +113 -9
  31. data/lib/pocketrb/providers/base.rb +39 -4
  32. data/lib/pocketrb/providers/claude_cli.rb +79 -7
  33. data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
  34. data/lib/pocketrb/providers/openai_compatible.rb +79 -0
  35. data/lib/pocketrb/providers/openrouter.rb +72 -12
  36. data/lib/pocketrb/providers/registry.rb +24 -0
  37. data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
  38. data/lib/pocketrb/providers/types.rb +51 -1
  39. data/lib/pocketrb/session/manager.rb +34 -2
  40. data/lib/pocketrb/session/session.rb +53 -11
  41. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
  42. data/lib/pocketrb/skills/create_tool.rb +28 -0
  43. data/lib/pocketrb/skills/loader.rb +22 -1
  44. data/lib/pocketrb/skills/modify_tool.rb +22 -0
  45. data/lib/pocketrb/skills/skill.rb +19 -3
  46. data/lib/pocketrb/tools/background_job_manager.rb +29 -0
  47. data/lib/pocketrb/tools/base.rb +16 -1
  48. data/lib/pocketrb/tools/browser.rb +40 -0
  49. data/lib/pocketrb/tools/browser_advanced.rb +94 -4
  50. data/lib/pocketrb/tools/browser_session.rb +18 -0
  51. data/lib/pocketrb/tools/cron.rb +46 -0
  52. data/lib/pocketrb/tools/edit_file.rb +16 -0
  53. data/lib/pocketrb/tools/exec.rb +46 -0
  54. data/lib/pocketrb/tools/jobs.rb +26 -2
  55. data/lib/pocketrb/tools/list_dir.rb +19 -0
  56. data/lib/pocketrb/tools/memory.rb +34 -0
  57. data/lib/pocketrb/tools/message.rb +15 -0
  58. data/lib/pocketrb/tools/para_memory.rb +47 -0
  59. data/lib/pocketrb/tools/read_file.rb +11 -0
  60. data/lib/pocketrb/tools/registry.rb +12 -4
  61. data/lib/pocketrb/tools/send_file.rb +29 -0
  62. data/lib/pocketrb/tools/think.rb +9 -0
  63. data/lib/pocketrb/tools/web_fetch.rb +58 -3
  64. data/lib/pocketrb/tools/web_search.rb +25 -0
  65. data/lib/pocketrb/tools/write_file.rb +10 -0
  66. data/lib/pocketrb/version.rb +2 -1
  67. data/lib/pocketrb.rb +4 -1
  68. data/pocketrb.gemspec +6 -8
  69. metadata +24 -44
@@ -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-4-20250514",
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")
@@ -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] Message to process
61
- # @param deliver [Boolean] Deliver to channel vs process as agent task
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] Message to process
100
- # @param deliver [Boolean] Deliver to channel
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] Message to process
118
+ # @param message [String] Content to send or task description when job executes
113
119
  # @param tz [String, nil] Timezone
114
- # @param deliver [Boolean] Deliver to channel
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] Message to process
127
- # @param deliver [Boolean] Deliver to channel
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
 
@@ -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
- # Direct HTTP endpoints (fallback when not using MCP tools)
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