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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. metadata +327 -0
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "fileutils"
6
+ require_relative "job"
7
+
8
+ module Pocketrb
9
+ module Cron
10
+ # Manages scheduled jobs with persistence and execution
11
+ class Service
12
+ attr_reader :jobs
13
+
14
+ def initialize(store_path:, on_job:)
15
+ @store_path = Pathname.new(store_path)
16
+ @on_job = on_job
17
+ @jobs = {}
18
+ @mutex = Mutex.new
19
+ @running = false
20
+ @timer_thread = nil
21
+
22
+ ensure_store_dir!
23
+ load_jobs!
24
+ end
25
+
26
+ # Start the cron service
27
+ def start
28
+ return if @running
29
+
30
+ @running = true
31
+ compute_next_runs!
32
+ arm_timer!
33
+
34
+ Pocketrb.logger.info("Cron service started with #{@jobs.size} jobs")
35
+ end
36
+
37
+ # Stop the cron service
38
+ def stop
39
+ @running = false
40
+ @timer_thread&.kill
41
+ @timer_thread = nil
42
+
43
+ Pocketrb.logger.info("Cron service stopped")
44
+ end
45
+
46
+ # List all jobs
47
+ # @param include_disabled [Boolean] Include disabled jobs
48
+ # @return [Array<Job>]
49
+ def list_jobs(include_disabled: false)
50
+ @mutex.synchronize do
51
+ jobs = @jobs.values
52
+ jobs = jobs.select(&:enabled) unless include_disabled
53
+ jobs.sort_by { |j| j.state.next_run_at_ms || Float::INFINITY }
54
+ end
55
+ end
56
+
57
+ # Add a new job
58
+ # @param name [String] Job name
59
+ # @param schedule [Schedule] Schedule configuration
60
+ # @param message [String] Message to process
61
+ # @param deliver [Boolean] Deliver to channel vs process as agent task
62
+ # @param channel [String, nil] Target channel
63
+ # @param to [String, nil] Target chat ID
64
+ # @return [Job] Created job
65
+ def add_job(name:, schedule:, message:, deliver: false, channel: nil, to: nil)
66
+ job_id = SecureRandom.hex(8)
67
+
68
+ payload = Payload.new(
69
+ message: message,
70
+ deliver: deliver,
71
+ channel: channel,
72
+ to: to
73
+ )
74
+
75
+ job = Job.new(
76
+ id: job_id,
77
+ name: name,
78
+ schedule: schedule,
79
+ payload: payload,
80
+ delete_after_run: schedule.one_time?
81
+ )
82
+
83
+ job = compute_next_run(job)
84
+
85
+ @mutex.synchronize do
86
+ @jobs[job_id] = job
87
+ end
88
+
89
+ save_jobs!
90
+ arm_timer!
91
+
92
+ Pocketrb.logger.info("Added cron job '#{name}' (ID: #{job_id})")
93
+ job
94
+ end
95
+
96
+ # Add a job with interval schedule
97
+ # @param name [String] Job name
98
+ # @param every [Integer] Interval in seconds
99
+ # @param message [String] Message to process
100
+ # @param deliver [Boolean] Deliver to channel
101
+ # @param channel [String, nil] Target channel
102
+ # @param to [String, nil] Target chat ID
103
+ # @return [Job]
104
+ def add_interval_job(name:, every:, message:, deliver: false, channel: nil, to: nil)
105
+ schedule = Pocketrb::Cron::Schedule.new(kind: :every, every_ms: every * 1000)
106
+ add_job(name: name, schedule: schedule, message: message, deliver: deliver, channel: channel, to: to)
107
+ end
108
+
109
+ # Add a job with cron expression
110
+ # @param name [String] Job name
111
+ # @param cron [String] Cron expression
112
+ # @param message [String] Message to process
113
+ # @param tz [String, nil] Timezone
114
+ # @param deliver [Boolean] Deliver to channel
115
+ # @param channel [String, nil] Target channel
116
+ # @param to [String, nil] Target chat ID
117
+ # @return [Job]
118
+ def add_cron_job(name:, cron:, message:, tz: nil, deliver: false, channel: nil, to: nil)
119
+ schedule = Pocketrb::Cron::Schedule.new(kind: :cron, expr: cron, tz: tz)
120
+ add_job(name: name, schedule: schedule, message: message, deliver: deliver, channel: channel, to: to)
121
+ end
122
+
123
+ # Add a one-time job
124
+ # @param name [String] Job name
125
+ # @param at [Time, Integer] Execution time (Time or Unix timestamp)
126
+ # @param message [String] Message to process
127
+ # @param deliver [Boolean] Deliver to channel
128
+ # @param channel [String, nil] Target channel
129
+ # @param to [String, nil] Target chat ID
130
+ # @return [Job]
131
+ def add_one_time_job(name:, at:, message:, deliver: false, channel: nil, to: nil)
132
+ at_ms = at.is_a?(Time) ? (at.to_f * 1000).to_i : at * 1000
133
+ schedule = Pocketrb::Cron::Schedule.new(kind: :at, at_ms: at_ms)
134
+ add_job(name: name, schedule: schedule, message: message, deliver: deliver, channel: channel, to: to)
135
+ end
136
+
137
+ # Remove a job
138
+ # @param job_id [String] Job ID
139
+ # @return [Boolean] Whether job was removed
140
+ def remove_job(job_id)
141
+ removed = @mutex.synchronize do
142
+ @jobs.delete(job_id)
143
+ end
144
+
145
+ if removed
146
+ save_jobs!
147
+ arm_timer!
148
+ Pocketrb.logger.info("Removed cron job #{job_id}")
149
+ true
150
+ else
151
+ false
152
+ end
153
+ end
154
+
155
+ # Enable or disable a job
156
+ # @param job_id [String] Job ID
157
+ # @param enabled [Boolean] Enabled state
158
+ # @return [Job, nil] Updated job
159
+ def enable_job(job_id, enabled: true)
160
+ job = @mutex.synchronize do
161
+ current = @jobs[job_id]
162
+ return nil unless current
163
+
164
+ updated = Job.new(
165
+ id: current.id,
166
+ name: current.name,
167
+ enabled: enabled,
168
+ schedule: current.schedule,
169
+ payload: current.payload,
170
+ state: current.state,
171
+ created_at_ms: current.created_at_ms,
172
+ updated_at_ms: (Time.now.to_f * 1000).to_i,
173
+ delete_after_run: current.delete_after_run
174
+ )
175
+
176
+ @jobs[job_id] = updated
177
+ updated
178
+ end
179
+
180
+ save_jobs!
181
+ arm_timer!
182
+ job
183
+ end
184
+
185
+ # Run a job manually (force execution)
186
+ # @param job_id [String] Job ID
187
+ # @param force [Boolean] Run even if disabled
188
+ # @return [Boolean] Whether job was executed
189
+ def run_job(job_id, force: false)
190
+ job = @mutex.synchronize { @jobs[job_id] }
191
+ return false unless job
192
+ return false unless force || job.enabled
193
+
194
+ execute_job(job)
195
+ true
196
+ end
197
+
198
+ # Get a job by ID
199
+ # @param job_id [String] Job ID
200
+ # @return [Job, nil]
201
+ def get_job(job_id)
202
+ @mutex.synchronize { @jobs[job_id] }
203
+ end
204
+
205
+ private
206
+
207
+ def ensure_store_dir!
208
+ FileUtils.mkdir_p(@store_path.dirname)
209
+ end
210
+
211
+ def load_jobs!
212
+ return unless @store_path.exist?
213
+
214
+ data = JSON.parse(File.read(@store_path))
215
+ @jobs = data.transform_values { |h| Job.from_h(h) }
216
+ Pocketrb.logger.debug("Loaded #{@jobs.size} cron jobs")
217
+ rescue JSON::ParserError => e
218
+ Pocketrb.logger.warn("Failed to parse cron jobs: #{e.message}")
219
+ @jobs = {}
220
+ end
221
+
222
+ def save_jobs!
223
+ @mutex.synchronize do
224
+ data = @jobs.transform_values(&:to_h)
225
+ File.write(@store_path, JSON.pretty_generate(data))
226
+ end
227
+ end
228
+
229
+ def compute_next_runs!
230
+ now_ms = (Time.now.to_f * 1000).to_i
231
+
232
+ @mutex.synchronize do
233
+ @jobs.each do |id, job|
234
+ next unless job.enabled
235
+ next if job.state.next_run_at_ms && job.state.next_run_at_ms > now_ms
236
+
237
+ @jobs[id] = compute_next_run(job)
238
+ end
239
+ end
240
+
241
+ save_jobs!
242
+ end
243
+
244
+ def compute_next_run(job)
245
+ now_ms = (Time.now.to_f * 1000).to_i
246
+ next_ms = case job.schedule.kind
247
+ when :at
248
+ job.schedule.at_ms
249
+ when :every
250
+ base = job.state.last_run_at_ms || now_ms
251
+ base + job.schedule.every_ms
252
+ when :cron
253
+ compute_cron_next(job.schedule.expr, job.schedule.tz)
254
+ end
255
+
256
+ new_state = JobState.new(
257
+ next_run_at_ms: next_ms,
258
+ last_run_at_ms: job.state.last_run_at_ms,
259
+ last_status: job.state.last_status,
260
+ last_error: job.state.last_error
261
+ )
262
+
263
+ Job.new(
264
+ id: job.id,
265
+ name: job.name,
266
+ enabled: job.enabled,
267
+ schedule: job.schedule,
268
+ payload: job.payload,
269
+ state: new_state,
270
+ created_at_ms: job.created_at_ms,
271
+ updated_at_ms: job.updated_at_ms,
272
+ delete_after_run: job.delete_after_run
273
+ )
274
+ end
275
+
276
+ def compute_cron_next(expr, tz = nil)
277
+ # Try to use fugit if available
278
+
279
+ require "fugit"
280
+ cron = Fugit.parse_cron(expr)
281
+ return nil unless cron
282
+
283
+ now = Time.now
284
+ now = now.in_time_zone(tz) if tz && now.respond_to?(:in_time_zone)
285
+
286
+ next_time = cron.next_time(now)
287
+ (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
+ end
293
+
294
+ def arm_timer!
295
+ return unless @running
296
+
297
+ @timer_thread&.kill
298
+ @timer_thread = nil
299
+
300
+ # Find earliest next run
301
+ earliest = @mutex.synchronize do
302
+ @jobs.values
303
+ .select(&:enabled)
304
+ .filter_map { |j| j.state.next_run_at_ms }
305
+ .min
306
+ end
307
+
308
+ return unless earliest
309
+
310
+ now_ms = (Time.now.to_f * 1000).to_i
311
+ delay_ms = [earliest - now_ms, 0].max
312
+ delay_s = delay_ms / 1000.0
313
+
314
+ @timer_thread = Thread.new do
315
+ sleep delay_s
316
+ tick if @running
317
+ end
318
+
319
+ Pocketrb.logger.debug("Cron timer armed for #{delay_s}s")
320
+ end
321
+
322
+ def tick
323
+ now_ms = (Time.now.to_f * 1000).to_i
324
+ due_jobs = @mutex.synchronize do
325
+ @jobs.values.select { |j| j.due?(now_ms) }
326
+ end
327
+
328
+ due_jobs.each do |job|
329
+ execute_job(job)
330
+ end
331
+
332
+ arm_timer!
333
+ end
334
+
335
+ def execute_job(job)
336
+ Pocketrb.logger.info("Executing cron job '#{job.name}' (ID: #{job.id})")
337
+
338
+ begin
339
+ @on_job.call(job)
340
+
341
+ update_job_state(job.id, status: "success")
342
+
343
+ if job.delete_after_run
344
+ remove_job(job.id)
345
+ else
346
+ update_job_next_run(job.id)
347
+ end
348
+ rescue StandardError => e
349
+ Pocketrb.logger.error("Cron job #{job.id} failed: #{e.message}")
350
+ update_job_state(job.id, status: "failed", error: e.message)
351
+ update_job_next_run(job.id) unless job.delete_after_run
352
+ end
353
+ end
354
+
355
+ def update_job_state(job_id, status:, error: nil)
356
+ @mutex.synchronize do
357
+ job = @jobs[job_id]
358
+ return unless job
359
+
360
+ new_state = JobState.new(
361
+ next_run_at_ms: job.state.next_run_at_ms,
362
+ last_run_at_ms: (Time.now.to_f * 1000).to_i,
363
+ last_status: status,
364
+ last_error: error
365
+ )
366
+
367
+ @jobs[job_id] = Job.new(
368
+ id: job.id,
369
+ name: job.name,
370
+ enabled: job.enabled,
371
+ schedule: job.schedule,
372
+ payload: job.payload,
373
+ state: new_state,
374
+ created_at_ms: job.created_at_ms,
375
+ updated_at_ms: (Time.now.to_f * 1000).to_i,
376
+ delete_after_run: job.delete_after_run
377
+ )
378
+ end
379
+
380
+ save_jobs!
381
+ end
382
+
383
+ def update_job_next_run(job_id)
384
+ @mutex.synchronize do
385
+ job = @jobs[job_id]
386
+ return unless job
387
+
388
+ @jobs[job_id] = compute_next_run(job)
389
+ end
390
+
391
+ save_jobs!
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Heartbeat
5
+ # Periodic wake-up service that checks HEARTBEAT.md for pending tasks
6
+ class Service
7
+ DEFAULT_INTERVAL = 30 * 60 # 30 minutes
8
+ HEARTBEAT_FILE = "HEARTBEAT.md"
9
+
10
+ HEARTBEAT_PROMPT = <<~PROMPT
11
+ Read HEARTBEAT.md in your workspace. Follow any instructions or tasks listed there.
12
+ If nothing needs attention, reply with: HEARTBEAT_OK
13
+
14
+ Guidelines:
15
+ - Complete any tasks listed in HEARTBEAT.md
16
+ - Mark completed tasks as done (check the checkbox)
17
+ - If a task cannot be completed, explain why
18
+ - Only reply HEARTBEAT_OK if the file is empty or contains no actionable items
19
+ PROMPT
20
+
21
+ attr_reader :interval, :enabled, :last_run_at
22
+
23
+ def initialize(workspace:, on_heartbeat:, interval: DEFAULT_INTERVAL, enabled: true)
24
+ @workspace = Pathname.new(workspace)
25
+ @on_heartbeat = on_heartbeat
26
+ @interval = interval
27
+ @enabled = enabled
28
+ @running = false
29
+ @timer_thread = nil
30
+ @last_run_at = nil
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Start the heartbeat service
35
+ def start
36
+ return unless @enabled
37
+ return if @running
38
+
39
+ @running = true
40
+ arm_timer!
41
+
42
+ Pocketrb.logger.info("Heartbeat service started (interval: #{@interval}s)")
43
+ end
44
+
45
+ # Stop the heartbeat service
46
+ def stop
47
+ @running = false
48
+ @timer_thread&.kill
49
+ @timer_thread = nil
50
+
51
+ Pocketrb.logger.info("Heartbeat service stopped")
52
+ end
53
+
54
+ # Update the interval
55
+ # @param seconds [Integer] New interval in seconds
56
+ def set_interval(seconds)
57
+ @mutex.synchronize { @interval = seconds }
58
+ arm_timer! if @running
59
+ end
60
+
61
+ # Enable or disable the service
62
+ # @param value [Boolean] Enabled state
63
+ def enabled=(value)
64
+ was_enabled = @enabled
65
+ @enabled = value
66
+
67
+ if @enabled && !was_enabled && @running
68
+ arm_timer!
69
+ elsif !@enabled && was_enabled
70
+ @timer_thread&.kill
71
+ @timer_thread = nil
72
+ end
73
+ end
74
+
75
+ # Force a heartbeat check now
76
+ # @return [String, nil] Response from agent or nil if skipped
77
+ def tick
78
+ @mutex.synchronize { @last_run_at = Time.now }
79
+
80
+ content = read_heartbeat_file
81
+ if empty_heartbeat?(content)
82
+ Pocketrb.logger.debug("Heartbeat: no actionable content, skipping")
83
+ return nil
84
+ end
85
+
86
+ Pocketrb.logger.info("Heartbeat: processing HEARTBEAT.md")
87
+
88
+ begin
89
+ response = @on_heartbeat.call(HEARTBEAT_PROMPT)
90
+
91
+ if response&.include?("HEARTBEAT_OK")
92
+ Pocketrb.logger.debug("Heartbeat: agent reports OK")
93
+ else
94
+ Pocketrb.logger.info("Heartbeat: agent processed tasks")
95
+ end
96
+
97
+ response
98
+ rescue StandardError => e
99
+ Pocketrb.logger.error("Heartbeat failed: #{e.message}")
100
+ nil
101
+ end
102
+ end
103
+
104
+ # Get status information
105
+ # @return [Hash] Status info
106
+ def status
107
+ {
108
+ enabled: @enabled,
109
+ running: @running,
110
+ interval: @interval,
111
+ last_run_at: @last_run_at,
112
+ heartbeat_file: heartbeat_file.to_s,
113
+ file_exists: heartbeat_file.exist?,
114
+ next_run_in: next_run_in
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ def arm_timer!
121
+ return unless @running && @enabled
122
+
123
+ @timer_thread&.kill
124
+ @timer_thread = nil
125
+
126
+ @timer_thread = Thread.new do
127
+ loop do
128
+ sleep @interval
129
+ tick if @running && @enabled
130
+ end
131
+ end
132
+ end
133
+
134
+ def heartbeat_file
135
+ @workspace.join(HEARTBEAT_FILE)
136
+ end
137
+
138
+ def read_heartbeat_file
139
+ return nil unless heartbeat_file.exist?
140
+
141
+ heartbeat_file.read
142
+ rescue StandardError => e
143
+ Pocketrb.logger.warn("Failed to read HEARTBEAT.md: #{e.message}")
144
+ nil
145
+ end
146
+
147
+ def empty_heartbeat?(content)
148
+ return true if content.nil? || content.strip.empty?
149
+
150
+ # Skip if only contains:
151
+ # - Empty lines
152
+ # - Headers (# ...)
153
+ # - HTML comments (<!-- ... -->)
154
+ # - Unchecked checkboxes that are likely template (- [ ])
155
+ # - Checked checkboxes (- [x])
156
+ content.lines.all? do |line|
157
+ stripped = line.strip
158
+ stripped.empty? ||
159
+ stripped.start_with?("#") ||
160
+ stripped.start_with?("<!--") ||
161
+ stripped.end_with?("-->") ||
162
+ stripped.match?(/^- \[[xX ]\]\s*$/) # Empty checkbox
163
+ end
164
+ end
165
+
166
+ def next_run_in
167
+ return nil unless @running && @enabled && @last_run_at
168
+
169
+ elapsed = Time.now - @last_run_at
170
+ remaining = @interval - elapsed
171
+ remaining.positive? ? remaining.to_i : 0
172
+ end
173
+ end
174
+ end
175
+ end