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,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Pocketrb
7
+ # Simple memory system for facts and recent events
8
+ # Inspired by nanobot - no vector DB, just JSON + keyword matching
9
+ class Memory
10
+ # Maximum number of recent events to keep
11
+ MAX_RECENT = 50
12
+
13
+ # Initialize memory system
14
+ # @param workspace [String, Pathname] workspace directory
15
+ def initialize(workspace:)
16
+ @workspace = Pathname.new(workspace)
17
+ @memory_dir = @workspace.join("memory")
18
+ @memory_dir.mkpath
19
+
20
+ @facts_file = @memory_dir.join("facts.json")
21
+ @recent_file = @memory_dir.join("recent.json")
22
+
23
+ @facts = load_json(@facts_file) || { "learned" => {}, "user" => {}, "preferences" => {}, "context" => {} }
24
+ @recent = load_json(@recent_file) || []
25
+ end
26
+
27
+ # === Long-term facts ===
28
+
29
+ # Remember something learned
30
+ # @param topic [String] topic name
31
+ # @param info [String] information about the topic
32
+ # @return [String] confirmation message
33
+ def remember_learned(topic, info)
34
+ @facts["learned"][topic] ||= []
35
+ @facts["learned"][topic] << {
36
+ "info" => info,
37
+ "learned_at" => Time.now.utc.iso8601
38
+ }
39
+ save_facts
40
+ "Remembered: learned about #{topic}"
41
+ end
42
+
43
+ # Remember user preference/info
44
+ # @param key [String] user attribute key
45
+ # @param value [String] user attribute value
46
+ # @return [String] confirmation message
47
+ def remember_user(key, value)
48
+ @facts["user"][key] = {
49
+ "value" => value,
50
+ "updated_at" => Time.now.utc.iso8601
51
+ }
52
+ save_facts
53
+ "Remembered: user's #{key} is #{value}"
54
+ end
55
+
56
+ # Remember preference
57
+ # @param key [String] preference key
58
+ # @param value [String] preference value
59
+ # @return [String] confirmation message
60
+ def remember_preference(key, value)
61
+ @facts["preferences"][key] = {
62
+ "value" => value,
63
+ "updated_at" => Time.now.utc.iso8601
64
+ }
65
+ save_facts
66
+ "Remembered preference: #{key} = #{value}"
67
+ end
68
+
69
+ # Remember general context
70
+ # @param key [String] context key
71
+ # @param value [String] context value
72
+ # @return [String] confirmation message
73
+ def remember_context(key, value)
74
+ @facts["context"][key] = {
75
+ "value" => value,
76
+ "updated_at" => Time.now.utc.iso8601
77
+ }
78
+ save_facts
79
+ "Remembered: #{key}"
80
+ end
81
+
82
+ # Recall learned facts about a topic
83
+ # @param topic [String] topic name
84
+ # @return [Array, nil] learned facts or nil
85
+ def recall_learned(topic)
86
+ @facts["learned"][topic]
87
+ end
88
+
89
+ # Recall user info
90
+ # @param key [String, nil] specific key to recall, or nil for all
91
+ # @return [Hash, String, nil] user info
92
+ def recall_user(key = nil)
93
+ key ? @facts["user"][key] : @facts["user"]
94
+ end
95
+
96
+ # Recall preferences
97
+ # @param key [String, nil] specific key to recall, or nil for all
98
+ # @return [Hash, String, nil] preferences
99
+ def recall_preferences(key = nil)
100
+ key ? @facts["preferences"][key] : @facts["preferences"]
101
+ end
102
+
103
+ # === Recent events ===
104
+
105
+ # Add a recent event
106
+ # @param description [String] event description
107
+ # @param category [String] event category (default: "general")
108
+ # @return [void]
109
+ def add_event(description, category: "general")
110
+ @recent.unshift({
111
+ "category" => category,
112
+ "description" => description,
113
+ "timestamp" => Time.now.utc.iso8601
114
+ })
115
+ @recent = @recent.first(MAX_RECENT)
116
+ save_recent
117
+ end
118
+
119
+ # Get recent events
120
+ # @param count [Integer] number of events to return (default: 10)
121
+ # @return [Array<Hash>] recent events
122
+ def recent_events(count = 10)
123
+ @recent.first(count)
124
+ end
125
+
126
+ # === Context building for LLM ===
127
+
128
+ # Get relevant memories for a message
129
+ # @param message [String] message to find relevant context for
130
+ # @param max_facts [Integer] maximum number of facts to include (default: 10)
131
+ # @return [String] formatted context string
132
+ def relevant_context(message, max_facts: 10)
133
+ message_lower = message.downcase
134
+ parts = []
135
+
136
+ # User info
137
+ if @facts["user"].any?
138
+ user_info = @facts["user"].map { |k, v| "#{k}: #{v["value"]}" }.join(", ")
139
+ parts << "USER: #{user_info}"
140
+ end
141
+
142
+ # Preferences
143
+ if @facts["preferences"].any?
144
+ prefs = @facts["preferences"].map { |k, v| "#{k}: #{v["value"]}" }.join(", ")
145
+ parts << "PREFERENCES: #{prefs}"
146
+ end
147
+
148
+ # Learned facts matching keywords
149
+ matched_facts = 0
150
+ @facts["learned"].each do |topic, entries|
151
+ break if matched_facts >= max_facts
152
+
153
+ next unless message_lower.include?(topic.downcase)
154
+
155
+ info = begin
156
+ entries.last["info"]
157
+ rescue StandardError
158
+ entries.to_s
159
+ end
160
+ parts << "KNOWN ABOUT #{topic}: #{info}"
161
+ matched_facts += 1
162
+ end
163
+
164
+ # Context items matching keywords
165
+ @facts["context"].each do |key, data|
166
+ break if matched_facts >= max_facts
167
+
168
+ if message_lower.include?(key.downcase)
169
+ parts << "#{key.upcase}: #{data["value"]}"
170
+ matched_facts += 1
171
+ end
172
+ end
173
+
174
+ # Recent events (last 5)
175
+ if @recent.any?
176
+ recent = @recent.first(5).map { |e| "- #{e["description"]}" }.join("\n")
177
+ parts << "RECENT:\n#{recent}"
178
+ end
179
+
180
+ parts.join("\n\n")
181
+ end
182
+
183
+ # Search across all memories
184
+ # @param query [String] search query
185
+ # @return [Array<Hash>] search results
186
+ def search(query)
187
+ results = []
188
+ query_lower = query.downcase
189
+
190
+ # Search learned facts
191
+ @facts["learned"].each do |topic, entries|
192
+ next unless topic.downcase.include?(query_lower)
193
+
194
+ entries.each do |entry|
195
+ results << {
196
+ type: "learned",
197
+ topic: topic,
198
+ content: entry["info"],
199
+ date: entry["learned_at"]
200
+ }
201
+ end
202
+ end
203
+
204
+ # Search user info
205
+ @facts["user"].each do |key, data|
206
+ next unless key.downcase.include?(query_lower) || data["value"].to_s.downcase.include?(query_lower)
207
+
208
+ results << {
209
+ type: "user",
210
+ key: key,
211
+ value: data["value"],
212
+ date: data["updated_at"]
213
+ }
214
+ end
215
+
216
+ # Search preferences
217
+ @facts["preferences"].each do |key, data|
218
+ next unless key.downcase.include?(query_lower) || data["value"].to_s.downcase.include?(query_lower)
219
+
220
+ results << {
221
+ type: "preference",
222
+ key: key,
223
+ value: data["value"],
224
+ date: data["updated_at"]
225
+ }
226
+ end
227
+
228
+ # Search context
229
+ @facts["context"].each do |key, data|
230
+ next unless key.downcase.include?(query_lower) || data["value"].to_s.downcase.include?(query_lower)
231
+
232
+ results << {
233
+ type: "context",
234
+ key: key,
235
+ value: data["value"],
236
+ date: data["updated_at"]
237
+ }
238
+ end
239
+
240
+ results
241
+ end
242
+
243
+ # Get memory statistics
244
+ def stats
245
+ {
246
+ learned_topics: @facts["learned"].keys.size,
247
+ total_learned: @facts["learned"].values.sum(&:size),
248
+ user_facts: @facts["user"].size,
249
+ preferences: @facts["preferences"].size,
250
+ context_items: @facts["context"].size,
251
+ recent_events: @recent.size
252
+ }
253
+ end
254
+
255
+ # Dump all memories (for debugging)
256
+ def dump_all
257
+ {
258
+ "facts" => @facts,
259
+ "recent" => @recent
260
+ }
261
+ end
262
+
263
+ private
264
+
265
+ def load_json(path)
266
+ return nil unless path.exist? # Missing file OK (first run)
267
+
268
+ content = path.read
269
+ JSON.parse(content)
270
+ rescue JSON::ParserError => e
271
+ raise Pocketrb::ConfigurationError,
272
+ "Invalid JSON in #{path}: #{e.message}\nContent preview: #{content[0..100]}"
273
+ end
274
+
275
+ def save_facts
276
+ @facts_file.write(JSON.pretty_generate(@facts))
277
+ end
278
+
279
+ def save_recent
280
+ @recent_file.write(JSON.pretty_generate(@recent))
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pocketrb
6
+ module Planning
7
+ # Manages plans persistence and lifecycle
8
+ class Manager
9
+ attr_reader :workspace
10
+
11
+ def initialize(workspace:)
12
+ @workspace = Pathname.new(workspace)
13
+ @plans_dir = @workspace.join(".pocketrb", "plans")
14
+ @plans_cache = {}
15
+
16
+ ensure_plans_dir!
17
+ end
18
+
19
+ # Create a new plan
20
+ # @param name [String] Plan name
21
+ # @param steps [Array<String>] Step descriptions
22
+ # @param description [String] Plan description
23
+ # @return [Plan]
24
+ def create_plan(name:, steps:, description: nil)
25
+ raise Error, "Plan '#{name}' already exists" if exists?(name)
26
+
27
+ plan = Plan.new(name: name, description: description)
28
+ plan.add_steps(steps)
29
+
30
+ save_plan(plan)
31
+ @plans_cache[name] = plan
32
+
33
+ Pocketrb.logger.info("Created plan: #{name} with #{steps.length} steps")
34
+ plan
35
+ end
36
+
37
+ # Get a plan by name
38
+ # @param name [String] Plan name
39
+ # @return [Plan|nil]
40
+ def get_plan(name)
41
+ @plans_cache[name] ||= load_plan(name)
42
+ end
43
+
44
+ # Update a plan
45
+ # @param name [String] Plan name
46
+ # @param completed_step [Integer] Step index to mark complete
47
+ # @param new_steps [Array<String>] Steps to add
48
+ # @param notes [String] Notes for completed step
49
+ def update_plan(name:, completed_step: nil, new_steps: nil, notes: nil)
50
+ plan = get_plan(name)
51
+ raise Error, "Plan '#{name}' not found" unless plan
52
+
53
+ plan.complete_step(completed_step, notes: notes) if completed_step
54
+
55
+ plan.add_steps(new_steps) if new_steps
56
+
57
+ # Auto-complete plan if all steps done
58
+ plan.mark_complete! if plan.complete? && plan.status == Plan::PlanStatus::ACTIVE
59
+
60
+ save_plan(plan)
61
+ plan
62
+ end
63
+
64
+ # Fail a step in a plan
65
+ def fail_step(name:, step_index:, notes: nil)
66
+ plan = get_plan(name)
67
+ raise Error, "Plan '#{name}' not found" unless plan
68
+
69
+ plan.fail_step(step_index, notes: notes)
70
+ save_plan(plan)
71
+ plan
72
+ end
73
+
74
+ # Activate a plan
75
+ def activate_plan(name)
76
+ plan = get_plan(name)
77
+ raise Error, "Plan '#{name}' not found" unless plan
78
+
79
+ plan.activate!
80
+ save_plan(plan)
81
+ plan
82
+ end
83
+
84
+ # Mark a plan as complete
85
+ def mark_complete(name)
86
+ plan = get_plan(name)
87
+ raise Error, "Plan '#{name}' not found" unless plan
88
+
89
+ plan.mark_complete!
90
+ save_plan(plan)
91
+ plan
92
+ end
93
+
94
+ # Cancel a plan
95
+ def cancel_plan(name)
96
+ plan = get_plan(name)
97
+ raise Error, "Plan '#{name}' not found" unless plan
98
+
99
+ plan.cancel!
100
+ save_plan(plan)
101
+ plan
102
+ end
103
+
104
+ # Delete a plan
105
+ def delete_plan(name)
106
+ file = plan_file(name)
107
+ File.delete(file) if file.exist?
108
+ @plans_cache.delete(name)
109
+ end
110
+
111
+ # Get all active plans
112
+ # @return [Array<Plan>]
113
+ def get_active_plans
114
+ list_plans.select { |p| p.status == Plan::PlanStatus::ACTIVE }
115
+ end
116
+
117
+ # Get all plans
118
+ # @return [Array<Plan>]
119
+ def list_plans
120
+ Dir.glob(@plans_dir.join("*.json")).filter_map do |file|
121
+ name = File.basename(file, ".json")
122
+ get_plan(name)
123
+ end
124
+ end
125
+
126
+ # Check if a plan exists
127
+ def exists?(name)
128
+ plan_file(name).exist?
129
+ end
130
+
131
+ private
132
+
133
+ def ensure_plans_dir!
134
+ FileUtils.mkdir_p(@plans_dir) unless @plans_dir.exist?
135
+ end
136
+
137
+ def plan_file(name)
138
+ safe_name = name.gsub(/[^a-zA-Z0-9_-]/, "_")
139
+ @plans_dir.join("#{safe_name}.json")
140
+ end
141
+
142
+ def save_plan(plan)
143
+ file = plan_file(plan.name)
144
+ File.write(file, JSON.pretty_generate(plan.to_h))
145
+ end
146
+
147
+ def load_plan(name)
148
+ file = plan_file(name)
149
+ return nil unless file.exist?
150
+
151
+ data = JSON.parse(File.read(file))
152
+ Plan.from_h(data)
153
+ rescue JSON::ParserError => e
154
+ Pocketrb.logger.error("Failed to parse plan #{name}: #{e.message}")
155
+ nil
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Planning
5
+ # Represents an execution plan with steps
6
+ class Plan
7
+ attr_reader :name, :description, :steps, :created_at, :metadata
8
+ attr_accessor :status
9
+
10
+ # Step statuses
11
+ module StepStatus
12
+ PENDING = "pending"
13
+ IN_PROGRESS = "in_progress"
14
+ COMPLETED = "completed"
15
+ FAILED = "failed"
16
+ SKIPPED = "skipped"
17
+ end
18
+
19
+ # Plan statuses
20
+ module PlanStatus
21
+ DRAFT = "draft"
22
+ ACTIVE = "active"
23
+ COMPLETED = "completed"
24
+ FAILED = "failed"
25
+ CANCELLED = "cancelled"
26
+ end
27
+
28
+ Step = Data.define(:index, :description, :status, :notes, :completed_at) do
29
+ def initialize(index:, description:, status: StepStatus::PENDING, notes: nil, completed_at: nil)
30
+ super
31
+ end
32
+
33
+ def pending?
34
+ status == StepStatus::PENDING
35
+ end
36
+
37
+ def completed?
38
+ status == StepStatus::COMPLETED
39
+ end
40
+
41
+ def failed?
42
+ status == StepStatus::FAILED
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ index: index,
48
+ description: description,
49
+ status: status,
50
+ notes: notes,
51
+ completed_at: completed_at&.iso8601
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ def initialize(name:, description: nil, steps: [], status: PlanStatus::DRAFT, metadata: {})
57
+ @name = name
58
+ @description = description
59
+ @steps = steps.map.with_index do |step, idx|
60
+ step.is_a?(Step) ? step : Step.new(index: idx, description: step.to_s)
61
+ end
62
+ @status = status
63
+ @metadata = metadata
64
+ @created_at = Time.now
65
+ end
66
+
67
+ # Add a step to the plan
68
+ def add_step(description)
69
+ step = Step.new(index: @steps.length, description: description)
70
+ @steps << step
71
+ step
72
+ end
73
+
74
+ # Add multiple steps
75
+ def add_steps(descriptions)
76
+ descriptions.each { |d| add_step(d) }
77
+ end
78
+
79
+ # Update a step's status
80
+ def update_step(index, status:, notes: nil)
81
+ return nil unless @steps[index]
82
+
83
+ completed_at = status == StepStatus::COMPLETED ? Time.now : nil
84
+
85
+ @steps[index] = Step.new(
86
+ index: index,
87
+ description: @steps[index].description,
88
+ status: status,
89
+ notes: notes || @steps[index].notes,
90
+ completed_at: completed_at
91
+ )
92
+
93
+ @steps[index]
94
+ end
95
+
96
+ # Mark a step as completed
97
+ def complete_step(index, notes: nil)
98
+ update_step(index, status: StepStatus::COMPLETED, notes: notes)
99
+ end
100
+
101
+ # Mark a step as failed
102
+ def fail_step(index, notes: nil)
103
+ update_step(index, status: StepStatus::FAILED, notes: notes)
104
+ end
105
+
106
+ # Skip a step
107
+ def skip_step(index, notes: nil)
108
+ update_step(index, status: StepStatus::SKIPPED, notes: notes)
109
+ end
110
+
111
+ # Get the next pending step
112
+ def next_step
113
+ @steps.find(&:pending?)
114
+ end
115
+
116
+ # Get current step (in progress or next pending)
117
+ def current_step
118
+ @steps.find { |s| s.status == StepStatus::IN_PROGRESS } || next_step
119
+ end
120
+
121
+ # Check if plan is complete
122
+ def complete?
123
+ @steps.all?(&:completed?)
124
+ end
125
+
126
+ # Check if plan has failed
127
+ def failed?
128
+ @steps.any?(&:failed?)
129
+ end
130
+
131
+ # Get progress percentage
132
+ def progress
133
+ return 0 if @steps.empty?
134
+
135
+ completed = @steps.count(&:completed?)
136
+ (completed.to_f / @steps.length * 100).round
137
+ end
138
+
139
+ # Activate the plan
140
+ def activate!
141
+ @status = PlanStatus::ACTIVE
142
+ end
143
+
144
+ # Mark plan as complete
145
+ def mark_complete!
146
+ @status = PlanStatus::COMPLETED
147
+ end
148
+
149
+ # Mark plan as failed
150
+ def mark_failed!
151
+ @status = PlanStatus::FAILED
152
+ end
153
+
154
+ # Cancel the plan
155
+ def cancel!
156
+ @status = PlanStatus::CANCELLED
157
+ end
158
+
159
+ # Format as markdown
160
+ def to_markdown
161
+ lines = ["# Plan: #{@name}"]
162
+ lines << "" << @description if @description
163
+ lines << "" << "Status: #{@status} | Progress: #{progress}%"
164
+ lines << "" << "## Steps" << ""
165
+
166
+ @steps.each do |step|
167
+ checkbox = case step.status
168
+ when StepStatus::COMPLETED then "[x]"
169
+ when StepStatus::IN_PROGRESS then "[~]"
170
+ when StepStatus::FAILED then "[!]"
171
+ when StepStatus::SKIPPED then "[-]"
172
+ else "[ ]"
173
+ end
174
+
175
+ lines << "#{checkbox} #{step.index + 1}. #{step.description}"
176
+ lines << " Notes: #{step.notes}" if step.notes
177
+ end
178
+
179
+ lines.join("\n")
180
+ end
181
+
182
+ # Convert to hash for serialization
183
+ def to_h
184
+ {
185
+ name: @name,
186
+ description: @description,
187
+ steps: @steps.map(&:to_h),
188
+ status: @status,
189
+ metadata: @metadata,
190
+ created_at: @created_at.iso8601,
191
+ progress: progress
192
+ }
193
+ end
194
+
195
+ # Create from hash
196
+ def self.from_h(hash)
197
+ steps = (hash[:steps] || hash["steps"] || []).map do |s|
198
+ Step.new(
199
+ index: s[:index] || s["index"],
200
+ description: s[:description] || s["description"],
201
+ status: s[:status] || s["status"] || StepStatus::PENDING,
202
+ notes: s[:notes] || s["notes"],
203
+ completed_at: s[:completed_at] || s["completed_at"] ? Time.parse(s[:completed_at] || s["completed_at"]) : nil
204
+ )
205
+ end
206
+
207
+ plan = new(
208
+ name: hash[:name] || hash["name"],
209
+ description: hash[:description] || hash["description"],
210
+ steps: steps,
211
+ status: hash[:status] || hash["status"] || PlanStatus::DRAFT,
212
+ metadata: hash[:metadata] || hash["metadata"] || {}
213
+ )
214
+
215
+ if hash[:created_at] || hash["created_at"]
216
+ plan.instance_variable_set(:@created_at, Time.parse(hash[:created_at] || hash["created_at"]))
217
+ end
218
+
219
+ plan
220
+ end
221
+ end
222
+ end
223
+ end