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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Pocketrb
6
+ module Skills
7
+ # Loads and manages skills from SKILL.md files
8
+ class Loader
9
+ SKILL_FILE = "SKILL.md"
10
+ FRONTMATTER_REGEX = /\A---\s*\n(.+?)\n---\s*\n/m
11
+
12
+ attr_reader :workspace, :builtin_dir
13
+
14
+ def initialize(workspace:, builtin_dir: nil)
15
+ @workspace = Pathname.new(workspace)
16
+ @builtin_dir = if builtin_dir
17
+ Pathname.new(builtin_dir)
18
+ else
19
+ # Default to gem's builtin skills directory
20
+ Pocketrb.root.join("lib", "pocketrb", "skills", "builtin")
21
+ end
22
+ @skills_cache = {}
23
+ end
24
+
25
+ # List all available skills
26
+ # @param filter_unavailable [Boolean] Exclude unavailable skills
27
+ # @return [Array<Skill>]
28
+ def list_skills(filter_unavailable: true)
29
+ skills = []
30
+
31
+ # Load builtin skills
32
+ skills.concat(load_from_directory(@builtin_dir)) if @builtin_dir&.exist?
33
+
34
+ # Load workspace skills
35
+ skills_dir = @workspace.join("skills")
36
+ skills.concat(load_from_directory(skills_dir)) if skills_dir.exist?
37
+
38
+ # Filter unavailable if requested
39
+ skills = skills.select(&:available?) if filter_unavailable
40
+
41
+ skills
42
+ end
43
+
44
+ # Load a specific skill by name
45
+ # @param name [String] Skill name
46
+ # @return [Skill|nil]
47
+ def load_skill(name)
48
+ @skills_cache[name] ||= find_and_load_skill(name)
49
+ end
50
+
51
+ # Get skills that should always be included
52
+ # @return [Array<Skill>]
53
+ def get_always_skills
54
+ list_skills.select(&:always?)
55
+ end
56
+
57
+ # Get skills triggered by a message
58
+ # @param text [String] Message text
59
+ # @return [Array<Skill>]
60
+ def get_triggered_skills(text)
61
+ list_skills.select { |s| s.matches?(text) }
62
+ end
63
+
64
+ # Build XML summary of all skills for context
65
+ # @return [String]
66
+ def build_skills_summary
67
+ skills = list_skills
68
+
69
+ return "" if skills.empty?
70
+
71
+ <<~XML
72
+ <available-skills>
73
+ #{skills.map(&:to_summary).join("\n")}
74
+ </available-skills>
75
+ XML
76
+ end
77
+
78
+ # Build full prompt content for specific skills
79
+ # @param skill_names [Array<String>] Skills to include
80
+ # @return [String]
81
+ def build_skills_prompt(skill_names)
82
+ skills = skill_names.filter_map { |name| load_skill(name) }
83
+ skills.map(&:to_prompt).join("\n\n")
84
+ end
85
+
86
+ # Clear the skills cache
87
+ def clear_cache!
88
+ @skills_cache.clear
89
+ end
90
+
91
+ private
92
+
93
+ def load_from_directory(dir)
94
+ return [] unless dir.exist?
95
+
96
+ skills = []
97
+
98
+ # Direct SKILL.md in skills directory
99
+ skill_file = dir.join(SKILL_FILE)
100
+ skills << parse_skill_file(skill_file) if skill_file.exist?
101
+
102
+ # Subdirectories with SKILL.md
103
+ Dir.glob(dir.join("*", SKILL_FILE)).each do |path|
104
+ skill = parse_skill_file(Pathname.new(path))
105
+ skills << skill if skill
106
+ end
107
+
108
+ skills.compact
109
+ end
110
+
111
+ def find_and_load_skill(name)
112
+ # Check workspace skills
113
+ skill_path = @workspace.join("skills", name, SKILL_FILE)
114
+ return parse_skill_file(skill_path) if skill_path.exist?
115
+
116
+ # Check builtin skills
117
+ if @builtin_dir
118
+ builtin_path = @builtin_dir.join(name, SKILL_FILE)
119
+ return parse_skill_file(builtin_path) if builtin_path.exist?
120
+ end
121
+
122
+ nil
123
+ end
124
+
125
+ def parse_skill_file(path)
126
+ return nil unless path.exist?
127
+
128
+ content = File.read(path)
129
+ metadata, body = extract_frontmatter(content)
130
+
131
+ name = metadata["name"] || path.dirname.basename.to_s
132
+ description = metadata["description"] || extract_description(body)
133
+
134
+ Skill.new(
135
+ name: name,
136
+ description: description,
137
+ content: body,
138
+ path: path,
139
+ metadata: metadata
140
+ )
141
+ rescue StandardError => e
142
+ Pocketrb.logger.error("Failed to parse skill at #{path}: #{e.message}")
143
+ nil
144
+ end
145
+
146
+ def extract_frontmatter(content)
147
+ if content.match?(FRONTMATTER_REGEX)
148
+ match = content.match(FRONTMATTER_REGEX)
149
+ metadata = YAML.safe_load(match[1]) || {}
150
+ body = content.sub(FRONTMATTER_REGEX, "").strip
151
+ [metadata, body]
152
+ else
153
+ [{}, content.strip]
154
+ end
155
+ end
156
+
157
+ def extract_description(body)
158
+ # Extract first line or paragraph as description
159
+ first_line = body.lines.first&.strip
160
+ first_line&.gsub(/^#+\s*/, "") || "No description"
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Skills
5
+ # Tool for modifying existing skills
6
+ class ModifyTool < Tools::Base
7
+ def name
8
+ "skill_modify"
9
+ end
10
+
11
+ def description
12
+ "Modify an existing skill. Can update the content, description, triggers, or other properties."
13
+ end
14
+
15
+ def parameters
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ skill_name: {
20
+ type: "string",
21
+ description: "Name of the skill to modify"
22
+ },
23
+ new_content: {
24
+ type: "string",
25
+ description: "New content for the skill (replaces existing)"
26
+ },
27
+ append_content: {
28
+ type: "string",
29
+ description: "Content to append to the skill"
30
+ },
31
+ new_description: {
32
+ type: "string",
33
+ description: "New description for the skill"
34
+ },
35
+ add_triggers: {
36
+ type: "array",
37
+ items: { type: "string" },
38
+ description: "Triggers to add"
39
+ },
40
+ remove_triggers: {
41
+ type: "array",
42
+ items: { type: "string" },
43
+ description: "Triggers to remove"
44
+ },
45
+ set_always: {
46
+ type: "boolean",
47
+ description: "Set whether skill is always loaded"
48
+ }
49
+ },
50
+ required: ["skill_name"]
51
+ }
52
+ end
53
+
54
+ def execute(
55
+ skill_name:,
56
+ new_content: nil,
57
+ append_content: nil,
58
+ new_description: nil,
59
+ add_triggers: nil,
60
+ remove_triggers: nil,
61
+ set_always: nil
62
+ )
63
+ skill_file = workspace.join("skills", skill_name, "SKILL.md")
64
+
65
+ return error("Skill '#{skill_name}' not found") unless skill_file.exist?
66
+
67
+ # Parse existing skill
68
+ content = File.read(skill_file)
69
+ metadata, body = parse_frontmatter(content)
70
+
71
+ # Apply modifications
72
+ metadata["description"] = new_description if new_description
73
+
74
+ if add_triggers
75
+ metadata["triggers"] ||= []
76
+ metadata["triggers"] = (metadata["triggers"] + add_triggers).uniq
77
+ end
78
+
79
+ if remove_triggers
80
+ metadata["triggers"] ||= []
81
+ metadata["triggers"] -= remove_triggers
82
+ metadata.delete("triggers") if metadata["triggers"].empty?
83
+ end
84
+
85
+ metadata["always"] = set_always unless set_always.nil?
86
+
87
+ if new_content
88
+ body = new_content
89
+ elsif append_content
90
+ body = "#{body}\n\n#{append_content}"
91
+ end
92
+
93
+ # Write updated skill
94
+ updated_content = build_skill_file(metadata, body)
95
+ File.write(skill_file, updated_content)
96
+
97
+ success("Modified skill '#{skill_name}'")
98
+ end
99
+
100
+ private
101
+
102
+ def parse_frontmatter(content)
103
+ if content.match?(/\A---\s*\n(.+?)\n---\s*\n/m)
104
+ match = content.match(/\A---\s*\n(.+?)\n---\s*\n/m)
105
+ metadata = YAML.safe_load(match[1]) || {}
106
+ body = content.sub(/\A---\s*\n.+?\n---\s*\n/m, "").strip
107
+ [metadata, body]
108
+ else
109
+ [{}, content.strip]
110
+ end
111
+ end
112
+
113
+ def build_skill_file(metadata, body)
114
+ <<~MD
115
+ ---
116
+ #{metadata.to_yaml.lines[1..].join}---
117
+
118
+ #{body}
119
+ MD
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Skills
5
+ # Represents a loaded skill from SKILL.md
6
+ class Skill
7
+ attr_reader :name, :description, :content, :path, :metadata
8
+
9
+ def initialize(name:, description:, content:, path:, metadata: {})
10
+ @name = name
11
+ @description = description
12
+ @content = content
13
+ @path = path
14
+ @metadata = metadata
15
+ end
16
+
17
+ # Check if skill should always be included in context
18
+ def always?
19
+ metadata[:always] == true || metadata["always"] == true
20
+ end
21
+
22
+ # Check if skill is available (meets requirements)
23
+ def available?
24
+ requires = metadata[:requires] || metadata["requires"]
25
+ return true unless requires
26
+
27
+ Array(requires).all? { |req| check_requirement(req) }
28
+ end
29
+
30
+ # Get skill triggers/keywords
31
+ def triggers
32
+ triggers = metadata[:triggers] || metadata["triggers"] || []
33
+ Array(triggers)
34
+ end
35
+
36
+ # Check if a message should trigger this skill
37
+ def matches?(text)
38
+ return false if triggers.empty?
39
+
40
+ text_lower = text.downcase
41
+ triggers.any? { |t| text_lower.include?(t.downcase) }
42
+ end
43
+
44
+ # Get full prompt content for this skill
45
+ def to_prompt
46
+ <<~PROMPT
47
+ <skill name="#{name}">
48
+ #{content}
49
+ </skill>
50
+ PROMPT
51
+ end
52
+
53
+ # Summary for skills list
54
+ def to_summary
55
+ "- #{name}: #{description}"
56
+ end
57
+
58
+ private
59
+
60
+ def check_requirement(req)
61
+ case req
62
+ when /^env:(.+)/
63
+ !ENV[::Regexp.last_match(1)].nil?
64
+ when /^file:(.+)/
65
+ File.exist?(::Regexp.last_match(1))
66
+ when /^tool:(.+)/
67
+ # Would need tool registry context
68
+ true
69
+ else
70
+ true
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Pocketrb
6
+ module Tools
7
+ # Manages background jobs for long-running commands
8
+ class BackgroundJobManager
9
+ MAX_JOBS = 50
10
+ MAX_COMPLETED_JOBS = 20
11
+ MAX_JOB_AGE = 24 * 60 * 60 # 24 hours
12
+
13
+ # Commands that should auto-run in background
14
+ LONG_RUNNING_PATTERNS = [
15
+ /^apt(-get)?\s+(install|update|upgrade|dist-upgrade|autoremove)/i,
16
+ /^(npm|yarn|pnpm)\s+(install|ci|update|build|run\s+build)/i,
17
+ /^pip3?\s+install/i,
18
+ /^gem\s+install/i,
19
+ /^bundle\s+(install|update)/i,
20
+ /^cargo\s+(build|install|run)/i,
21
+ /^make\b/i,
22
+ /^docker\s+(build|pull|push|run)/i,
23
+ /^wget\s/i,
24
+ /^git\s+(clone|pull|fetch)/i,
25
+ /sleep\s+\d{2,}/, # sleep 10+ seconds
26
+ /^python3?\s+.*\.py/i,
27
+ /^ruby\s+.*\.rb/i,
28
+ /^node\s+.*\.js/i,
29
+ /nohup\s/i,
30
+ /&\s*$/ # ends with &
31
+ ].freeze
32
+
33
+ attr_reader :jobs_dir
34
+
35
+ def initialize(workspace:)
36
+ @workspace = Pathname.new(workspace)
37
+ @jobs_dir = resolve_jobs_dir
38
+ @available = setup_jobs_dir!
39
+ cleanup_stale_jobs if @available
40
+ end
41
+
42
+ # Check if job manager is available (directory accessible)
43
+ def available?
44
+ @available
45
+ end
46
+
47
+ private
48
+
49
+ def resolve_jobs_dir
50
+ # Don't create .pocketrb in root filesystem
51
+ if @workspace.to_s == "/" || !@workspace.writable?
52
+ Pathname.new(Dir.home).join(".pocketrb", "jobs")
53
+ else
54
+ @workspace.join(".pocketrb", "jobs")
55
+ end
56
+ end
57
+
58
+ def setup_jobs_dir!
59
+ FileUtils.mkdir_p(@jobs_dir)
60
+ true
61
+ rescue Errno::EACCES, Errno::EROFS => e
62
+ Pocketrb.logger.warn("Cannot create jobs directory #{@jobs_dir}: #{e.message}")
63
+ false
64
+ end
65
+
66
+ public
67
+
68
+ # Check if command should auto-run in background
69
+ def long_running?(command)
70
+ return false if command.nil? || command.empty?
71
+
72
+ LONG_RUNNING_PATTERNS.any? { |pattern| command.match?(pattern) }
73
+ end
74
+
75
+ # Start a background job
76
+ def start(command:, working_dir: nil, name: nil)
77
+ cleanup_stale_jobs
78
+
79
+ job_id = "job_#{Time.now.to_i}_#{rand(10_000)}"
80
+ job_dir = @jobs_dir.join(job_id)
81
+ FileUtils.mkdir_p(job_dir)
82
+
83
+ log_file = job_dir.join("output.log")
84
+ pid_file = job_dir.join("pid")
85
+ cmd_file = job_dir.join("command")
86
+ name_file = job_dir.join("name")
87
+ status_file = job_dir.join("status")
88
+
89
+ File.write(cmd_file, command)
90
+ File.write(name_file, name || command[0..50])
91
+ File.write(status_file, "running")
92
+
93
+ work_dir = working_dir || @workspace.to_s
94
+
95
+ pid = Process.spawn(
96
+ "bash", "-lc", command,
97
+ chdir: work_dir,
98
+ out: [log_file.to_s, "a"],
99
+ err: [log_file.to_s, "a"],
100
+ pgroup: true
101
+ )
102
+ Process.detach(pid)
103
+
104
+ File.write(pid_file, pid.to_s)
105
+
106
+ {
107
+ job_id: job_id,
108
+ pid: pid,
109
+ log_file: log_file.to_s
110
+ }
111
+ end
112
+
113
+ # List all jobs
114
+ def list
115
+ return [] unless @jobs_dir.exist?
116
+
117
+ Dir.glob(@jobs_dir.join("job_*")).filter_map do |job_dir|
118
+ job_id = File.basename(job_dir)
119
+ job_dir_path = Pathname.new(job_dir)
120
+
121
+ pid_file = job_dir_path.join("pid")
122
+ name_file = job_dir_path.join("name")
123
+
124
+ next unless pid_file.exist?
125
+
126
+ pid = File.read(pid_file).strip.to_i
127
+ name = name_file.exist? ? File.read(name_file).strip : "unknown"
128
+ running = process_running?(pid)
129
+
130
+ {
131
+ job_id: job_id,
132
+ pid: pid,
133
+ name: name,
134
+ running: running,
135
+ created_at: File.mtime(job_dir)
136
+ }
137
+ end.sort_by { |j| j[:created_at] }.reverse
138
+ end
139
+
140
+ # Get job status and output
141
+ def status(job_id)
142
+ job_dir = @jobs_dir.join(job_id)
143
+ return nil unless job_dir.exist?
144
+
145
+ pid_file = job_dir.join("pid")
146
+ log_file = job_dir.join("output.log")
147
+ cmd_file = job_dir.join("command")
148
+ name_file = job_dir.join("name")
149
+
150
+ pid = pid_file.exist? ? File.read(pid_file).strip.to_i : nil
151
+ running = pid ? process_running?(pid) : false
152
+ output = log_file.exist? ? truncate_output(File.read(log_file)) : ""
153
+ command = cmd_file.exist? ? File.read(cmd_file) : "unknown"
154
+ name = name_file.exist? ? File.read(name_file).strip : "unknown"
155
+
156
+ {
157
+ job_id: job_id,
158
+ pid: pid,
159
+ name: name,
160
+ running: running,
161
+ output: output,
162
+ command: command
163
+ }
164
+ end
165
+
166
+ # Get job output (tail)
167
+ def output(job_id, lines: 50)
168
+ log_file = @jobs_dir.join(job_id, "output.log")
169
+ return nil unless log_file.exist?
170
+
171
+ `tail -n #{lines} #{log_file}`
172
+ end
173
+
174
+ # Kill a job
175
+ def kill(job_id)
176
+ pid_file = @jobs_dir.join(job_id, "pid")
177
+ return false unless pid_file.exist?
178
+
179
+ pid = File.read(pid_file).strip.to_i
180
+ return false unless process_running?(pid)
181
+
182
+ begin
183
+ Process.kill("-TERM", pid)
184
+ sleep 0.5
185
+ Process.kill("-KILL", pid) if process_running?(pid)
186
+
187
+ status_file = @jobs_dir.join(job_id, "status")
188
+ File.write(status_file, "killed")
189
+ true
190
+ rescue Errno::ESRCH
191
+ false
192
+ end
193
+ end
194
+
195
+ # Clean up old jobs
196
+ def cleanup_stale_jobs
197
+ return 0 unless @jobs_dir.exist?
198
+
199
+ jobs = Dir.glob(@jobs_dir.join("job_*")).filter_map do |job_dir|
200
+ pid_file = File.join(job_dir, "pid")
201
+ next nil unless File.exist?(pid_file)
202
+
203
+ pid = begin
204
+ File.read(pid_file).strip.to_i
205
+ rescue StandardError
206
+ 0
207
+ end
208
+ running = pid.positive? && process_running?(pid)
209
+ created = begin
210
+ File.mtime(job_dir)
211
+ rescue StandardError
212
+ Time.now
213
+ end
214
+
215
+ { dir: job_dir, running: running, created: created }
216
+ end
217
+
218
+ completed_jobs = jobs.reject { |j| j[:running] }.sort_by { |j| j[:created] }
219
+ removed = 0
220
+
221
+ # Remove jobs older than MAX_JOB_AGE
222
+ cutoff = Time.now - MAX_JOB_AGE
223
+ completed_jobs.each do |job|
224
+ if job[:created] < cutoff
225
+ FileUtils.rm_rf(job[:dir])
226
+ removed += 1
227
+ end
228
+ end
229
+
230
+ completed_jobs.reject! { |j| j[:created] < cutoff }
231
+
232
+ # Keep only MAX_COMPLETED_JOBS
233
+ while completed_jobs.length > MAX_COMPLETED_JOBS
234
+ oldest = completed_jobs.shift
235
+ FileUtils.rm_rf(oldest[:dir])
236
+ removed += 1
237
+ end
238
+
239
+ removed
240
+ rescue StandardError => e
241
+ Pocketrb.logger.warn("Job cleanup error: #{e.message}")
242
+ 0
243
+ end
244
+
245
+ private
246
+
247
+ def process_running?(pid)
248
+ Process.kill(0, pid)
249
+ true
250
+ rescue Errno::ESRCH, Errno::EPERM
251
+ false
252
+ end
253
+
254
+ def truncate_output(output, max_size: 100_000)
255
+ return output if output.length <= max_size
256
+
257
+ "#{output[0...max_size]}\n... (truncated, #{output.length - max_size} more characters)"
258
+ end
259
+ end
260
+ end
261
+ end