ruby-claw 0.1.2 → 0.2.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +214 -10
  4. data/exe/claw +42 -1
  5. data/lib/claw/auto_forge.rb +66 -0
  6. data/lib/claw/benchmark/benchmark.rb +79 -0
  7. data/lib/claw/benchmark/diff.rb +69 -0
  8. data/lib/claw/benchmark/report.rb +87 -0
  9. data/lib/claw/benchmark/runner.rb +91 -0
  10. data/lib/claw/benchmark/scorer.rb +69 -0
  11. data/lib/claw/benchmark/task.rb +63 -0
  12. data/lib/claw/benchmark/tasks/claw_remember.rb +20 -0
  13. data/lib/claw/benchmark/tasks/claw_session.rb +18 -0
  14. data/lib/claw/benchmark/tasks/evolution_trace.rb +18 -0
  15. data/lib/claw/benchmark/tasks/mana_call_func.rb +21 -0
  16. data/lib/claw/benchmark/tasks/mana_eval.rb +18 -0
  17. data/lib/claw/benchmark/tasks/mana_knowledge.rb +19 -0
  18. data/lib/claw/benchmark/tasks/mana_var_readwrite.rb +18 -0
  19. data/lib/claw/benchmark/tasks/runtime_fork.rb +18 -0
  20. data/lib/claw/benchmark/tasks/runtime_snapshot.rb +18 -0
  21. data/lib/claw/benchmark/trigger.rb +68 -0
  22. data/lib/claw/chat.rb +119 -6
  23. data/lib/claw/child_runtime.rb +196 -0
  24. data/lib/claw/cli.rb +177 -0
  25. data/lib/claw/commands.rb +131 -0
  26. data/lib/claw/config.rb +5 -1
  27. data/lib/claw/console/event_logger.rb +69 -0
  28. data/lib/claw/console/public/app.js +264 -0
  29. data/lib/claw/console/public/style.css +330 -0
  30. data/lib/claw/console/server.rb +253 -0
  31. data/lib/claw/console/sse.rb +28 -0
  32. data/lib/claw/console/views/experiments.erb +8 -0
  33. data/lib/claw/console/views/index.erb +27 -0
  34. data/lib/claw/console/views/layout.erb +29 -0
  35. data/lib/claw/console/views/memory.erb +13 -0
  36. data/lib/claw/console/views/monitor.erb +15 -0
  37. data/lib/claw/console/views/prompt.erb +15 -0
  38. data/lib/claw/console/views/snapshots.erb +12 -0
  39. data/lib/claw/console/views/tools.erb +13 -0
  40. data/lib/claw/console/views/traces.erb +9 -0
  41. data/lib/claw/console.rb +5 -0
  42. data/lib/claw/evolution.rb +227 -0
  43. data/lib/claw/forge.rb +144 -0
  44. data/lib/claw/hub.rb +67 -0
  45. data/lib/claw/init.rb +199 -0
  46. data/lib/claw/knowledge.rb +36 -2
  47. data/lib/claw/memory_store.rb +2 -2
  48. data/lib/claw/plan_mode.rb +110 -0
  49. data/lib/claw/resource.rb +35 -0
  50. data/lib/claw/resources/binding_resource.rb +128 -0
  51. data/lib/claw/resources/context_resource.rb +73 -0
  52. data/lib/claw/resources/filesystem_resource.rb +107 -0
  53. data/lib/claw/resources/memory_resource.rb +74 -0
  54. data/lib/claw/resources/worktree_resource.rb +133 -0
  55. data/lib/claw/roles.rb +56 -0
  56. data/lib/claw/runtime.rb +189 -0
  57. data/lib/claw/serializer.rb +10 -7
  58. data/lib/claw/tool.rb +99 -0
  59. data/lib/claw/tool_index.rb +84 -0
  60. data/lib/claw/tool_registry.rb +100 -0
  61. data/lib/claw/trace.rb +86 -0
  62. data/lib/claw/tui/agent_executor.rb +92 -0
  63. data/lib/claw/tui/chat_panel.rb +81 -0
  64. data/lib/claw/tui/command_bar.rb +22 -0
  65. data/lib/claw/tui/file_card.rb +88 -0
  66. data/lib/claw/tui/folding.rb +80 -0
  67. data/lib/claw/tui/input_handler.rb +73 -0
  68. data/lib/claw/tui/layout.rb +34 -0
  69. data/lib/claw/tui/messages.rb +31 -0
  70. data/lib/claw/tui/model.rb +411 -0
  71. data/lib/claw/tui/object_explorer.rb +136 -0
  72. data/lib/claw/tui/status_bar.rb +30 -0
  73. data/lib/claw/tui/status_panel.rb +133 -0
  74. data/lib/claw/tui/styles.rb +58 -0
  75. data/lib/claw/tui/tui.rb +54 -0
  76. data/lib/claw/version.rb +1 -1
  77. data/lib/claw.rb +99 -1
  78. metadata +223 -7
data/lib/claw/forge.rb ADDED
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Claw
6
+ # Promotes eval-defined methods into formal Claw::Tool classes.
7
+ # `/forge method_name` reads the method source, uses LLM to generate
8
+ # a tool class, and writes it to `.ruby-claw/tools/`.
9
+ module Forge
10
+ TEMPLATE_PROMPT = <<~PROMPT
11
+ Convert this Ruby method into a Claw::Tool class.
12
+
13
+ Method source:
14
+ ```ruby
15
+ %{source}
16
+ ```
17
+
18
+ Output ONLY a complete Ruby file with this exact structure (no explanation):
19
+ ```ruby
20
+ class ClassName
21
+ include Claw::Tool
22
+ tool_name "tool_name_here"
23
+ description "One-line description of what this tool does"
24
+ parameter :param1, type: "String", required: true, desc: "..."
25
+ # Add more parameters as needed
26
+
27
+ def call(param1:)
28
+ # Implementation (adapted from the method source above)
29
+ end
30
+ end
31
+ ```
32
+
33
+ Rules:
34
+ - tool_name should be the method name in snake_case
35
+ - Extract method parameters as tool parameters with appropriate types
36
+ - The call method should have keyword arguments matching the parameters
37
+ - Return a meaningful result (string or data structure)
38
+ PROMPT
39
+
40
+ class << self
41
+ # Promote an eval-defined method to a formal tool class file.
42
+ #
43
+ # @param method_name [String] name of the method to promote
44
+ # @param binding [Binding] caller's binding (to read definitions)
45
+ # @param claw_dir [String] path to .ruby-claw/ directory
46
+ # @return [Hash] { success: bool, path: String, message: String }
47
+ def promote(method_name, binding:, claw_dir: nil)
48
+ claw_dir ||= File.join(Dir.pwd, ".ruby-claw")
49
+ tools_dir = File.join(claw_dir, "tools")
50
+ FileUtils.mkdir_p(tools_dir)
51
+
52
+ # 1. Read method source from tracked definitions
53
+ source = find_source(method_name, binding)
54
+ unless source
55
+ return { success: false, message: "Method '#{method_name}' not found in definitions" }
56
+ end
57
+
58
+ # 2. Generate tool class via LLM (direct API call, no tools)
59
+ prompt = format(TEMPLATE_PROMPT, source: source)
60
+ backend = Mana::Backends::Base.for(Mana.config)
61
+ response = backend.chat(
62
+ system: "You are a Ruby code generator. Output only code, no explanations.",
63
+ messages: [{ role: "user", content: prompt }],
64
+ tools: [],
65
+ model: Mana.config.model,
66
+ max_tokens: 2048
67
+ )
68
+ raw = response.dig(:content, 0, :text) || response[:content]&.map { |c| c[:text] }&.compact&.join
69
+
70
+ # 3. Extract Ruby code from response
71
+ code = extract_ruby_code(raw.to_s)
72
+ unless code
73
+ return { success: false, message: "LLM did not generate valid Ruby code" }
74
+ end
75
+
76
+ # 4. Write to tools directory
77
+ filename = "#{method_name.downcase.gsub(/[^a-z0-9_]/, '_')}.rb"
78
+ path = File.join(tools_dir, filename)
79
+ File.write(path, code)
80
+
81
+ # 5. Refresh tool index if registry exists
82
+ Claw.tool_registry&.index&.scan!
83
+
84
+ { success: true, path: path, message: "Tool '#{method_name}' forged at #{path}" }
85
+ rescue => e
86
+ { success: false, message: "Forge failed: #{e.class}: #{e.message}" }
87
+ end
88
+
89
+ private
90
+
91
+ def find_source(method_name, binding)
92
+ receiver = binding.receiver
93
+ defs = if receiver.instance_variable_defined?(:@__claw_definitions__)
94
+ receiver.instance_variable_get(:@__claw_definitions__)
95
+ else
96
+ {}
97
+ end
98
+
99
+ # Try tracked definitions first
100
+ source = defs[method_name.to_s] || defs[method_name.to_sym]
101
+ return source if source
102
+
103
+ # Try source_location as fallback
104
+ meth = begin
105
+ receiver.method(method_name.to_sym)
106
+ rescue NameError
107
+ nil
108
+ end
109
+
110
+ if meth&.source_location
111
+ file, line = meth.source_location
112
+ return nil unless file && File.exist?(file)
113
+ lines = File.readlines(file)
114
+ # Read from def line until matching end
115
+ start = line - 1
116
+ depth = 0
117
+ result_lines = []
118
+ lines[start..].each do |l|
119
+ result_lines << l
120
+ depth += 1 if l.match?(/\b(def|class|module|do|begin|if|unless|while|until|for|case)\b/)
121
+ depth -= 1 if l.match?(/\bend\b/)
122
+ break if depth <= 0 && result_lines.size > 1
123
+ end
124
+ result_lines.join
125
+ end
126
+ end
127
+
128
+ def extract_ruby_code(text)
129
+ # Extract code from ```ruby ... ``` blocks
130
+ if text.match?(/```ruby\s*\n/)
131
+ match = text.match(/```ruby\s*\n(.*?)```/m)
132
+ return match[1].strip if match
133
+ end
134
+
135
+ # If the entire response looks like Ruby code
136
+ if text.strip.match?(/\Aclass\s/)
137
+ return text.strip
138
+ end
139
+
140
+ nil
141
+ end
142
+ end
143
+ end
144
+ end
data/lib/claw/hub.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Claw
8
+ # Client for the ruby-claw-toolhub community tool repository.
9
+ # Provides search and download of community-contributed tools.
10
+ class Hub
11
+ attr_reader :url
12
+
13
+ def initialize(url:)
14
+ @url = url.chomp("/")
15
+ end
16
+
17
+ # Search hub for tools matching a keyword.
18
+ #
19
+ # @param keyword [String]
20
+ # @return [Array<Hash>] [{name:, description:, version:, url:}]
21
+ def search(keyword)
22
+ uri = URI("#{@url}/api/search?q=#{URI.encode_www_form_component(keyword)}")
23
+ response = http_get(uri)
24
+ return [] unless response
25
+
26
+ results = JSON.parse(response, symbolize_names: true)
27
+ results.map do |r|
28
+ { name: r[:name], description: r[:description] || "",
29
+ version: r[:version], url: r[:url] }
30
+ end
31
+ rescue JSON::ParserError
32
+ []
33
+ end
34
+
35
+ # Download a tool file from the hub.
36
+ #
37
+ # @param name [String] tool name
38
+ # @param target_dir [String] directory to write the file
39
+ # @return [String] path to the downloaded file
40
+ def download(name, target_dir:)
41
+ uri = URI("#{@url}/api/tools/#{URI.encode_www_form_component(name)}")
42
+ content = http_get(uri)
43
+ raise "Tool '#{name}' not found on hub" unless content
44
+
45
+ safe_name = File.basename(name).gsub(/[^a-zA-Z0-9_\-]/, "_")
46
+ path = File.join(target_dir, "#{safe_name}.rb")
47
+ File.write(path, content)
48
+ path
49
+ end
50
+
51
+ private
52
+
53
+ def http_get(uri)
54
+ http = Net::HTTP.new(uri.host, uri.port)
55
+ http.use_ssl = uri.scheme == "https"
56
+ http.open_timeout = 10
57
+ http.read_timeout = 10
58
+
59
+ req = Net::HTTP::Get.new(uri)
60
+ res = http.request(req)
61
+
62
+ res.is_a?(Net::HTTPSuccess) ? res.body : nil
63
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError
64
+ nil
65
+ end
66
+ end
67
+ end
data/lib/claw/init.rb ADDED
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module Claw
7
+ # Scaffolds a new Claw project in the current directory.
8
+ #
9
+ # Creates .ruby-claw/ with:
10
+ # gems/ — cloned ruby-claw and ruby-mana source (editable)
11
+ # system_prompt.md — default agent personality (customizable)
12
+ # MEMORY.md — empty long-term memory
13
+ # .git/ — git repo for filesystem snapshots
14
+ #
15
+ # Also generates a project-root Gemfile pointing to local gem copies.
16
+ module Init
17
+ GITHUB_CLAW = "https://github.com/twokidsCarl/ruby-claw.git"
18
+ GITHUB_MANA = "https://github.com/twokidsCarl/ruby-mana.git"
19
+
20
+ class << self
21
+ # Run the full init sequence.
22
+ #
23
+ # @param dir [String] project root (defaults to pwd)
24
+ # @param stdout [IO] output stream for progress messages
25
+ def run(dir: Dir.pwd, stdout: $stdout)
26
+ claw_dir = File.join(dir, ".ruby-claw")
27
+
28
+ if File.directory?(claw_dir) && !Dir.empty?(claw_dir)
29
+ stdout.puts " ⚠ .ruby-claw/ already exists — skipping init"
30
+ return false
31
+ end
32
+
33
+ FileUtils.mkdir_p(claw_dir)
34
+
35
+ clone_gems(claw_dir, stdout)
36
+ write_gemfile(dir, stdout)
37
+ write_system_prompt(claw_dir, stdout)
38
+ write_memory(claw_dir, stdout)
39
+ create_roles(claw_dir, stdout)
40
+ create_tools_dir(claw_dir, stdout)
41
+ git_init(claw_dir, stdout)
42
+
43
+ stdout.puts " ✓ claw init complete"
44
+ stdout.puts " Run `bundle install` to use local gem copies"
45
+ true
46
+ end
47
+
48
+ private
49
+
50
+ # Clone ruby-claw and ruby-mana into .ruby-claw/gems/
51
+ def clone_gems(claw_dir, stdout)
52
+ gems_dir = File.join(claw_dir, "gems")
53
+ FileUtils.mkdir_p(gems_dir)
54
+
55
+ [
56
+ ["ruby-claw", GITHUB_CLAW],
57
+ ["ruby-mana", GITHUB_MANA]
58
+ ].each do |name, url|
59
+ target = File.join(gems_dir, name)
60
+ if Dir.exist?(target)
61
+ stdout.puts " · #{name} already cloned"
62
+ next
63
+ end
64
+ stdout.puts " ↓ cloning #{name}..."
65
+ out, status = Open3.capture2e("git", "clone", "--depth=1", url, target)
66
+ unless status.success?
67
+ raise "Failed to clone #{name}: #{out}"
68
+ end
69
+ end
70
+ end
71
+
72
+ # Generate a Gemfile in the project root with path references to local gems.
73
+ def write_gemfile(dir, stdout)
74
+ path = File.join(dir, "Gemfile")
75
+ if File.exist?(path)
76
+ stdout.puts " · Gemfile already exists — skipping"
77
+ stdout.puts " Add these lines manually:"
78
+ stdout.puts ' gem "ruby-claw", path: ".ruby-claw/gems/ruby-claw"'
79
+ stdout.puts ' gem "ruby-mana", path: ".ruby-claw/gems/ruby-mana"'
80
+ return
81
+ end
82
+
83
+ content = <<~RUBY
84
+ source "https://rubygems.org"
85
+
86
+ gem "ruby-claw", path: ".ruby-claw/gems/ruby-claw"
87
+ gem "ruby-mana", path: ".ruby-claw/gems/ruby-mana"
88
+ gem "dotenv"
89
+ RUBY
90
+
91
+ File.write(path, content)
92
+ stdout.puts " ✓ Gemfile created"
93
+ end
94
+
95
+ # Write the default system prompt template.
96
+ def write_system_prompt(claw_dir, stdout)
97
+ path = File.join(claw_dir, "system_prompt.md")
98
+ File.write(path, default_system_prompt)
99
+ stdout.puts " ✓ system_prompt.md created"
100
+ end
101
+
102
+ # Create an empty MEMORY.md.
103
+ def write_memory(claw_dir, stdout)
104
+ path = File.join(claw_dir, "MEMORY.md")
105
+ File.write(path, "# Long-term Memory\n")
106
+ stdout.puts " ✓ MEMORY.md created"
107
+ end
108
+
109
+ # Create roles/ directory with a default role.
110
+ def create_roles(claw_dir, stdout)
111
+ roles_dir = File.join(claw_dir, "roles")
112
+ FileUtils.mkdir_p(roles_dir)
113
+ default_path = File.join(roles_dir, "default.md")
114
+ File.write(default_path, <<~ROLE)
115
+ # Default Role
116
+
117
+ You are a helpful Ruby assistant with access to the runtime binding.
118
+ Help the user analyze data, write code, and manage their Ruby environment.
119
+ ROLE
120
+ stdout.puts " ✓ roles/ directory created"
121
+ end
122
+
123
+ # Create tools/ directory for project tools.
124
+ def create_tools_dir(claw_dir, stdout)
125
+ tools_dir = File.join(claw_dir, "tools")
126
+ FileUtils.mkdir_p(tools_dir)
127
+ readme = File.join(tools_dir, "README.md")
128
+ File.write(readme, <<~MD) unless File.exist?(readme)
129
+ # Project Tools
130
+
131
+ Place `Claw::Tool` class files here. They will be indexed at startup
132
+ and available via `search_tools` / `load_tool`.
133
+
134
+ Example:
135
+ ```ruby
136
+ class MyTool
137
+ include Claw::Tool
138
+ tool_name "my_tool"
139
+ description "Does something useful"
140
+ parameter :input, type: "String", required: true, desc: "The input"
141
+
142
+ def call(input:)
143
+ "Result: \#{input}"
144
+ end
145
+ end
146
+ ```
147
+ MD
148
+ stdout.puts " ✓ tools/ directory created"
149
+ end
150
+
151
+ # Initialize a git repo in .ruby-claw/ with an initial commit.
152
+ def git_init(claw_dir, stdout)
153
+ if Dir.exist?(File.join(claw_dir, ".git"))
154
+ stdout.puts " · git already initialized"
155
+ return
156
+ end
157
+
158
+ run_git(claw_dir, "init")
159
+ run_git(claw_dir, "add", "-A")
160
+ run_git(claw_dir, "commit", "-m", "claw init", "--allow-empty")
161
+ stdout.puts " ✓ git initialized with initial snapshot"
162
+ end
163
+
164
+ def run_git(dir, *args)
165
+ out, status = Open3.capture2e("git", "-C", dir, *args)
166
+ raise "git #{args.first} failed: #{out}" unless status.success?
167
+ out
168
+ end
169
+
170
+ def default_system_prompt
171
+ <<~MD
172
+ # System Prompt
173
+
174
+ You are a helpful AI assistant embedded in a Ruby runtime.
175
+ You have full access to the Ruby environment through tools.
176
+
177
+ ## Personality
178
+
179
+ - Be concise and direct
180
+ - Show code when helpful
181
+ - Explain your reasoning when the task is non-trivial
182
+ - Match the user's language (Chinese → Chinese, English → English)
183
+
184
+ ## Guidelines
185
+
186
+ - Use read_var/write_var for variable access
187
+ - Use call_func for calling Ruby methods
188
+ - Use eval only for defining new methods or requiring libraries
189
+ - Always return a result via the done tool
190
+ - Use the knowledge tool when unsure about your capabilities
191
+
192
+ ## Custom Instructions
193
+
194
+ Add your project-specific instructions here.
195
+ MD
196
+ end
197
+ end
198
+ end
199
+ end
@@ -25,7 +25,11 @@ module Claw
25
25
  "compaction" => compaction,
26
26
  "session" => session,
27
27
  "serializer" => serializer,
28
- "persistence" => session
28
+ "persistence" => session,
29
+ "tools" => tools_section,
30
+ "search_tools" => tools_section,
31
+ "load_tool" => tools_section,
32
+ "forge" => tools_section
29
33
  }
30
34
  end
31
35
 
@@ -69,13 +73,43 @@ module Claw
69
73
  TEXT
70
74
  end
71
75
 
76
+ def tools_section
77
+ <<~TEXT
78
+ Tool system in Claw (three layers):
79
+
80
+ 1. Core tools (always loaded): read_var, write_var, call_func, eval, etc. + remember
81
+ 2. Project tools (on-demand): .ruby-claw/tools/*.rb — indexed at startup, loaded via load_tool
82
+ 3. Hub tools (remote): community tools from ruby-claw-toolhub, downloaded on demand
83
+
84
+ Key tools for discovery:
85
+ - search_tools: search available project/hub tools by keyword
86
+ - load_tool: load a discovered tool into the current session
87
+
88
+ Creating tools:
89
+ - Write a class including Claw::Tool with tool_name, description, parameter DSL
90
+ - Place in .ruby-claw/tools/
91
+ - Or use /forge to promote an eval-defined method to a formal tool
92
+
93
+ Tool class structure:
94
+ class MyTool
95
+ include Claw::Tool
96
+ tool_name "my_tool"
97
+ description "What it does"
98
+ parameter :input, type: "String", required: true, desc: "..."
99
+ def call(input:)
100
+ # logic
101
+ end
102
+ end
103
+ TEXT
104
+ end
105
+
72
106
  def serializer
73
107
  <<~TEXT
74
108
  Runtime state serialization in Claw:
75
109
  Claw::Serializer can save and restore local variables and method definitions.
76
110
  - Claw::Serializer.save(binding, dir) — saves values.json + definitions.rb
77
111
  - Claw::Serializer.restore(binding, dir) — restores from saved files
78
- - Values: Marshal.dump (hex encoded) with JSON fallback
112
+ - Values: MarshalMd.dump (human-readable Markdown) with JSON fallback
79
113
  - Definitions: tracked via @__claw_definitions__ on the receiver
80
114
  TEXT
81
115
  end
@@ -7,7 +7,7 @@ module Claw
7
7
  # Replaces JSON memory/session files with human-readable Markdown.
8
8
  #
9
9
  # Directory layout:
10
- # .mana/
10
+ # .ruby-claw/
11
11
  # MEMORY.md — long-term memory
12
12
  # session.md — session summary
13
13
  # values.json — kept as-is (Marshal data)
@@ -72,7 +72,7 @@ module Claw
72
72
  private
73
73
 
74
74
  def base_dir
75
- @base_path || Mana.config.memory_path || File.join(Dir.pwd, ".mana")
75
+ @base_path || Mana.config.memory_path || File.join(Dir.pwd, ".ruby-claw")
76
76
  end
77
77
 
78
78
  def memory_md_path
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Plan Mode: two-phase plan-then-execute workflow.
5
+ #
6
+ # Phase 1 (plan): LLM outputs a step-by-step plan without executing tools.
7
+ # Phase 2 (execute): After user confirmation, execute inside a fork for safety.
8
+ class PlanMode
9
+ STATES = %i[inactive ready planning reviewing executing].freeze
10
+
11
+ attr_reader :pending_plan, :state
12
+
13
+ def initialize(runtime)
14
+ @runtime = runtime
15
+ @state = :inactive
16
+ @pending_plan = nil
17
+ end
18
+
19
+ def active? = @state != :inactive
20
+ def pending? = @state == :reviewing
21
+
22
+ def toggle!
23
+ if @state == :inactive
24
+ @state = :ready # activated, waiting for plan! call
25
+ true
26
+ else
27
+ discard!
28
+ false
29
+ end
30
+ end
31
+
32
+ # Phase 1: Generate a plan (no tool execution).
33
+ #
34
+ # @param prompt [String] user's task description
35
+ # @param caller_binding [Binding] for binding context
36
+ # @param on_text [Proc, nil] streaming callback
37
+ # @return [String] the plan text
38
+ def plan!(prompt, caller_binding, &on_text)
39
+ @state = :planning
40
+
41
+ binding_md = @runtime&.resources&.dig("binding")&.to_md || "(no binding)"
42
+ memory_md = @runtime&.resources&.dig("memory")&.to_md || "(no memory)"
43
+
44
+ planning_prompt = <<~PROMPT
45
+ The user wants: #{prompt}
46
+
47
+ Current binding state:
48
+ #{binding_md}
49
+
50
+ Current memory:
51
+ #{memory_md}
52
+
53
+ Output ONLY a step-by-step plan describing what tools you would use and in what order.
54
+ Do NOT call any tools. Do NOT execute anything.
55
+ Format as a numbered list. For each step, specify:
56
+ - Which tool to use
57
+ - On what target (variable/method/etc.)
58
+ - Expected result
59
+ PROMPT
60
+
61
+ engine = Mana::Engine.new(caller_binding)
62
+ # Execute with empty tools array so LLM cannot call any tools
63
+ plan_text = engine.execute(planning_prompt, &on_text)
64
+
65
+ @pending_plan = {
66
+ prompt: prompt,
67
+ plan_text: plan_text.to_s,
68
+ created_at: Time.now
69
+ }
70
+ @state = :reviewing
71
+ @pending_plan[:plan_text]
72
+ end
73
+
74
+ # Phase 2: Execute the approved plan inside a fork for safety.
75
+ #
76
+ # @param caller_binding [Binding]
77
+ # @param edited_plan [String, nil] user-edited plan text (optional)
78
+ # @param on_text [Proc, nil] streaming callback
79
+ # @return [Array] [success, result] from Runtime#fork
80
+ def execute!(caller_binding, edited_plan: nil, &on_text)
81
+ raise "No pending plan" unless @state == :reviewing
82
+
83
+ @state = :executing
84
+ plan_text = edited_plan || @pending_plan[:plan_text]
85
+ prompt = @pending_plan[:prompt]
86
+ @pending_plan = nil
87
+
88
+ result = @runtime.fork(label: "plan_execution") do
89
+ engine = Mana::Engine.new(caller_binding)
90
+ engine.execute(<<~EXEC, &on_text)
91
+ Execute this task: #{prompt}
92
+
93
+ Your approved plan:
94
+ #{plan_text}
95
+
96
+ Follow the plan step by step. Use the available tools to complete each step.
97
+ EXEC
98
+ end
99
+
100
+ @state = :inactive
101
+ result
102
+ end
103
+
104
+ # Discard the pending plan.
105
+ def discard!
106
+ @pending_plan = nil
107
+ @state = :inactive
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Interface for reversible resources managed by Claw::Runtime.
5
+ # All resources that participate in snapshot/rollback must include this module
6
+ # and implement the required methods.
7
+ module Resource
8
+ # Capture current state. Returns an opaque token for later rollback.
9
+ def snapshot!
10
+ raise NotImplementedError, "#{self.class}#snapshot! not implemented"
11
+ end
12
+
13
+ # Restore state to a previous snapshot token.
14
+ # Implementations must guarantee success — partial rollback is not acceptable.
15
+ def rollback!(token)
16
+ raise NotImplementedError, "#{self.class}#rollback! not implemented"
17
+ end
18
+
19
+ # Compare two snapshot tokens. Returns a human-readable diff string.
20
+ def diff(token_a, token_b)
21
+ raise NotImplementedError, "#{self.class}#diff not implemented"
22
+ end
23
+
24
+ # Render current state as Markdown for human/LLM consumption.
25
+ def to_md
26
+ raise NotImplementedError, "#{self.class}#to_md not implemented"
27
+ end
28
+
29
+ # Merge changes from another resource instance (e.g., child → parent).
30
+ # Used by V8 Multi-Agent to selectively merge child results back.
31
+ def merge_from!(other)
32
+ raise NotImplementedError, "#{self.class}#merge_from! not implemented"
33
+ end
34
+ end
35
+ end