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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Reversible Runtime — manages resources and provides atomic snapshot/rollback.
5
+ #
6
+ # All registered resources are snapshot/rolled-back together. A snapshot captures
7
+ # the state of every resource at a point in time; rollback restores all of them
8
+ # atomically.
9
+ #
10
+ # Resources must be registered at startup. Dynamic registration during execution
11
+ # is not allowed — it would break snapshot consistency.
12
+ class Runtime
13
+ Snapshot = Struct.new(:id, :label, :tokens, :timestamp, keyword_init: true)
14
+ Step = Struct.new(:number, :tool_name, :target, :elapsed_ms, keyword_init: true)
15
+
16
+ STATES = %i[idle thinking executing_tool failed].freeze
17
+
18
+ attr_reader :resources, :snapshots, :events, :state, :current_step, :children
19
+
20
+ def initialize
21
+ @resources = {} # name => resource instance
22
+ @snapshot_data = {} # snapshot_id => { name => token }
23
+ @snapshots = [] # ordered list of Snapshot metadata
24
+ @next_id = 1
25
+ @locked = false
26
+ @events = [] # append-only event log
27
+ @state = :idle
28
+ @current_step = nil
29
+ @state_callbacks = []
30
+ @children = {} # id => ChildRuntime (V8)
31
+ end
32
+
33
+ # Transition the runtime execution state.
34
+ # Fires registered callbacks with (old_state, new_state, step).
35
+ def transition!(new_state, step: nil)
36
+ raise ArgumentError, "Invalid state: #{new_state}" unless STATES.include?(new_state)
37
+
38
+ old = @state
39
+ @state = new_state
40
+ @current_step = step
41
+ @state_callbacks.each { |cb| cb.call(old, new_state, step) }
42
+ end
43
+
44
+ # Register an observer for state transitions.
45
+ def on_state_change(&block)
46
+ @state_callbacks << block
47
+ end
48
+
49
+ # Register a named resource. Must be called before any snapshot.
50
+ # Raises if called after the first snapshot (resources are locked).
51
+ def register(name, resource)
52
+ raise "Cannot register resources after first snapshot" if @locked
53
+ raise ArgumentError, "Resource must include Claw::Resource" unless resource.is_a?(Claw::Resource)
54
+ raise ArgumentError, "Resource '#{name}' already registered" if @resources.key?(name)
55
+
56
+ @resources[name] = resource
57
+ end
58
+
59
+ # Atomic snapshot: captures all registered resources.
60
+ # Returns the snapshot id.
61
+ def snapshot!(label: nil)
62
+ @locked = true
63
+ tokens = {}
64
+ @resources.each do |name, resource|
65
+ tokens[name] = resource.snapshot!
66
+ end
67
+
68
+ snap = Snapshot.new(
69
+ id: @next_id,
70
+ label: label,
71
+ tokens: tokens.values.sum { |t| t.respond_to?(:size) ? t.size : 0 },
72
+ timestamp: Time.now.iso8601
73
+ )
74
+ @snapshot_data[@next_id] = tokens
75
+ @snapshots << snap
76
+ record_event(action: "snapshot", target: "runtime", detail: "id=#{@next_id} label=#{label}")
77
+ @next_id += 1
78
+ snap.id
79
+ end
80
+
81
+ # Atomic rollback: restores all resources to a previous snapshot.
82
+ def rollback!(snap_id)
83
+ tokens = @snapshot_data[snap_id]
84
+ raise ArgumentError, "Unknown snapshot id: #{snap_id}" unless tokens
85
+
86
+ @resources.each do |name, resource|
87
+ resource.rollback!(tokens[name])
88
+ end
89
+ record_event(action: "rollback", target: "runtime", detail: "to snapshot id=#{snap_id}")
90
+ end
91
+
92
+ # Compare two snapshots across all resources.
93
+ # Returns a Hash { resource_name => diff_string }.
94
+ def diff(snap_id_a, snap_id_b)
95
+ tokens_a = @snapshot_data[snap_id_a]
96
+ tokens_b = @snapshot_data[snap_id_b]
97
+ raise ArgumentError, "Unknown snapshot id: #{snap_id_a}" unless tokens_a
98
+ raise ArgumentError, "Unknown snapshot id: #{snap_id_b}" unless tokens_b
99
+
100
+ result = {}
101
+ @resources.each do |name, resource|
102
+ result[name] = resource.diff(tokens_a[name], tokens_b[name])
103
+ end
104
+ result
105
+ end
106
+
107
+ # Fork: snapshot → execute block → rollback on failure.
108
+ # Returns [success, result] tuple.
109
+ def fork(label: nil)
110
+ snap_id = snapshot!(label: label || "fork")
111
+ begin
112
+ result = yield
113
+ [true, result]
114
+ rescue => e
115
+ rollback!(snap_id)
116
+ record_event(action: "fork_rollback", target: "runtime", detail: "#{e.class}: #{e.message}")
117
+ [false, e]
118
+ end
119
+ end
120
+
121
+ # Append an event to the log.
122
+ def record_event(action:, target:, detail: nil)
123
+ @events << {
124
+ timestamp: Time.now.iso8601,
125
+ action: action,
126
+ target: target,
127
+ detail: detail
128
+ }
129
+ end
130
+
131
+ # Fork a child agent that runs in a separate thread with isolated resources.
132
+ # The child can later be merged back via child.merge!
133
+ #
134
+ # @param prompt [String] the task for the child to execute
135
+ # @param vars [Hash] variables to inject into the child's binding
136
+ # @param role [String, nil] optional role name for the child
137
+ # @param model [String, nil] optional model override
138
+ # @return [ChildRuntime]
139
+ def fork_async(prompt:, vars: {}, role: nil, model: nil)
140
+ child = ChildRuntime.new(
141
+ parent: self,
142
+ prompt: prompt,
143
+ vars: vars,
144
+ role: role,
145
+ model: model
146
+ )
147
+ @children[child.id] = child
148
+ child.start!
149
+ record_event(action: "fork_async", target: child.id, detail: prompt[0..80])
150
+ child
151
+ end
152
+
153
+ # Render runtime state as Markdown.
154
+ def to_md
155
+ lines = ["# Runtime State\n"]
156
+
157
+ lines << "## Status"
158
+ lines << "- state: #{@state}"
159
+ if @current_step
160
+ lines << "- step: ##{@current_step.number} #{@current_step.tool_name} (#{@current_step.target})"
161
+ end
162
+ lines << ""
163
+
164
+ lines << "## Resources"
165
+ @resources.each do |name, resource|
166
+ lines << "### #{name}"
167
+ lines << resource.to_md
168
+ lines << ""
169
+ end
170
+
171
+ lines << "## Snapshots"
172
+ if @snapshots.empty?
173
+ lines << "(none)"
174
+ else
175
+ @snapshots.each do |snap|
176
+ lines << "- **##{snap.id}** #{snap.label || '(unlabeled)'} — #{snap.timestamp}"
177
+ end
178
+ end
179
+ lines << ""
180
+
181
+ lines << "## Events (last 20)"
182
+ @events.last(20).each do |ev|
183
+ lines << "- `#{ev[:timestamp]}` #{ev[:action]} #{ev[:target]} #{ev[:detail]}"
184
+ end
185
+
186
+ lines.join("\n")
187
+ end
188
+ end
189
+ end
@@ -61,14 +61,14 @@ module Claw
61
61
  # Corrupted file — skip
62
62
  end
63
63
 
64
- # Encode a value for JSON storage.
65
- # Strategy: try Marshal (hex-encoded), fall back to JSON, skip unserializable.
64
+ # Encode a value for storage.
65
+ # Strategy: try MarshalMd (human-readable Markdown), fall back to JSON, skip unserializable.
66
66
  def encode_value(val)
67
- # Try Marshal first for full Ruby fidelity
68
- marshalled = Marshal.dump(val)
69
- { "type" => "marshal", "data" => marshalled.unpack1("H*") }
67
+ # Try MarshalMd first for full Ruby fidelity + human readability
68
+ md = MarshalMd.dump(val)
69
+ { "type" => "marshal_md", "data" => md }
70
70
  rescue TypeError
71
- # Marshal failed — try JSON for simple types
71
+ # MarshalMd failed — try JSON for simple types
72
72
  begin
73
73
  json = JSON.generate(val)
74
74
  { "type" => "json", "data" => json }
@@ -80,7 +80,10 @@ module Claw
80
80
  # Decode a value from its stored representation.
81
81
  def decode_value(entry)
82
82
  case entry["type"]
83
+ when "marshal_md"
84
+ MarshalMd.load(entry["data"])
83
85
  when "marshal"
86
+ # Backward compatibility: load old binary Marshal data
84
87
  Marshal.load([entry["data"]].pack("H*")) # rubocop:disable Security/MarshalLoad
85
88
  when "json"
86
89
  JSON.parse(entry["data"])
@@ -111,7 +114,7 @@ module Claw
111
114
  return if source.strip.empty?
112
115
 
113
116
  bind.eval(source)
114
- rescue => e
117
+ rescue Exception => e # rubocop:disable Lint/RescueException — SyntaxError is not a StandardError
115
118
  $stderr.puts "Claw::Serializer restore error: #{e.message}" if $DEBUG
116
119
  end
117
120
  end
data/lib/claw/tool.rb ADDED
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Mixin for defining project tools as classes with a declarative DSL.
5
+ #
6
+ # Usage:
7
+ # class FormatReport
8
+ # include Claw::Tool
9
+ # tool_name "format_report"
10
+ # description "Format raw data into a readable report"
11
+ # parameter :data, type: "Hash", required: true, desc: "Raw data"
12
+ # parameter :style, type: "String", required: false, desc: "brief or detailed"
13
+ #
14
+ # def call(data:, style: "brief")
15
+ # # ...
16
+ # end
17
+ # end
18
+ module Tool
19
+ @tool_classes = []
20
+
21
+ def self.tool_classes
22
+ @tool_classes
23
+ end
24
+
25
+ def self.included(base)
26
+ base.extend(ClassMethods)
27
+ base.instance_variable_set(:@tool_parameters, [])
28
+ @tool_classes << base unless @tool_classes.include?(base)
29
+ end
30
+
31
+ module ClassMethods
32
+ def tool_name(name = nil)
33
+ if name
34
+ @tool_name = name
35
+ else
36
+ @tool_name || self.name&.split("::")&.last&.gsub(/([a-z])([A-Z])/, '\1_\2')&.downcase
37
+ end
38
+ end
39
+
40
+ def description(desc = nil)
41
+ if desc
42
+ @tool_description = desc
43
+ else
44
+ @tool_description || ""
45
+ end
46
+ end
47
+
48
+ def parameter(name, type: "String", required: false, desc: "")
49
+ @tool_parameters ||= []
50
+ @tool_parameters << { name: name, type: type, required: required, desc: desc }
51
+ end
52
+
53
+ def tool_parameters
54
+ @tool_parameters || []
55
+ end
56
+
57
+ # Generate a Mana-compatible tool definition hash.
58
+ def to_tool_definition
59
+ props = {}
60
+ required = []
61
+
62
+ tool_parameters.each do |p|
63
+ json_type = ruby_type_to_json(p[:type])
64
+ props[p[:name].to_s] = { type: json_type, description: p[:desc] }
65
+ required << p[:name].to_s if p[:required]
66
+ end
67
+
68
+ {
69
+ name: tool_name,
70
+ description: description,
71
+ input_schema: {
72
+ type: "object",
73
+ properties: props,
74
+ required: required
75
+ }
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ def ruby_type_to_json(type)
82
+ case type.to_s
83
+ when "String" then "string"
84
+ when "Integer", "Fixnum", "Bignum" then "integer"
85
+ when "Float", "Numeric" then "number"
86
+ when "Hash" then "object"
87
+ when "Array" then "array"
88
+ when "Boolean", "TrueClass", "FalseClass" then "boolean"
89
+ else "string"
90
+ end
91
+ end
92
+ end
93
+
94
+ # Subclasses must implement #call with keyword args matching parameters.
95
+ def call(**kwargs)
96
+ raise NotImplementedError, "#{self.class}#call not implemented"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Lightweight index of project tools in `.ruby-claw/tools/`.
5
+ # Scans files via regex to extract tool_name and description without requiring them.
6
+ class ToolIndex
7
+ Entry = Struct.new(:name, :description, :path, keyword_init: true)
8
+
9
+ attr_reader :entries
10
+
11
+ def initialize(tools_dir)
12
+ @tools_dir = tools_dir
13
+ @entries = []
14
+ scan! if tools_dir && Dir.exist?(tools_dir)
15
+ end
16
+
17
+ # Rebuild the index by scanning the tools directory.
18
+ def scan!
19
+ @entries = []
20
+ return unless @tools_dir && Dir.exist?(@tools_dir)
21
+
22
+ Dir.glob(File.join(@tools_dir, "*.rb")).each do |path|
23
+ entry = extract_metadata(path)
24
+ @entries << entry if entry
25
+ end
26
+ end
27
+
28
+ # Search for tools matching a keyword (case-insensitive substring match).
29
+ #
30
+ # @param keyword [String]
31
+ # @return [Array<Entry>]
32
+ def search(keyword)
33
+ return @entries if keyword.nil? || keyword.empty?
34
+
35
+ pattern = keyword.downcase
36
+ @entries.select do |e|
37
+ e.name.downcase.include?(pattern) || e.description.downcase.include?(pattern)
38
+ end
39
+ end
40
+
41
+ # Find an entry by exact name.
42
+ #
43
+ # @param name [String]
44
+ # @return [Entry, nil]
45
+ def find(name)
46
+ @entries.find { |e| e.name == name }
47
+ end
48
+
49
+ # Load a tool class from its file. Returns the class or nil.
50
+ #
51
+ # @param name [String] tool name
52
+ # @return [Class, nil] the class including Claw::Tool
53
+ def load_tool(name)
54
+ entry = find(name)
55
+ return nil unless entry
56
+
57
+ before_count = Claw::Tool.tool_classes.size
58
+ Kernel.load(entry.path)
59
+ new_classes = Claw::Tool.tool_classes[before_count..]
60
+
61
+ # Prefer the class whose tool_name matches
62
+ new_classes&.find { |c| c.tool_name == name } || new_classes&.first
63
+ end
64
+
65
+ private
66
+
67
+ # Extract tool_name and description from a .rb file using regex.
68
+ # Avoids require to keep startup fast.
69
+ def extract_metadata(path)
70
+ content = File.read(path, 4096) # Read only the first 4KB
71
+
72
+ name_match = content.match(/tool_name\s+["']([^"']+)["']/)
73
+ desc_match = content.match(/description\s+["']([^"']+)["']/)
74
+
75
+ return nil unless name_match
76
+
77
+ Entry.new(
78
+ name: name_match[1],
79
+ description: desc_match ? desc_match[1] : "",
80
+ path: path
81
+ )
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Manages the tool lifecycle: indexing, searching, loading, and tracking.
5
+ # Bridges project tools (Claw::Tool classes) with Mana's tool registration.
6
+ class ToolRegistry
7
+ attr_reader :index, :loaded_tools
8
+
9
+ def initialize(tools_dir: nil, hub: nil)
10
+ @tools_dir = tools_dir
11
+ @index = ToolIndex.new(tools_dir)
12
+ @hub = hub
13
+ @loaded_tools = {} # name → tool class
14
+ end
15
+
16
+ # Search local index (and optionally hub) for tools matching a keyword.
17
+ #
18
+ # @param keyword [String]
19
+ # @return [Array<Hash>] [{name:, description:, source:, loaded:}]
20
+ def search(keyword)
21
+ results = @index.search(keyword).map do |entry|
22
+ { name: entry.name, description: entry.description,
23
+ source: "project", loaded: @loaded_tools.key?(entry.name) }
24
+ end
25
+
26
+ # Query hub if configured and local results are sparse
27
+ if @hub && results.size < 3
28
+ hub_results = @hub.search(keyword) rescue []
29
+ hub_results.each do |hr|
30
+ next if results.any? { |r| r[:name] == hr[:name] }
31
+ results << hr.merge(source: "hub", loaded: false)
32
+ end
33
+ end
34
+
35
+ results
36
+ end
37
+
38
+ # Load a tool by name. Requires the file, registers with Mana.
39
+ #
40
+ # @param name [String]
41
+ # @return [String] success/error message
42
+ def load(name)
43
+ return "Tool '#{name}' is already loaded" if @loaded_tools.key?(name)
44
+
45
+ # Try local index first
46
+ klass = @index.load_tool(name)
47
+
48
+ # Try downloading from hub if not found locally
49
+ if klass.nil? && @hub
50
+ downloaded = download_from_hub(name)
51
+ klass = @index.load_tool(name) if downloaded
52
+ end
53
+
54
+ return "Tool '#{name}' not found" unless klass
55
+
56
+ register_with_mana(klass)
57
+ @loaded_tools[name] = klass
58
+ "Tool '#{name}' loaded successfully"
59
+ end
60
+
61
+ # Check if a tool is currently loaded.
62
+ def loaded?(name)
63
+ @loaded_tools.key?(name)
64
+ end
65
+
66
+ # Unload a tool (remove from Mana's registered tools).
67
+ def unload(name)
68
+ return "Tool '#{name}' is not loaded" unless @loaded_tools.key?(name)
69
+
70
+ # Remove from Mana's registrations
71
+ Mana.instance_variable_get(:@registered_tools)&.reject! { |t| t[:name] == name }
72
+ Mana.instance_variable_get(:@tool_handlers)&.delete(name)
73
+ @loaded_tools.delete(name)
74
+ "Tool '#{name}' unloaded"
75
+ end
76
+
77
+ # Download a tool from the hub into the local tools directory.
78
+ def download_from_hub(name)
79
+ return false unless @hub && @tools_dir
80
+
81
+ @hub.download(name, target_dir: @tools_dir)
82
+ @index.scan! # Refresh index
83
+ true
84
+ rescue => e
85
+ false
86
+ end
87
+
88
+ private
89
+
90
+ def register_with_mana(klass)
91
+ definition = klass.to_tool_definition
92
+ Mana.register_tool(definition) do |input|
93
+ kwargs = input.transform_keys(&:to_sym)
94
+ klass.new.call(**kwargs)
95
+ rescue => e
96
+ "error: #{e.class}: #{e.message}"
97
+ end
98
+ end
99
+ end
100
+ end
data/lib/claw/trace.rb ADDED
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Claw
6
+ # Writes execution traces as Markdown files to .ruby-claw/traces/.
7
+ # Each task execution produces one trace file with timing, token usage,
8
+ # and tool call details per LLM iteration.
9
+ module Trace
10
+ TRACES_DIR = "traces"
11
+
12
+ class << self
13
+ # Write a trace file from engine trace_data.
14
+ #
15
+ # @param trace_data [Hash] from Mana::Engine#trace_data
16
+ # @param claw_dir [String] path to .ruby-claw/ directory
17
+ # @return [String] path to the written trace file
18
+ def write(trace_data, claw_dir)
19
+ dir = File.join(claw_dir, TRACES_DIR)
20
+ FileUtils.mkdir_p(dir)
21
+
22
+ ts = trace_data[:timestamp] || Time.now.iso8601
23
+ filename = ts.gsub(/[:\-]/, "").sub("T", "_").split("+").first + ".md"
24
+ path = File.join(dir, filename)
25
+
26
+ File.write(path, render(trace_data))
27
+ path
28
+ end
29
+
30
+ # Render trace_data as Markdown.
31
+ def render(data)
32
+ lines = []
33
+ prompt_summary = data[:prompt].to_s
34
+ prompt_summary = prompt_summary[0, 80] + "..." if prompt_summary.length > 80
35
+
36
+ lines << "# Task: #{prompt_summary}"
37
+ lines << ""
38
+ lines << "- Started: #{data[:timestamp]}"
39
+ lines << "- Model: #{data[:model]}"
40
+ lines << "- Steps: #{data[:steps].size}"
41
+
42
+ total_in = data[:steps].sum { |s| s.dig(:usage, :input_tokens) || 0 }
43
+ total_out = data[:steps].sum { |s| s.dig(:usage, :output_tokens) || 0 }
44
+ total_ms = data[:steps].sum { |s| s[:latency_ms] || 0 }
45
+
46
+ lines << "- Total tokens: #{total_in} in / #{total_out} out"
47
+ lines << "- Total latency: #{total_ms}ms"
48
+ lines << ""
49
+
50
+ data[:steps].each_with_index do |step, i|
51
+ lines << "## Step #{i + 1}"
52
+ lines << ""
53
+ lines << "- Latency: #{step[:latency_ms]}ms"
54
+ if step[:usage]
55
+ lines << "- Tokens: #{step[:usage][:input_tokens] || 0} in / #{step[:usage][:output_tokens] || 0} out"
56
+ end
57
+
58
+ if step[:tool_calls]&.any?
59
+ lines << ""
60
+ lines << "### Tool calls"
61
+ lines << ""
62
+ step[:tool_calls].each do |tc|
63
+ input_str = summarize_hash(tc[:input])
64
+ result_str = truncate(tc[:result].to_s, 100)
65
+ lines << "- **#{tc[:name]}**(#{input_str}) -> #{result_str}"
66
+ end
67
+ end
68
+ lines << ""
69
+ end
70
+
71
+ lines.join("\n")
72
+ end
73
+
74
+ private
75
+
76
+ def summarize_hash(hash)
77
+ return "" unless hash.is_a?(Hash)
78
+ hash.map { |k, v| "#{k}: #{truncate(v.inspect, 40)}" }.join(", ")
79
+ end
80
+
81
+ def truncate(str, max)
82
+ str.length > max ? "#{str[0, max]}..." : str
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module TUI
5
+ # Runs Mana::Engine#execute in a background thread, sending MVU messages
6
+ # back to the model via a callback. Manages runtime state transitions.
7
+ class AgentExecutor
8
+ def initialize(runtime)
9
+ @runtime = runtime
10
+ @mutex = Mutex.new
11
+ @running = false
12
+ end
13
+
14
+ # Is an LLM execution currently in progress?
15
+ def running? = @running
16
+
17
+ # Execute an LLM prompt in a background thread.
18
+ # Yields MVU message objects as events occur.
19
+ # Returns nil if already running.
20
+ #
21
+ # @param input [String] user prompt
22
+ # @param binding [Binding] caller's binding
23
+ # @return [Thread, nil] the execution thread, or nil if busy
24
+ def execute(input, binding, &on_event)
25
+ @mutex.synchronize do
26
+ return nil if @running
27
+ @running = true
28
+ end
29
+
30
+ @runtime&.transition!(:thinking)
31
+
32
+ # Capture thread-local state from main thread
33
+ parent_context = Thread.current[:mana_context]
34
+ parent_role = Thread.current[:claw_role]
35
+ parent_memory = Thread.current[:claw_memory]
36
+
37
+ Thread.new do
38
+ # Propagate thread-local state to agent thread
39
+ Thread.current[:mana_context] = parent_context
40
+ Thread.current[:claw_role] = parent_role
41
+ Thread.current[:claw_memory] = parent_memory
42
+
43
+ engine = Mana::Engine.new(binding)
44
+ step_num = 0
45
+
46
+ result = engine.execute(input) do |type, *args|
47
+ case type
48
+ when :text
49
+ on_event.call(AgentTextMsg.new(text: args[0]))
50
+
51
+ when :tool_start
52
+ step_num += 1
53
+ name, input_data = args
54
+ @runtime&.transition!(:executing_tool,
55
+ step: Runtime::Step.new(
56
+ number: step_num,
57
+ tool_name: name,
58
+ target: input_data.is_a?(Hash) ? (input_data[:name] || input_data["name"] || name) : name
59
+ ))
60
+ on_event.call(ToolCallMsg.new(name: name, input: input_data))
61
+
62
+ when :tool_end
63
+ name, result_str = args
64
+ on_event.call(ToolResultMsg.new(name: name, result: result_str))
65
+ @runtime&.transition!(:thinking)
66
+ end
67
+ end
68
+
69
+ @runtime&.transition!(:idle)
70
+ on_event.call(ExecutionDoneMsg.new(result: result, trace: engine.trace_data))
71
+ rescue => e
72
+ @runtime&.transition!(:failed)
73
+ on_event.call(ExecutionErrorMsg.new(error: e))
74
+ ensure
75
+ @mutex.synchronize { @running = false }
76
+ end
77
+ end
78
+
79
+ # Execute a Ruby expression, returning the result or error.
80
+ #
81
+ # @param code [String] Ruby code to eval
82
+ # @param binding [Binding] caller's binding
83
+ # @return [Hash] { success: bool, result: Any, error: Exception? }
84
+ def eval_ruby(code, binding)
85
+ result = binding.eval(code)
86
+ { success: true, result: result }
87
+ rescue => e
88
+ { success: false, error: e }
89
+ end
90
+ end
91
+ end
92
+ end