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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- 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
|