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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -0
- data/README.md +214 -10
- data/exe/claw +42 -1
- data/lib/claw/auto_forge.rb +66 -0
- data/lib/claw/benchmark/benchmark.rb +79 -0
- data/lib/claw/benchmark/diff.rb +69 -0
- data/lib/claw/benchmark/report.rb +87 -0
- data/lib/claw/benchmark/runner.rb +91 -0
- data/lib/claw/benchmark/scorer.rb +69 -0
- data/lib/claw/benchmark/task.rb +63 -0
- data/lib/claw/benchmark/tasks/claw_remember.rb +20 -0
- data/lib/claw/benchmark/tasks/claw_session.rb +18 -0
- data/lib/claw/benchmark/tasks/evolution_trace.rb +18 -0
- data/lib/claw/benchmark/tasks/mana_call_func.rb +21 -0
- data/lib/claw/benchmark/tasks/mana_eval.rb +18 -0
- data/lib/claw/benchmark/tasks/mana_knowledge.rb +19 -0
- data/lib/claw/benchmark/tasks/mana_var_readwrite.rb +18 -0
- data/lib/claw/benchmark/tasks/runtime_fork.rb +18 -0
- data/lib/claw/benchmark/tasks/runtime_snapshot.rb +18 -0
- data/lib/claw/benchmark/trigger.rb +68 -0
- data/lib/claw/chat.rb +119 -6
- data/lib/claw/child_runtime.rb +196 -0
- data/lib/claw/cli.rb +177 -0
- data/lib/claw/commands.rb +131 -0
- data/lib/claw/config.rb +5 -1
- data/lib/claw/console/event_logger.rb +69 -0
- data/lib/claw/console/public/app.js +264 -0
- data/lib/claw/console/public/style.css +330 -0
- data/lib/claw/console/server.rb +253 -0
- data/lib/claw/console/sse.rb +28 -0
- data/lib/claw/console/views/experiments.erb +8 -0
- data/lib/claw/console/views/index.erb +27 -0
- data/lib/claw/console/views/layout.erb +29 -0
- data/lib/claw/console/views/memory.erb +13 -0
- data/lib/claw/console/views/monitor.erb +15 -0
- data/lib/claw/console/views/prompt.erb +15 -0
- data/lib/claw/console/views/snapshots.erb +12 -0
- data/lib/claw/console/views/tools.erb +13 -0
- data/lib/claw/console/views/traces.erb +9 -0
- data/lib/claw/console.rb +5 -0
- data/lib/claw/evolution.rb +227 -0
- data/lib/claw/forge.rb +144 -0
- data/lib/claw/hub.rb +67 -0
- data/lib/claw/init.rb +199 -0
- data/lib/claw/knowledge.rb +36 -2
- data/lib/claw/memory_store.rb +2 -2
- data/lib/claw/plan_mode.rb +110 -0
- data/lib/claw/resource.rb +35 -0
- data/lib/claw/resources/binding_resource.rb +128 -0
- data/lib/claw/resources/context_resource.rb +73 -0
- data/lib/claw/resources/filesystem_resource.rb +107 -0
- data/lib/claw/resources/memory_resource.rb +74 -0
- data/lib/claw/resources/worktree_resource.rb +133 -0
- data/lib/claw/roles.rb +56 -0
- data/lib/claw/runtime.rb +189 -0
- data/lib/claw/serializer.rb +10 -7
- data/lib/claw/tool.rb +99 -0
- data/lib/claw/tool_index.rb +84 -0
- data/lib/claw/tool_registry.rb +100 -0
- data/lib/claw/trace.rb +86 -0
- data/lib/claw/tui/agent_executor.rb +92 -0
- data/lib/claw/tui/chat_panel.rb +81 -0
- data/lib/claw/tui/command_bar.rb +22 -0
- data/lib/claw/tui/file_card.rb +88 -0
- data/lib/claw/tui/folding.rb +80 -0
- data/lib/claw/tui/input_handler.rb +73 -0
- data/lib/claw/tui/layout.rb +34 -0
- data/lib/claw/tui/messages.rb +31 -0
- data/lib/claw/tui/model.rb +411 -0
- data/lib/claw/tui/object_explorer.rb +136 -0
- data/lib/claw/tui/status_bar.rb +30 -0
- data/lib/claw/tui/status_panel.rb +133 -0
- data/lib/claw/tui/styles.rb +58 -0
- data/lib/claw/tui/tui.rb +54 -0
- data/lib/claw/version.rb +1 -1
- data/lib/claw.rb +99 -1
- metadata +223 -7
data/lib/claw/runtime.rb
ADDED
|
@@ -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
|
data/lib/claw/serializer.rb
CHANGED
|
@@ -61,14 +61,14 @@ module Claw
|
|
|
61
61
|
# Corrupted file — skip
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
# Encode a value for
|
|
65
|
-
# Strategy: try
|
|
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
|
|
68
|
-
|
|
69
|
-
{ "type" => "
|
|
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
|
-
#
|
|
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
|