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
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require "json"
5
+
6
+ module Claw
7
+ module Console
8
+ # Local web server for agent observability and operations.
9
+ # Serves the console UI and provides API endpoints.
10
+ class Server < Sinatra::Base
11
+ set :views, File.join(__dir__, "views")
12
+ set :public_folder, File.join(__dir__, "public")
13
+ set :bind, "127.0.0.1"
14
+ set :port, 4567
15
+ set :server, :webrick
16
+
17
+ # Allow all hosts in development/testing (console is localhost-only)
18
+ set :host_authorization, { permitted_hosts: [] }
19
+
20
+ # Shared state — configured before starting
21
+ class << self
22
+ attr_accessor :event_logger, :runtime, :memory_instance, :claw_dir
23
+ end
24
+
25
+ # Configure the server with runtime references.
26
+ def self.setup(claw_dir:, runtime: nil, memory: nil, port: 4567)
27
+ self.claw_dir = claw_dir
28
+ self.runtime = runtime
29
+ self.memory_instance = memory
30
+ self.event_logger = EventLogger.new(File.join(claw_dir, "log"))
31
+ set :port, port
32
+ end
33
+
34
+ # --- Pages ---
35
+
36
+ get "/" do
37
+ erb :index
38
+ end
39
+
40
+ get "/prompt" do
41
+ erb :prompt
42
+ end
43
+
44
+ get "/monitor" do
45
+ erb :monitor
46
+ end
47
+
48
+ get "/traces" do
49
+ erb :traces
50
+ end
51
+
52
+ get "/memory" do
53
+ erb :memory
54
+ end
55
+
56
+ get "/tools" do
57
+ erb :tools
58
+ end
59
+
60
+ get "/snapshots" do
61
+ erb :snapshots
62
+ end
63
+
64
+ get "/experiments" do
65
+ erb :experiments
66
+ end
67
+
68
+ # --- API Endpoints ---
69
+
70
+ get "/api/status" do
71
+ content_type :json
72
+ {
73
+ version: Claw::VERSION,
74
+ state: self.class.runtime&.state,
75
+ snapshot_count: self.class.runtime&.snapshots&.size || 0,
76
+ memory_count: self.class.memory_instance&.long_term&.size || 0,
77
+ tool_count: Mana.registered_tools.size,
78
+ event_count: self.class.event_logger&.count || 0
79
+ }.to_json
80
+ end
81
+
82
+ get "/api/events" do
83
+ content_type "text/event-stream"
84
+ cache_control :no_cache
85
+
86
+ stream(:keep_open) do |out|
87
+ SSE.stream_events(out, self.class.event_logger)
88
+ end
89
+ end
90
+
91
+ get "/api/traces" do
92
+ content_type :json
93
+ traces_dir = File.join(self.class.claw_dir, "traces")
94
+ unless Dir.exist?(traces_dir)
95
+ return [].to_json
96
+ end
97
+
98
+ files = Dir.glob(File.join(traces_dir, "*.md")).sort.reverse.first(50)
99
+ files.map do |f|
100
+ { id: File.basename(f, ".md"), filename: File.basename(f),
101
+ size: File.size(f), modified: File.mtime(f).iso8601 }
102
+ end.to_json
103
+ end
104
+
105
+ get "/api/traces/:id" do
106
+ content_type :json
107
+ halt 400, { error: "Invalid trace ID" }.to_json unless params[:id] =~ /\A[a-zA-Z0-9_\-]+\z/
108
+
109
+ path = File.join(self.class.claw_dir, "traces", "#{params[:id]}.md")
110
+ halt 404, { error: "Trace not found" }.to_json unless File.exist?(path)
111
+
112
+ { id: params[:id], content: File.read(path) }.to_json
113
+ end
114
+
115
+ get "/api/memory" do
116
+ content_type :json
117
+ mem = self.class.memory_instance
118
+ unless mem
119
+ return [].to_json
120
+ end
121
+
122
+ mem.long_term.to_json
123
+ end
124
+
125
+ get "/api/prompt" do
126
+ content_type :json
127
+ prompt_path = File.join(self.class.claw_dir, "system_prompt.md")
128
+ content = File.exist?(prompt_path) ? File.read(prompt_path) : ""
129
+
130
+ sections = Mana.instance_variable_get(:@prompt_sections)&.filter_map(&:call) || []
131
+
132
+ { template: content, sections: sections }.to_json
133
+ end
134
+
135
+ get "/api/prompt/sections" do
136
+ content_type :json
137
+ sections = Mana.instance_variable_get(:@prompt_sections)&.filter_map(&:call) || []
138
+ sections.to_json
139
+ end
140
+
141
+ get "/api/tools" do
142
+ content_type :json
143
+ registry = Claw.tool_registry
144
+ core_tools = Mana.registered_tools.map { |t| { name: t[:name], description: t[:description], source: "core" } }
145
+
146
+ project_tools = registry ? registry.index.entries.map do |e|
147
+ { name: e.name, description: e.description, source: "project",
148
+ loaded: registry.loaded?(e.name) }
149
+ end : []
150
+
151
+ { core: core_tools, project: project_tools }.to_json
152
+ end
153
+
154
+ # --- Helpers ---
155
+
156
+ helpers do
157
+ def parse_json!
158
+ data = JSON.parse(request.body.read, symbolize_names: true)
159
+ data
160
+ rescue JSON::ParserError
161
+ halt 400, { error: "Invalid JSON" }.to_json
162
+ end
163
+
164
+ def require_field!(data, field)
165
+ halt 400, { error: "Missing field: #{field}" }.to_json unless data[field]
166
+ end
167
+ end
168
+
169
+ # --- Mutation API ---
170
+
171
+ post "/api/memory" do
172
+ content_type :json
173
+ data = parse_json!
174
+ require_field!(data, :content)
175
+ mem = self.class.memory_instance
176
+ halt 400, { error: "Memory not available" }.to_json unless mem
177
+
178
+ entry = mem.remember(data[:content])
179
+ { success: true, entry: entry }.to_json
180
+ end
181
+
182
+ delete "/api/memory/:id" do
183
+ content_type :json
184
+ halt 400, { error: "Invalid ID" }.to_json unless params[:id] =~ /\A\d+\z/
185
+ mem = self.class.memory_instance
186
+ halt 400, { error: "Memory not available" }.to_json unless mem
187
+
188
+ mem.forget(id: params[:id].to_i)
189
+ { success: true }.to_json
190
+ end
191
+
192
+ post "/api/prompt" do
193
+ content_type :json
194
+ data = parse_json!
195
+ require_field!(data, :content)
196
+ path = File.join(self.class.claw_dir, "system_prompt.md")
197
+ File.write(path, data[:content])
198
+ { success: true }.to_json
199
+ end
200
+
201
+ post "/api/tools/load" do
202
+ content_type :json
203
+ data = parse_json!
204
+ require_field!(data, :name)
205
+ registry = Claw.tool_registry
206
+ halt 400, { error: "Tool registry not available" }.to_json unless registry
207
+
208
+ msg = registry.load(data[:name])
209
+ { success: true, message: msg }.to_json
210
+ end
211
+
212
+ post "/api/tools/unload" do
213
+ content_type :json
214
+ data = parse_json!
215
+ require_field!(data, :name)
216
+ registry = Claw.tool_registry
217
+ halt 400, { error: "Tool registry not available" }.to_json unless registry
218
+
219
+ msg = registry.unload(data[:name])
220
+ { success: true, message: msg }.to_json
221
+ end
222
+
223
+ post "/api/snapshots" do
224
+ content_type :json
225
+ runtime = self.class.runtime
226
+ halt 400, { error: "Runtime not available" }.to_json unless runtime
227
+
228
+ id = runtime.snapshot!(label: "console")
229
+ { success: true, id: id }.to_json
230
+ end
231
+
232
+ post "/api/snapshots/:id/rollback" do
233
+ content_type :json
234
+ halt 400, { error: "Invalid ID" }.to_json unless params[:id] =~ /\A\d+\z/
235
+ runtime = self.class.runtime
236
+ halt 400, { error: "Runtime not available" }.to_json unless runtime
237
+
238
+ runtime.rollback!(params[:id].to_i)
239
+ { success: true }.to_json
240
+ end
241
+
242
+ get "/api/snapshots" do
243
+ content_type :json
244
+ runtime = self.class.runtime
245
+ return [].to_json unless runtime
246
+
247
+ runtime.snapshots.map do |s|
248
+ { id: s.id, label: s.label, timestamp: s.timestamp }
249
+ end.to_json
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module Console
5
+ # Server-Sent Events helper for streaming events to the browser.
6
+ module SSE
7
+ # Stream events from the event logger to a Sinatra stream block.
8
+ #
9
+ # @param stream [Object] Sinatra stream object (responds to <<)
10
+ # @param logger [EventLogger] the event source
11
+ # @param poll_interval [Float] seconds between polls
12
+ def self.stream_events(stream, logger, poll_interval: 0.5)
13
+ last_timestamp = nil
14
+
15
+ loop do
16
+ events = logger.tail(since: last_timestamp)
17
+ events.each do |event|
18
+ stream << "data: #{JSON.generate(event)}\n\n"
19
+ last_timestamp = event[:timestamp]
20
+ end
21
+ sleep(poll_interval)
22
+ end
23
+ rescue IOError
24
+ # Client disconnected
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,8 @@
1
+ <h1>Experiments</h1>
2
+ <p class="muted">Experiment platform allows forking runtime state, running prompt variations, and comparing results.</p>
3
+ <div class="experiment-controls">
4
+ <button class="btn" id="new-experiment" onclick="newExperiment()">New Experiment</button>
5
+ </div>
6
+ <div id="experiments-list">
7
+ <p class="muted">No experiments yet.</p>
8
+ </div>
@@ -0,0 +1,27 @@
1
+ <h1>Dashboard</h1>
2
+ <div class="cards" id="dashboard-cards">
3
+ <div class="card">
4
+ <div class="card-label">Version</div>
5
+ <div class="card-value" id="stat-version">—</div>
6
+ </div>
7
+ <div class="card">
8
+ <div class="card-label">Tools</div>
9
+ <div class="card-value" id="stat-tools">—</div>
10
+ </div>
11
+ <div class="card">
12
+ <div class="card-label">Memories</div>
13
+ <div class="card-value" id="stat-memories">—</div>
14
+ </div>
15
+ <div class="card">
16
+ <div class="card-label">Snapshots</div>
17
+ <div class="card-value" id="stat-snapshots">—</div>
18
+ </div>
19
+ <div class="card">
20
+ <div class="card-label">Events</div>
21
+ <div class="card-value" id="stat-events">—</div>
22
+ </div>
23
+ </div>
24
+ <h2>Recent Events</h2>
25
+ <div class="event-feed" id="dashboard-events">
26
+ <p class="muted">No events yet.</p>
27
+ </div>
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Claw Console</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <div class="logo">claw</div>
12
+ <nav>
13
+ <a href="/" class="nav-link">Dashboard</a>
14
+ <a href="/prompt" class="nav-link">Prompt</a>
15
+ <a href="/monitor" class="nav-link">Monitor</a>
16
+ <a href="/traces" class="nav-link">Traces</a>
17
+ <a href="/memory" class="nav-link">Memory</a>
18
+ <a href="/tools" class="nav-link">Tools</a>
19
+ <a href="/snapshots" class="nav-link">Snapshots</a>
20
+ <a href="/experiments" class="nav-link">Experiments</a>
21
+ </nav>
22
+ <div class="header-status" id="header-status"></div>
23
+ </header>
24
+ <main>
25
+ <%= yield %>
26
+ </main>
27
+ <script src="/app.js"></script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,13 @@
1
+ <h1>Memory</h1>
2
+ <div class="memory-controls">
3
+ <input type="text" id="memory-input" placeholder="Add a new memory..." class="text-input">
4
+ <button class="btn" onclick="addMemory()">Remember</button>
5
+ </div>
6
+ <table class="data-table" id="memory-table">
7
+ <thead>
8
+ <tr><th>ID</th><th>Content</th><th>Created</th><th></th></tr>
9
+ </thead>
10
+ <tbody id="memory-body">
11
+ <tr><td colspan="4" class="muted">Loading...</td></tr>
12
+ </tbody>
13
+ </table>
@@ -0,0 +1,15 @@
1
+ <h1>LLM Monitor</h1>
2
+ <div class="monitor-controls">
3
+ <label><input type="checkbox" id="auto-scroll" checked> Auto-scroll</label>
4
+ <select id="event-filter">
5
+ <option value="">All events</option>
6
+ <option value="llm_call">LLM calls</option>
7
+ <option value="tool_call">Tool calls</option>
8
+ <option value="snapshot">Snapshots</option>
9
+ <option value="trace">Traces</option>
10
+ </select>
11
+ <span class="muted" id="event-count">0 events</span>
12
+ </div>
13
+ <div class="event-stream" id="event-stream">
14
+ <p class="muted">Connecting to event stream...</p>
15
+ </div>
@@ -0,0 +1,15 @@
1
+ <h1>Prompt Inspector</h1>
2
+ <div class="prompt-section">
3
+ <h2>System Prompt Template</h2>
4
+ <div class="prompt-editor">
5
+ <textarea id="prompt-template" rows="15" spellcheck="false"></textarea>
6
+ <button class="btn" id="save-prompt" onclick="savePrompt()">Save</button>
7
+ <span id="prompt-status" class="muted"></span>
8
+ </div>
9
+ </div>
10
+ <div class="prompt-section">
11
+ <h2>Dynamic Sections</h2>
12
+ <div id="prompt-sections" class="section-list">
13
+ <p class="muted">Loading...</p>
14
+ </div>
15
+ </div>
@@ -0,0 +1,12 @@
1
+ <h1>Snapshots</h1>
2
+ <div class="snapshot-controls">
3
+ <button class="btn" onclick="createSnapshot()">Take Snapshot</button>
4
+ </div>
5
+ <table class="data-table" id="snapshot-table">
6
+ <thead>
7
+ <tr><th>ID</th><th>Label</th><th>Timestamp</th><th></th></tr>
8
+ </thead>
9
+ <tbody id="snapshot-body">
10
+ <tr><td colspan="4" class="muted">Loading...</td></tr>
11
+ </tbody>
12
+ </table>
@@ -0,0 +1,13 @@
1
+ <h1>Tools</h1>
2
+ <div class="tools-section">
3
+ <h2>Core Tools</h2>
4
+ <div id="core-tools" class="tool-grid">
5
+ <p class="muted">Loading...</p>
6
+ </div>
7
+ </div>
8
+ <div class="tools-section">
9
+ <h2>Project Tools</h2>
10
+ <div id="project-tools" class="tool-grid">
11
+ <p class="muted">Loading...</p>
12
+ </div>
13
+ </div>
@@ -0,0 +1,9 @@
1
+ <h1>Trace Explorer</h1>
2
+ <div class="trace-layout">
3
+ <div class="trace-list" id="trace-list">
4
+ <p class="muted">Loading traces...</p>
5
+ </div>
6
+ <div class="trace-detail" id="trace-detail">
7
+ <p class="muted">Select a trace to view its content.</p>
8
+ </div>
9
+ </div>
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "console/event_logger"
4
+ require_relative "console/sse"
5
+ require_relative "console/server"
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+
6
+ module Claw
7
+ # Self-evolution loop: reads execution traces, uses LLM to diagnose
8
+ # improvements, forks runtime to apply changes, scores via test suite,
9
+ # and keeps or discards the change atomically.
10
+ #
11
+ # Depends on:
12
+ # - v3 Runtime (fork/rollback)
13
+ # - v5.1 Traces (.ruby-claw/traces/)
14
+ # - v5.2 claw init (.ruby-claw/gems/ editable source)
15
+ class Evolution
16
+ class RejectError < StandardError; end
17
+
18
+ DIAGNOSIS_SYSTEM = "You are a code improvement agent. Analyze execution traces and propose precise code changes. Respond only with valid JSON."
19
+
20
+ DIAGNOSIS_PROMPT = <<~PROMPT
21
+ Review these execution traces from a Ruby agent framework and propose ONE specific code change that would improve:
22
+ - Response quality (better tool use, fewer iterations)
23
+ - Performance (fewer tokens, lower latency)
24
+ - Robustness (better error handling, edge cases)
25
+
26
+ Respond with a JSON object:
27
+ {
28
+ "summary": "Brief description of the change",
29
+ "gem": "ruby-claw or ruby-mana",
30
+ "file": "relative/path/to/file.rb",
31
+ "old_code": "exact existing code to replace (copy-paste from source)",
32
+ "new_code": "replacement code",
33
+ "rationale": "why this improves the agent"
34
+ }
35
+
36
+ If no meaningful improvements can be made, respond with:
37
+ {"summary": "no changes needed"}
38
+
39
+ IMPORTANT: old_code must be an exact substring of the file. Be precise.
40
+ PROMPT
41
+
42
+ attr_reader :results
43
+
44
+ # @param runtime [Claw::Runtime] the reversible runtime
45
+ # @param claw_dir [String] path to .ruby-claw/
46
+ # @param config [Mana::Config] LLM configuration
47
+ def initialize(runtime:, claw_dir:, config: Mana.config)
48
+ @runtime = runtime
49
+ @claw_dir = claw_dir
50
+ @gems_dir = File.join(claw_dir, "gems")
51
+ @config = config
52
+ @results = []
53
+ end
54
+
55
+ # Run one evolution cycle: diagnose → propose → test → keep/discard.
56
+ # Returns a result hash with :status, :proposal, :reason.
57
+ def evolve
58
+ traces = load_recent_traces
59
+ if traces.empty?
60
+ return log_result(status: :skip, reason: "no traces found")
61
+ end
62
+
63
+ unless Dir.exist?(@gems_dir)
64
+ return log_result(status: :skip, reason: "no gems/ directory — run `claw init` first")
65
+ end
66
+
67
+ proposal = diagnose(traces)
68
+ if proposal[:file].nil?
69
+ return log_result(status: :skip, reason: proposal[:summary])
70
+ end
71
+
72
+ try_proposal(proposal)
73
+ end
74
+
75
+ # Load recent trace files as strings.
76
+ def load_recent_traces(limit: 5)
77
+ dir = File.join(@claw_dir, "traces")
78
+ return [] unless Dir.exist?(dir)
79
+
80
+ Dir.glob(File.join(dir, "*.md"))
81
+ .sort_by { |f| File.mtime(f) }
82
+ .last(limit)
83
+ .map { |f| File.read(f) }
84
+ end
85
+
86
+ # Send traces to LLM for diagnosis. Returns a proposal hash.
87
+ def diagnose(traces)
88
+ prompt = DIAGNOSIS_PROMPT + "\n\n## Recent Traces\n\n" + traces.join("\n\n---\n\n")
89
+
90
+ backend = Mana::Backends::Base.for(@config)
91
+ response = backend.chat(
92
+ system: DIAGNOSIS_SYSTEM,
93
+ messages: [{ role: "user", content: prompt }],
94
+ tools: [],
95
+ model: @config.model
96
+ )
97
+
98
+ text = extract_text(response[:content])
99
+ parse_proposal(text)
100
+ rescue => e
101
+ { summary: "diagnosis failed: #{e.message}" }
102
+ end
103
+
104
+ # Attempt to apply a proposal inside a runtime fork.
105
+ def try_proposal(proposal)
106
+ gem_name = proposal[:gem] || "ruby-claw"
107
+ file_path = File.join(@gems_dir, gem_name, proposal[:file])
108
+
109
+ unless File.exist?(file_path)
110
+ return log_result(status: :reject, proposal: proposal[:summary],
111
+ reason: "file not found: #{proposal[:file]}")
112
+ end
113
+
114
+ content = File.read(file_path)
115
+ unless content.include?(proposal[:old_code])
116
+ return log_result(status: :reject, proposal: proposal[:summary],
117
+ reason: "old_code not found in #{proposal[:file]}")
118
+ end
119
+
120
+ success, result = @runtime.fork(label: "evolve: #{proposal[:summary]}") do
121
+ # Apply the change
122
+ modified = content.sub(proposal[:old_code], proposal[:new_code])
123
+ File.write(file_path, modified)
124
+
125
+ # Score: run tests
126
+ score = run_tests(gem_name)
127
+ unless score[:passed]
128
+ raise RejectError, "tests failed:\n#{score[:output].to_s[0, 500]}"
129
+ end
130
+
131
+ score
132
+ end
133
+
134
+ if success
135
+ # Write evolution log
136
+ write_evolution_log(proposal, :accept, result)
137
+ log_result(status: :accept, proposal: proposal[:summary],
138
+ rationale: proposal[:rationale])
139
+ else
140
+ write_evolution_log(proposal, :reject, result)
141
+ log_result(status: :reject, proposal: proposal[:summary],
142
+ reason: result.is_a?(Exception) ? result.message : result.to_s)
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def extract_text(content)
149
+ return content.to_s unless content.is_a?(Array)
150
+ content.filter_map { |b| b[:text] || b["text"] }.join
151
+ end
152
+
153
+ def parse_proposal(text)
154
+ json_match = text.match(/\{[\s\S]*\}/)
155
+ return { summary: "no JSON in response" } unless json_match
156
+
157
+ parsed = JSON.parse(json_match[0], symbolize_names: true)
158
+ parsed
159
+ rescue JSON::ParserError
160
+ { summary: "failed to parse proposal JSON" }
161
+ end
162
+
163
+ def run_tests(gem_name)
164
+ gem_dir = File.join(@gems_dir, gem_name)
165
+ return { passed: true, output: "no gem directory" } unless Dir.exist?(gem_dir)
166
+
167
+ # Check if rspec is available
168
+ gemfile = File.join(gem_dir, "Gemfile")
169
+ unless File.exist?(gemfile)
170
+ return { passed: true, output: "no Gemfile — skipping tests" }
171
+ end
172
+
173
+ out, status = Open3.capture2e(
174
+ "bundle", "exec", "rspec", "--format", "progress",
175
+ chdir: gem_dir
176
+ )
177
+ { passed: status.success?, output: out }
178
+ rescue Errno::ENOENT
179
+ # bundle/rspec not found
180
+ { passed: true, output: "rspec not available — skipping" }
181
+ end
182
+
183
+ def write_evolution_log(proposal, status, result)
184
+ log_dir = File.join(@claw_dir, "evolution")
185
+ FileUtils.mkdir_p(log_dir)
186
+
187
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
188
+ path = File.join(log_dir, "#{timestamp}_#{status}.md")
189
+
190
+ lines = []
191
+ lines << "# Evolution: #{proposal[:summary]}"
192
+ lines << ""
193
+ lines << "- Status: #{status}"
194
+ lines << "- Gem: #{proposal[:gem]}"
195
+ lines << "- File: #{proposal[:file]}"
196
+ lines << "- Rationale: #{proposal[:rationale]}"
197
+ lines << "- Timestamp: #{Time.now.iso8601}"
198
+ lines << ""
199
+ lines << "## Old Code"
200
+ lines << "```ruby"
201
+ lines << proposal[:old_code].to_s
202
+ lines << "```"
203
+ lines << ""
204
+ lines << "## New Code"
205
+ lines << "```ruby"
206
+ lines << proposal[:new_code].to_s
207
+ lines << "```"
208
+
209
+ if result.is_a?(Hash) && result[:output]
210
+ lines << ""
211
+ lines << "## Test Output"
212
+ lines << "```"
213
+ lines << result[:output].to_s[0, 2000]
214
+ lines << "```"
215
+ end
216
+
217
+ File.write(path, lines.join("\n"))
218
+ rescue => e
219
+ # Don't crash on log failure
220
+ end
221
+
222
+ def log_result(result)
223
+ @results << result
224
+ result
225
+ end
226
+ end
227
+ end