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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # List directory contents
6
+ class ListDir < Base
7
+ def name
8
+ "list_dir"
9
+ end
10
+
11
+ def description
12
+ "List the contents of a directory. Shows files and subdirectories with basic metadata."
13
+ end
14
+
15
+ def parameters
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ path: {
20
+ type: "string",
21
+ description: "Path to the directory to list (defaults to workspace root)"
22
+ },
23
+ pattern: {
24
+ type: "string",
25
+ description: "Glob pattern to filter results (e.g., '*.rb', '**/*.ts')"
26
+ },
27
+ recursive: {
28
+ type: "boolean",
29
+ description: "List subdirectories recursively (default: false)"
30
+ },
31
+ include_hidden: {
32
+ type: "boolean",
33
+ description: "Include hidden files (starting with .) (default: false)"
34
+ }
35
+ },
36
+ required: []
37
+ }
38
+ end
39
+
40
+ def execute(path: nil, pattern: nil, recursive: false, include_hidden: false)
41
+ resolved = path ? validate_path!(path) : workspace
42
+
43
+ return error("Not a directory: #{path || "workspace"}") unless resolved&.directory?
44
+
45
+ entries = if pattern
46
+ glob_pattern = resolved.join(recursive ? "**" : "", pattern)
47
+ Dir.glob(glob_pattern)
48
+ elsif recursive
49
+ Dir.glob(resolved.join("**", "*"))
50
+ else
51
+ Dir.children(resolved).map { |e| resolved.join(e).to_s }
52
+ end
53
+
54
+ # Filter hidden files
55
+ entries = entries.reject { |e| File.basename(e).start_with?(".") } unless include_hidden
56
+
57
+ # Sort entries
58
+ entries.sort!
59
+
60
+ # Format output
61
+ output = entries.map do |entry|
62
+ path_obj = Pathname.new(entry)
63
+ relative = workspace ? path_obj.relative_path_from(workspace) : path_obj
64
+ format_entry(path_obj, relative)
65
+ end
66
+
67
+ if output.empty?
68
+ "Directory is empty or no matches found"
69
+ else
70
+ output.join("\n")
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def format_entry(path, relative)
77
+ if path.directory?
78
+ "#{relative}/"
79
+ else
80
+ size = format_size(path.size)
81
+ mtime = path.mtime.strftime("%Y-%m-%d %H:%M")
82
+ "#{relative} (#{size}, #{mtime})"
83
+ end
84
+ rescue StandardError
85
+ relative.to_s
86
+ end
87
+
88
+ def format_size(bytes)
89
+ units = %w[B KB MB GB]
90
+ unit_index = 0
91
+
92
+ size = bytes.to_f
93
+ while size >= 1024 && unit_index < units.length - 1
94
+ size /= 1024
95
+ unit_index += 1
96
+ end
97
+
98
+ format("%.1f%s", size, units[unit_index])
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # Simple memory tool - store and recall facts
6
+ class Memory < Base
7
+ ACTIONS = %w[store recall search recent stats].freeze
8
+ CATEGORIES = %w[learned user preference context].freeze
9
+
10
+ def name
11
+ "memory"
12
+ end
13
+
14
+ def description
15
+ <<~DESC.strip
16
+ Store and recall memories. Use this to remember important facts, preferences, and learnings.
17
+
18
+ Actions:
19
+ - store: Save a fact (categories: learned, user, preference, context)
20
+ - recall: Search for memories matching a query
21
+ - search: Deep search across all memory categories
22
+ - recent: Show recent events
23
+ - stats: Memory statistics
24
+ DESC
25
+ end
26
+
27
+ def parameters
28
+ {
29
+ type: "object",
30
+ properties: {
31
+ action: {
32
+ type: "string",
33
+ enum: ACTIONS,
34
+ description: "Memory action to perform"
35
+ },
36
+ category: {
37
+ type: "string",
38
+ enum: CATEGORIES,
39
+ description: "Category for storing (learned, user, preference, context)"
40
+ },
41
+ key: {
42
+ type: "string",
43
+ description: "Key/topic for the memory"
44
+ },
45
+ value: {
46
+ type: "string",
47
+ description: "Value/content to store"
48
+ },
49
+ query: {
50
+ type: "string",
51
+ description: "Search query for recall/search actions"
52
+ }
53
+ },
54
+ required: ["action"]
55
+ }
56
+ end
57
+
58
+ def available?
59
+ !memory_instance.nil?
60
+ end
61
+
62
+ def execute(action:, category: nil, key: nil, value: nil, query: nil, **)
63
+ return error("Memory not initialized") unless memory_instance
64
+
65
+ case action
66
+ when "store"
67
+ store_memory(category, key, value)
68
+ when "recall"
69
+ recall_memory(query)
70
+ when "search"
71
+ search_memory(query)
72
+ when "recent"
73
+ show_recent
74
+ when "stats"
75
+ show_stats
76
+ else
77
+ error("Unknown action: #{action}")
78
+ end
79
+ rescue StandardError => e
80
+ error("Memory error: #{e.message}")
81
+ end
82
+
83
+ private
84
+
85
+ def memory_instance
86
+ @context[:memory]
87
+ end
88
+
89
+ def store_memory(category, key, value)
90
+ return error("Category, key, and value required") unless category && key && value
91
+
92
+ result = case category
93
+ when "learned"
94
+ memory_instance.remember_learned(key, value)
95
+ when "user"
96
+ memory_instance.remember_user(key, value)
97
+ when "preference"
98
+ memory_instance.remember_preference(key, value)
99
+ when "context"
100
+ memory_instance.remember_context(key, value)
101
+ else
102
+ return error("Invalid category. Use: learned, user, preference, or context")
103
+ end
104
+
105
+ success(result)
106
+ end
107
+
108
+ def recall_memory(query)
109
+ return error("Query required") unless query
110
+
111
+ context = memory_instance.relevant_context(query, max_facts: 10)
112
+
113
+ if context.empty?
114
+ "No relevant memories found for: #{query}"
115
+ else
116
+ "Relevant memories:\n\n#{context}"
117
+ end
118
+ end
119
+
120
+ def search_memory(query)
121
+ return error("Query required") unless query
122
+
123
+ results = memory_instance.search(query)
124
+
125
+ return "No memories found matching: #{query}" if results.empty?
126
+
127
+ lines = ["Found #{results.size} memories:"]
128
+ results.each_with_index do |result, i|
129
+ lines << "\n#{i + 1}. [#{result[:type]}] #{result[:topic] || result[:key]}"
130
+ lines << " #{result[:content] || result[:value]}"
131
+ lines << " (#{result[:date]})" if result[:date]
132
+ end
133
+
134
+ lines.join("\n")
135
+ end
136
+
137
+ def show_recent
138
+ events = memory_instance.recent_events(10)
139
+
140
+ return "No recent events recorded." if events.empty?
141
+
142
+ lines = ["Recent events (#{events.size}):"]
143
+ events.each do |event|
144
+ timestamp = Time.parse(event["timestamp"]).strftime("%Y-%m-%d %H:%M")
145
+ lines << "- [#{timestamp}] #{event["description"]}"
146
+ end
147
+
148
+ lines.join("\n")
149
+ end
150
+
151
+ def show_stats
152
+ stats = memory_instance.stats
153
+
154
+ lines = [
155
+ "Memory Statistics:",
156
+ "- Learned topics: #{stats[:learned_topics]} (#{stats[:total_learned]} total facts)",
157
+ "- User facts: #{stats[:user_facts]}",
158
+ "- Preferences: #{stats[:preferences]}",
159
+ "- Context items: #{stats[:context_items]}",
160
+ "- Recent events: #{stats[:recent_events]}"
161
+ ]
162
+
163
+ lines.join("\n")
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # Tool for sending messages to chat channels programmatically
6
+ class Message < Base
7
+ def name
8
+ "message"
9
+ end
10
+
11
+ def description
12
+ "Send a message to a chat channel. Use this to proactively communicate with users, send notifications, or respond on specific channels."
13
+ end
14
+
15
+ def parameters
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ content: {
20
+ type: "string",
21
+ description: "The message content to send"
22
+ },
23
+ channel: {
24
+ type: "string",
25
+ description: "Target channel (telegram, whatsapp, cli). Uses default if not specified."
26
+ },
27
+ chat_id: {
28
+ type: "string",
29
+ description: "Recipient chat ID. Uses default if not specified."
30
+ }
31
+ },
32
+ required: ["content"]
33
+ }
34
+ end
35
+
36
+ def execute(content:, channel: nil, chat_id: nil)
37
+ # Use defaults from context if not provided
38
+ channel = (channel || @context[:default_channel])&.to_sym
39
+ chat_id ||= @context[:default_chat_id]
40
+
41
+ return error("No channel specified and no default channel in context") unless channel
42
+
43
+ return error("No chat_id specified and no default chat_id in context") unless chat_id
44
+
45
+ return error("Message bus not available in context") unless bus
46
+
47
+ outbound = Bus::OutboundMessage.new(
48
+ channel: channel,
49
+ chat_id: chat_id,
50
+ content: content
51
+ )
52
+
53
+ bus.publish_outbound(outbound)
54
+ Pocketrb.logger.info("Message sent to #{channel}:#{chat_id}")
55
+
56
+ success("Message sent to #{channel}:#{chat_id}")
57
+ end
58
+
59
+ def available?
60
+ !bus.nil?
61
+ end
62
+
63
+ private
64
+
65
+ def bus
66
+ @context[:bus]
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # PARA-based memory tool for structured knowledge management
6
+ class ParaMemory < Base
7
+ def name
8
+ "para_memory"
9
+ end
10
+
11
+ def description
12
+ <<~DESC.strip
13
+ Structured memory using PARA method. Store and retrieve facts about people, projects,
14
+ companies, and topics. Facts are organized, decay over time, and build a knowledge graph.
15
+ Use this for durable information that should persist across conversations.
16
+ DESC
17
+ end
18
+
19
+ def parameters
20
+ {
21
+ type: "object",
22
+ properties: {
23
+ action: {
24
+ type: "string",
25
+ enum: %w[store search entity_info create_entity list_entities context preferences learn_preference],
26
+ description: "Action to perform"
27
+ },
28
+ # For store action
29
+ content: {
30
+ type: "string",
31
+ description: "Fact content to store"
32
+ },
33
+ category: {
34
+ type: "string",
35
+ enum: %w[relationship milestone status preference context],
36
+ description: "Fact category (for store)"
37
+ },
38
+ entity_type: {
39
+ type: "string",
40
+ enum: %w[projects areas resources archives],
41
+ description: "PARA entity type"
42
+ },
43
+ entity_name: {
44
+ type: "string",
45
+ description: "Entity name (e.g., 'zakiya', 'people/john', 'companies/acme')"
46
+ },
47
+ # For search action
48
+ query: {
49
+ type: "string",
50
+ description: "Search query"
51
+ },
52
+ # For create_entity
53
+ entity_subtype: {
54
+ type: "string",
55
+ enum: %w[project person company topic],
56
+ description: "What kind of entity to create"
57
+ },
58
+ # For learn_preference
59
+ preference_category: {
60
+ type: "string",
61
+ enum: %w[communication_preferences working_style tool_preferences rules_and_boundaries],
62
+ description: "Preference category"
63
+ },
64
+ preference_key: {
65
+ type: "string",
66
+ description: "Preference key (e.g., 'verbosity', 'timezone')"
67
+ },
68
+ preference_value: {
69
+ type: "string",
70
+ description: "Preference value"
71
+ }
72
+ },
73
+ required: ["action"]
74
+ }
75
+ end
76
+
77
+ def available?
78
+ !para_manager.nil?
79
+ end
80
+
81
+ def execute(action:, **args)
82
+ return error("PARA memory not available. Configure memory_dir in agent settings.") unless para_manager
83
+
84
+ case action
85
+ when "store"
86
+ store_fact(args)
87
+ when "search"
88
+ search_memory(args[:query])
89
+ when "entity_info"
90
+ get_entity_info(args[:entity_type], args[:entity_name])
91
+ when "create_entity"
92
+ create_entity(args)
93
+ when "list_entities"
94
+ list_entities(args[:entity_type])
95
+ when "context"
96
+ get_context(args[:query])
97
+ when "preferences"
98
+ get_preferences
99
+ when "learn_preference"
100
+ learn_preference(args)
101
+ else
102
+ error("Unknown action: #{action}")
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def para_manager
109
+ @context[:para_manager]
110
+ end
111
+
112
+ def store_fact(args)
113
+ content = args[:content]
114
+ return error("Content required") if content.nil? || content.empty?
115
+
116
+ category = (args[:category] || "context").to_sym
117
+ entity_type = args[:entity_type]&.to_sym
118
+ entity_name = args[:entity_name]
119
+
120
+ if entity_type && entity_name
121
+ fact = para_manager.store_fact(
122
+ entity_type: entity_type,
123
+ entity_name: entity_name,
124
+ content: content,
125
+ category: category
126
+ )
127
+ success("Stored fact in #{entity_type}/#{entity_name}: #{content[0..50]}... (ID: #{fact.id})")
128
+ else
129
+ para_manager.remember(content: content, category: category)
130
+ success("Stored fact: #{content[0..50]}...")
131
+ end
132
+ end
133
+
134
+ def search_memory(query)
135
+ return error("Query required") if query.nil? || query.empty?
136
+
137
+ results = para_manager.search(query)
138
+
139
+ lines = ["Search results for: #{query}\n"]
140
+
141
+ if results[:knowledge_graph].any?
142
+ lines << "**Knowledge Graph:**"
143
+ results[:knowledge_graph].each do |r|
144
+ lines << " #{r[:entity]}:"
145
+ r[:facts].first(3).each { |f| lines << " - #{f}" }
146
+ end
147
+ end
148
+
149
+ if results[:daily_notes].any?
150
+ lines << "\n**Daily Notes:**"
151
+ results[:daily_notes].first(5).each do |r|
152
+ lines << " #{r[:date]}:"
153
+ r[:matches].first(2).each { |m| lines << " - #{m}" }
154
+ end
155
+ end
156
+
157
+ if results[:tacit].any?
158
+ lines << "\n**Preferences:**"
159
+ results[:tacit].each do |r|
160
+ lines << " #{r[:category]}/#{r[:key]}: #{r[:value]}"
161
+ end
162
+ end
163
+
164
+ lines << "No results found." if lines.size == 1
165
+
166
+ lines.join("\n")
167
+ end
168
+
169
+ def get_entity_info(entity_type, entity_name)
170
+ return error("Entity type and name required") unless entity_type && entity_name
171
+
172
+ summary = para_manager.entity_summary(type: entity_type.to_sym, name: entity_name)
173
+ return error("Entity not found: #{entity_type}/#{entity_name}") unless summary
174
+
175
+ summary
176
+ end
177
+
178
+ def create_entity(args)
179
+ subtype = args[:entity_subtype]
180
+ name = args[:entity_name]
181
+ content = args[:content]
182
+
183
+ return error("Entity subtype and name required") unless subtype && name
184
+
185
+ case subtype
186
+ when "project"
187
+ para_manager.create_project(name: name, description: content)
188
+ success("Created project: #{name}")
189
+ when "person"
190
+ para_manager.create_person(name: name, relationship: content)
191
+ success("Created person: #{name}")
192
+ when "company"
193
+ para_manager.create_company(name: name, context: content)
194
+ success("Created company: #{name}")
195
+ when "topic"
196
+ para_manager.knowledge_graph.create_entity(
197
+ type: :resources,
198
+ name: name,
199
+ initial_facts: content ? [{ content: content, category: :context }] : []
200
+ )
201
+ success("Created topic: #{name}")
202
+ else
203
+ error("Unknown entity subtype: #{subtype}")
204
+ end
205
+ end
206
+
207
+ def list_entities(entity_type)
208
+ if entity_type
209
+ entities = para_manager.knowledge_graph.list_entities(entity_type.to_sym)
210
+ return "No #{entity_type} entities found." if entities.empty?
211
+
212
+ lines = ["#{entity_type.capitalize}:"]
213
+ entities.each do |e|
214
+ e.load!
215
+ lines << " - #{e.name} (#{e.active_facts.size} facts)"
216
+ end
217
+ lines.join("\n")
218
+ else
219
+ para_manager.full_context_summary
220
+ end
221
+ end
222
+
223
+ def get_context(query)
224
+ if query && !query.empty?
225
+ para_manager.relevant_context(query)
226
+ else
227
+ para_manager.full_context_summary
228
+ end
229
+ end
230
+
231
+ def get_preferences
232
+ prefs = para_manager.get_preferences
233
+ return "No learned preferences yet." if prefs.empty?
234
+
235
+ lines = ["User Preferences:"]
236
+ prefs.each do |category, items|
237
+ lines << "\n#{category.to_s.tr("_", " ").capitalize}:"
238
+ items.each do |key, pref|
239
+ conf = (pref[:confidence] * 100).to_i
240
+ lines << " - #{key}: #{pref[:value]} (#{conf}% confident)"
241
+ end
242
+ end
243
+ lines.join("\n")
244
+ end
245
+
246
+ def learn_preference(args)
247
+ category = args[:preference_category]
248
+ key = args[:preference_key]
249
+ value = args[:preference_value]
250
+
251
+ return error("Category, key, and value required") unless category && key && value
252
+
253
+ para_manager.learn_preference(
254
+ category: category.to_sym,
255
+ key: key.to_sym,
256
+ value: value,
257
+ source: "conversation"
258
+ )
259
+
260
+ success("Learned preference: #{key} = #{value}")
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Tools
5
+ # Read file contents
6
+ class ReadFile < Base
7
+ def name
8
+ "read_file"
9
+ end
10
+
11
+ def description
12
+ "Read the contents of a file. Returns the file content as text."
13
+ end
14
+
15
+ def parameters
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ path: {
20
+ type: "string",
21
+ description: "Path to the file to read (relative to workspace or absolute)"
22
+ },
23
+ offset: {
24
+ type: "integer",
25
+ description: "Line number to start reading from (1-indexed, optional)"
26
+ },
27
+ limit: {
28
+ type: "integer",
29
+ description: "Maximum number of lines to read (optional)"
30
+ }
31
+ },
32
+ required: ["path"]
33
+ }
34
+ end
35
+
36
+ def execute(path:, offset: nil, limit: nil)
37
+ resolved = validate_path!(path)
38
+
39
+ return error("Not a file: #{path}") unless resolved.file?
40
+
41
+ content = File.read(resolved)
42
+ lines = content.lines
43
+
44
+ # Apply offset and limit
45
+ lines = lines[(offset - 1)..] if offset && offset > 1
46
+
47
+ lines = lines.first(limit) if limit
48
+
49
+ # Add line numbers
50
+ start_line = offset || 1
51
+ numbered_lines = lines.each_with_index.map do |line, idx|
52
+ "#{start_line + idx}: #{line}"
53
+ end
54
+
55
+ numbered_lines.join
56
+ rescue Errno::ENOENT
57
+ error("File not found: #{path}")
58
+ rescue Errno::EACCES
59
+ error("Permission denied: #{path}")
60
+ rescue Errno::EISDIR
61
+ error("Is a directory: #{path}")
62
+ end
63
+ end
64
+ end
65
+ end