ruby-mana 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d264315170eee3756db9dc603810cb524ce0a933f15b8897d36e7306c20d25b1
4
- data.tar.gz: fc2f36ef0eceec87a7ff9911384db6ed9c6f004eee8c279fc379d3d2c36756fb
3
+ metadata.gz: fa9b6637652accc3c6223e613713b06f3ef24654324c3498deac36067fbca29f
4
+ data.tar.gz: dc9722c916a0bcc6d599d37e24d68fc40870a6c35b0736d211266a7b4085c725
5
5
  SHA512:
6
- metadata.gz: be603cd3ddfcc6e457b656df9caff5ba8a9de4252dbb74416879daead34c64d7b4c50ad6ea422eaa21b76b92acc780e93b54425250e369e3f14223c0c82fee03
7
- data.tar.gz: ae6ea7cb12475dded7d5f6cf1c1f73acfb2bc16b592741cd935b9406d118516e1530651095310bc791d436f0039991936e8074da053f629240f0ec5b2c396143
6
+ metadata.gz: 911c263d7ad2e854c0268878ac19ac7e353d5d293e6683ae8453963c7edcb2443526ffdfdd019d24bbe024f729138ea3bad3234c96c16e968b6b997f81d298cb
7
+ data.tar.gz: b20334705ffd23d95249e5f3411e54e7e74da25442ebe059745de41ea1dbe88496377b410c3964979a86d2f64cab6e0755291bd5e860d5a116af6cfe535e50a9
data/README.md CHANGED
@@ -136,11 +136,72 @@ Mana.configure do |c|
136
136
  c.api_key = ENV["ANTHROPIC_API_KEY"]
137
137
  c.max_iterations = 50
138
138
  end
139
+ ```
140
+
141
+ ### Custom effect handlers
142
+
143
+ Define your own tools that the LLM can call. Each effect becomes an LLM tool automatically — the block's keyword parameters define the tool's input schema.
144
+
145
+ ```ruby
146
+ # No params
147
+ Mana.define_effect :get_time do
148
+ Time.now.to_s
149
+ end
139
150
 
140
- # Or shorthand
141
- Mana.model = "claude-sonnet-4-20250514"
151
+ # With params — keyword args become tool parameters
152
+ Mana.define_effect :query_db do |sql:|
153
+ ActiveRecord::Base.connection.execute(sql).to_a
154
+ end
155
+
156
+ # With description (optional, recommended)
157
+ Mana.define_effect :search_web,
158
+ description: "Search the web for information" do |query:, max_results: 5|
159
+ WebSearch.search(query, limit: max_results)
160
+ end
161
+
162
+ # Use in prompts
163
+ ~"get the current time and store in <now>"
164
+ ~"find recent orders using query_db, store in <orders>"
142
165
  ```
143
166
 
167
+ Built-in effects (`read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `done`) are reserved and cannot be overridden.
168
+
169
+ ### LLM-compiled methods
170
+
171
+ `mana def` lets LLM generate a method implementation on first call. The generated code is cached as a real `.rb` file — subsequent calls are pure Ruby with zero API overhead.
172
+
173
+ ```ruby
174
+ mana def fizzbuzz(n)
175
+ ~"return an array of FizzBuzz results from 1 to n"
176
+ end
177
+
178
+ fizzbuzz(15) # first call → LLM generates code → cached → executed
179
+ fizzbuzz(20) # pure Ruby from .mana_cache/fizzbuzz.rb
180
+
181
+ # View the generated source
182
+ puts Mana.source(:fizzbuzz)
183
+ # def fizzbuzz(n)
184
+ # (1..n).map do |i|
185
+ # if i % 15 == 0 then "FizzBuzz"
186
+ # elsif i % 3 == 0 then "Fizz"
187
+ # elsif i % 5 == 0 then "Buzz"
188
+ # else i.to_s
189
+ # end
190
+ # end
191
+ # end
192
+
193
+ # Works in classes too
194
+ class Converter
195
+ include Mana::Mixin
196
+
197
+ mana def celsius_to_fahrenheit(c)
198
+ ~"convert Celsius to Fahrenheit"
199
+ end
200
+ end
201
+ ```
202
+
203
+ Generated files live in `.mana_cache/` (add to `.gitignore`, or commit them to skip LLM on CI).
204
+
144
205
  ## How it works
145
206
 
146
207
  1. `~"..."` calls `String#~@`, which captures the caller's `Binding`
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Mana
6
+ # Compiler for `mana def` — LLM generates method implementations on first call,
7
+ # caches them as real .rb files, and replaces the method with native Ruby.
8
+ #
9
+ # Usage:
10
+ # mana def fizzbuzz(n)
11
+ # ~"return an array of FizzBuzz results from 1 to n"
12
+ # end
13
+ #
14
+ # fizzbuzz(15) # first call → LLM generates code → cached → executed
15
+ # fizzbuzz(20) # subsequent calls → pure Ruby, zero API overhead
16
+ #
17
+ # Mana.source(:fizzbuzz) # view generated source
18
+ module Compiler
19
+ class << self
20
+ # Registry of compiled method sources: { "ClassName#method" => source_code }
21
+ def registry
22
+ @registry ||= {}
23
+ end
24
+
25
+ # Cache directory for generated .rb files
26
+ def cache_dir
27
+ @cache_dir || ".mana_cache"
28
+ end
29
+
30
+ attr_writer :cache_dir
31
+
32
+ # Get the generated source for a compiled method
33
+ def source(method_name, owner: nil)
34
+ key = registry_key(method_name, owner)
35
+ registry[key]
36
+ end
37
+
38
+ # Compile a method: wrap it so first invocation triggers LLM code generation
39
+ def compile(owner, method_name)
40
+ original = owner.instance_method(method_name)
41
+ compiler = self
42
+ key = registry_key(method_name, owner)
43
+
44
+ # Read the prompt from the original method body
45
+ prompt = extract_prompt(original)
46
+
47
+ # Build parameter signature for the generated method
48
+ params_desc = describe_params(original)
49
+
50
+ owner.define_method(method_name) do |*args, **kwargs, &blk|
51
+ # Generate implementation via LLM
52
+ generated = compiler.generate(method_name, params_desc, prompt)
53
+
54
+ # Write to cache file
55
+ cache_path = compiler.write_cache(method_name, generated, owner)
56
+
57
+ # Store in registry
58
+ compiler.registry[key] = generated
59
+
60
+ # Define the method on the correct owner (not Object) via class_eval
61
+ target_owner = owner
62
+ target_owner.class_eval(generated, cache_path, 1)
63
+
64
+ # Call the now-native method
65
+ send(method_name, *args, **kwargs, &blk)
66
+ end
67
+ end
68
+
69
+ # Generate Ruby method source via LLM
70
+ def generate(method_name, params_desc, prompt)
71
+ code = nil # pre-declare so binding captures it
72
+ b = binding
73
+ engine_prompt = "Write a Ruby method definition `def #{method_name}(#{params_desc})` that: #{prompt}. " \
74
+ "Return ONLY the complete method definition (def...end), no explanation. " \
75
+ "Store the code as a string in <code>"
76
+
77
+ Mana::Engine.run(engine_prompt, b)
78
+ code
79
+ end
80
+
81
+ # Write generated code to a cache file, return the path
82
+ def write_cache(method_name, source, owner = nil)
83
+ FileUtils.mkdir_p(cache_dir)
84
+ prefix = owner && owner != Object ? "#{underscore(owner.name)}_" : ""
85
+ path = File.join(cache_dir, "#{prefix}#{method_name}.rb")
86
+ File.write(path, "# Auto-generated by ruby-mana\n# frozen_string_literal: true\n\n#{source}\n")
87
+ path
88
+ end
89
+
90
+ # Clear all cached files and registry
91
+ def clear!
92
+ FileUtils.rm_rf(cache_dir) if Dir.exist?(cache_dir)
93
+ @registry = {}
94
+ end
95
+
96
+ private
97
+
98
+ def registry_key(method_name, owner = nil)
99
+ if owner && owner != Object
100
+ "#{owner}##{method_name}"
101
+ else
102
+ method_name.to_s
103
+ end
104
+ end
105
+
106
+ # Extract the prompt string from the original method.
107
+ # The method body should be a single ~"..." expression.
108
+ def extract_prompt(unbound_method)
109
+ source_loc = unbound_method.source_location
110
+ return nil unless source_loc
111
+
112
+ file, line = source_loc
113
+ return nil unless file && File.exist?(file)
114
+
115
+ lines = File.readlines(file)
116
+ # Scan from the def line to find the prompt string
117
+ body_lines = []
118
+ depth = 0
119
+ (line - 1...lines.length).each do |i|
120
+ l = lines[i]
121
+ depth += l.scan(/\bdef\b|\bdo\b|\bclass\b|\bmodule\b|\bif\b|\bunless\b|\bcase\b|\bwhile\b|\buntil\b|\bbegin\b/).length
122
+ depth -= l.scan(/\bend\b/).length
123
+ body_lines << l
124
+ break if depth <= 0
125
+ end
126
+
127
+ # Extract string content from ~"..." pattern
128
+ body = body_lines.join
129
+ match = body.match(/~"([^"]*)"/) || body.match(/~'([^']*)'/)
130
+ match ? match[1] : body_lines[1...-1].join.strip
131
+ end
132
+
133
+ def describe_params(unbound_method)
134
+ unbound_method.parameters.map do |(type, name)|
135
+ case type
136
+ when :req then name.to_s
137
+ when :opt then "#{name}=nil"
138
+ when :rest then "*#{name}"
139
+ when :keyreq then "#{name}:"
140
+ when :key then "#{name}: nil"
141
+ when :keyrest then "**#{name}"
142
+ when :block then "&#{name}"
143
+ else name.to_s
144
+ end
145
+ end.join(", ")
146
+ end
147
+
148
+ def underscore(str)
149
+ return "anonymous" if str.nil? || str.empty?
150
+
151
+ str.gsub("::", "_")
152
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
153
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
154
+ .downcase
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ # Registry for custom effect handlers.
5
+ #
6
+ # Users define effects that become LLM tools automatically:
7
+ #
8
+ # Mana.define_effect :query_db,
9
+ # description: "Execute a SQL query" do |sql:|
10
+ # DB.execute(sql)
11
+ # end
12
+ #
13
+ # The block's keyword parameters become the tool's input schema.
14
+ # The block's return value is serialized and sent back to the LLM.
15
+ module EffectRegistry
16
+ class EffectDefinition
17
+ attr_reader :name, :description, :handler, :params
18
+
19
+ def initialize(name, description: nil, &handler)
20
+ @name = name.to_s
21
+ @description = description || @name
22
+ @handler = handler
23
+ @params = extract_params(handler)
24
+ end
25
+
26
+ # Convert to LLM tool definition
27
+ def to_tool
28
+ properties = {}
29
+ required = []
30
+
31
+ @params.each do |param|
32
+ properties[param[:name]] = {
33
+ type: infer_type(param[:default]),
34
+ description: param[:name]
35
+ }
36
+ required << param[:name] if param[:required]
37
+ end
38
+
39
+ tool = {
40
+ name: @name,
41
+ description: @description,
42
+ input_schema: {
43
+ type: "object",
44
+ properties: properties
45
+ }
46
+ }
47
+ tool[:input_schema][:required] = required unless required.empty?
48
+ tool
49
+ end
50
+
51
+ # Call the handler with LLM-provided input
52
+ def call(input)
53
+ kwargs = {}
54
+ @params.each do |param|
55
+ key = param[:name]
56
+ if input.key?(key)
57
+ kwargs[key.to_sym] = input[key]
58
+ elsif param[:default] != :__mana_no_default__
59
+ # Use block's default
60
+ elsif param[:required]
61
+ raise Mana::Error, "missing required parameter: #{key}"
62
+ end
63
+ end
64
+
65
+ if kwargs.empty? && @params.empty?
66
+ @handler.call
67
+ else
68
+ @handler.call(**kwargs)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def extract_params(block)
75
+ return [] unless block
76
+
77
+ block.parameters.map do |(type, name)|
78
+ case type
79
+ when :keyreq
80
+ { name: name.to_s, required: true, default: :__mana_no_default__ }
81
+ when :key
82
+ # Try to get default value — not possible via reflection,
83
+ # so we mark it as optional with unknown default
84
+ { name: name.to_s, required: false, default: nil }
85
+ when :keyrest
86
+ # **kwargs — skip, can't generate schema
87
+ nil
88
+ else
89
+ # Positional args — treat as required string params
90
+ { name: name.to_s, required: true, default: :__mana_no_default__ } if name
91
+ end
92
+ end.compact
93
+ end
94
+
95
+ def infer_type(default)
96
+ case default
97
+ when Integer then "integer"
98
+ when Float then "number"
99
+ when TrueClass, FalseClass then "boolean"
100
+ when Array then "array"
101
+ when Hash then "object"
102
+ else "string"
103
+ end
104
+ end
105
+ end
106
+
107
+ RESERVED_EFFECTS = %w[read_var write_var read_attr write_attr call_func done].freeze
108
+
109
+ class << self
110
+ def registry
111
+ @registry ||= {}
112
+ end
113
+
114
+ def define(name, description: nil, &handler)
115
+ name_s = name.to_s
116
+ if RESERVED_EFFECTS.include?(name_s)
117
+ raise Mana::Error, "cannot override built-in effect: #{name_s}"
118
+ end
119
+
120
+ registry[name_s] = EffectDefinition.new(name, description: description, &handler)
121
+ end
122
+
123
+ def undefine(name)
124
+ registry.delete(name.to_s)
125
+ end
126
+
127
+ def defined?(name)
128
+ registry.key?(name.to_s)
129
+ end
130
+
131
+ def get(name)
132
+ registry[name.to_s]
133
+ end
134
+
135
+ # Generate tool definitions for all registered effects
136
+ def tool_definitions
137
+ registry.values.map(&:to_tool)
138
+ end
139
+
140
+ def clear!
141
+ @registry = {}
142
+ end
143
+
144
+ # Handle a tool call if it matches a registered effect
145
+ # Returns [handled, result] — handled is true if we processed it
146
+ def handle(name, input)
147
+ effect = get(name)
148
+ return [false, nil] unless effect
149
+
150
+ result = effect.call(input)
151
+ [true, result]
152
+ end
153
+ end
154
+ end
155
+ end
data/lib/mana/engine.rb CHANGED
@@ -92,12 +92,18 @@ module Mana
92
92
  ensure
93
93
  handler_stack.pop
94
94
  end
95
+
96
+ # Built-in tools + any registered custom effects
97
+ def all_tools
98
+ TOOLS + Mana::EffectRegistry.tool_definitions
99
+ end
95
100
  end
96
101
 
97
102
  def initialize(prompt, caller_binding)
98
103
  @prompt = prompt
99
104
  @binding = caller_binding
100
105
  @config = Mana.config
106
+ @caller_path = caller_source_path
101
107
  end
102
108
 
103
109
  def execute
@@ -123,7 +129,7 @@ module Mana
123
129
  # Process each tool use
124
130
  tool_results = tool_uses.map do |tu|
125
131
  result = handle_effect(tu)
126
- done_result = tu[:input]["result"] if tu[:name] == "done"
132
+ done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
127
133
  { type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
128
134
  end
129
135
 
@@ -172,6 +178,28 @@ module Mana
172
178
  context.each { |k, v| parts << " #{k} = #{v}" }
173
179
  end
174
180
 
181
+ # Discover available functions from caller's source
182
+ methods = begin
183
+ Mana::Introspect.methods_from_file(@caller_path)
184
+ rescue => _e
185
+ []
186
+ end
187
+ unless methods.empty?
188
+ parts << ""
189
+ parts << Mana::Introspect.format_for_prompt(methods)
190
+ end
191
+
192
+ # List custom effects
193
+ custom_effects = Mana::EffectRegistry.tool_definitions
194
+ unless custom_effects.empty?
195
+ parts << ""
196
+ parts << "Custom tools available:"
197
+ custom_effects.each do |t|
198
+ params = (t[:input_schema][:properties] || {}).keys.join(", ")
199
+ parts << " #{t[:name]}(#{params}) — #{t[:description]}"
200
+ end
201
+ end
202
+
175
203
  parts.join("\n")
176
204
  end
177
205
 
@@ -183,10 +211,14 @@ module Mana
183
211
  # Normalize keys to strings for consistent access
184
212
  input = input.transform_keys(&:to_s) if input.is_a?(Hash)
185
213
 
186
- # Check handler stack first
214
+ # Check handler stack first (legacy)
187
215
  handler = self.class.handler_stack.last
188
216
  return handler.call(name, input) if handler && handler.respond_to?(:call)
189
217
 
218
+ # Check custom effect registry
219
+ handled, result = Mana::EffectRegistry.handle(name, input)
220
+ return serialize_value(result) if handled
221
+
190
222
  case name
191
223
  when "read_var"
192
224
  serialize_value(resolve(input["name"]))
@@ -249,6 +281,20 @@ module Mana
249
281
  @binding.local_variable_set(name.to_sym, value)
250
282
  end
251
283
 
284
+ def caller_source_path
285
+ # Walk up the call stack to find the first non-mana source file
286
+ loc = @binding.source_location
287
+ return loc[0] if loc.is_a?(Array)
288
+
289
+ # Fallback: search caller_locations
290
+ caller_locations(4, 20)&.each do |frame|
291
+ path = frame.absolute_path || frame.path
292
+ next if path.nil? || path.include?("mana/")
293
+ return path
294
+ end
295
+ nil
296
+ end
297
+
252
298
  def serialize_value(val)
253
299
  case val
254
300
  when String, Integer, Float, TrueClass, FalseClass, NilClass
@@ -278,7 +324,7 @@ module Mana
278
324
  model: @config.model,
279
325
  max_tokens: 4096,
280
326
  system: system,
281
- tools: TOOLS,
327
+ tools: self.class.all_tools,
282
328
  messages: messages
283
329
  }
284
330
 
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mana
6
+ # Introspects the caller's source file to discover user-defined methods.
7
+ # Uses Prism AST to extract `def` nodes with their parameter signatures.
8
+ module Introspect
9
+ class << self
10
+ # Extract method definitions from a Ruby source file.
11
+ # Returns an array of { name:, params: } hashes.
12
+ #
13
+ # @param path [String] path to the Ruby source file
14
+ # @return [Array<Hash>] method definitions found
15
+ def methods_from_file(path)
16
+ return [] unless path && File.exist?(path)
17
+
18
+ source = File.read(path)
19
+ result = Prism.parse(source)
20
+ methods = []
21
+
22
+ walk(result.value) do |node|
23
+ next unless node.is_a?(Prism::DefNode)
24
+
25
+ params = extract_params(node)
26
+ methods << { name: node.name.to_s, params: params }
27
+ end
28
+
29
+ methods
30
+ end
31
+
32
+ # Format discovered methods as a string for the system prompt.
33
+ #
34
+ # @param methods [Array<Hash>] from methods_from_file
35
+ # @return [String] formatted method list
36
+ def format_for_prompt(methods)
37
+ return "" if methods.empty?
38
+
39
+ lines = methods.map do |m|
40
+ sig = m[:params].empty? ? m[:name] : "#{m[:name]}(#{m[:params].join(', ')})"
41
+ " #{sig}"
42
+ end
43
+
44
+ "Available Ruby functions:\n#{lines.join("\n")}"
45
+ end
46
+
47
+ private
48
+
49
+ def walk(node, &block)
50
+ queue = [node]
51
+ while (current = queue.shift)
52
+ next unless current.respond_to?(:compact_child_nodes)
53
+
54
+ block.call(current)
55
+ queue.concat(current.compact_child_nodes)
56
+ end
57
+ end
58
+
59
+ def extract_params(def_node)
60
+ params_node = def_node.parameters
61
+ return [] unless params_node
62
+
63
+ result = []
64
+
65
+ # Required parameters
66
+ (params_node.requireds || []).each do |p|
67
+ result << param_name(p)
68
+ end
69
+
70
+ # Optional parameters
71
+ (params_node.optionals || []).each do |p|
72
+ result << "#{param_name(p)}=..."
73
+ end
74
+
75
+ # Rest parameter
76
+ if params_node.rest && !params_node.rest.is_a?(Prism::ImplicitRestNode)
77
+ name = params_node.rest.name
78
+ result << "*#{name || ''}"
79
+ end
80
+
81
+ # Keyword parameters
82
+ (params_node.keywords || []).each do |p|
83
+ case p
84
+ when Prism::RequiredKeywordParameterNode
85
+ result << "#{p.name}:"
86
+ when Prism::OptionalKeywordParameterNode
87
+ result << "#{p.name}: ..."
88
+ end
89
+ end
90
+
91
+ # Keyword rest
92
+ if params_node.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
93
+ name = params_node.keyword_rest.name
94
+ result << "**#{name || ''}"
95
+ end
96
+
97
+ # Block parameter
98
+ if params_node.block
99
+ result << "&#{params_node.block.name || ''}"
100
+ end
101
+
102
+ result
103
+ end
104
+
105
+ def param_name(node)
106
+ case node
107
+ when Prism::RequiredParameterNode
108
+ node.name.to_s
109
+ when Prism::OptionalParameterNode
110
+ node.name.to_s
111
+ else
112
+ node.respond_to?(:name) ? node.name.to_s : "_"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/mana/mixin.rb CHANGED
@@ -1,12 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mana
4
- # Include in classes to use ~"..." in instance methods.
5
- # binding_of_caller handles scope automatically, so this
6
- # is mainly a semantic marker + future extension point.
4
+ # Include in classes to use ~"..." in instance methods
5
+ # and `mana def` for LLM-compiled methods.
7
6
  module Mixin
8
7
  def self.included(base)
9
- # Reserved for future: auto-expose methods, etc.
8
+ base.extend(ClassMethods)
10
9
  end
10
+
11
+ module ClassMethods
12
+ # Mark a method for LLM compilation.
13
+ # Usage:
14
+ # mana def fizzbuzz(n)
15
+ # ~"return FizzBuzz array from 1 to n"
16
+ # end
17
+ def mana(method_name)
18
+ Mana::Compiler.compile(self, method_name)
19
+ method_name
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ # Make `mana def` available at the top level (main object)
26
+ class << self
27
+ def mana(method_name)
28
+ Mana::Compiler.compile(Object, method_name)
29
+ method_name
11
30
  end
12
31
  end
@@ -5,6 +5,13 @@ require "binding_of_caller"
5
5
  class String
6
6
  # ~"natural language prompt" → execute via Mana engine
7
7
  def ~@
8
- Mana::Engine.run(self, binding.of_caller(1))
8
+ return self if Thread.current[:mana_running]
9
+
10
+ Thread.current[:mana_running] = true
11
+ begin
12
+ Mana::Engine.run(self, binding.of_caller(1))
13
+ ensure
14
+ Thread.current[:mana_running] = false
15
+ end
9
16
  end
10
17
  end
data/lib/mana/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mana
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/mana.rb CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  require_relative "mana/version"
4
4
  require_relative "mana/config"
5
- require_relative "mana/effects"
5
+ require_relative "mana/effect_registry"
6
6
  require_relative "mana/engine"
7
+ require_relative "mana/introspect"
8
+ require_relative "mana/compiler"
7
9
  require_relative "mana/string_ext"
8
10
  require_relative "mana/mixin"
9
11
 
@@ -32,6 +34,27 @@ module Mana
32
34
 
33
35
  def reset!
34
36
  @config = Config.new
37
+ EffectRegistry.clear!
38
+ end
39
+
40
+ # Define a custom effect that becomes an LLM tool
41
+ def define_effect(name, description: nil, &handler)
42
+ EffectRegistry.define(name, description: description, &handler)
43
+ end
44
+
45
+ # Remove a custom effect
46
+ def undefine_effect(name)
47
+ EffectRegistry.undefine(name)
48
+ end
49
+
50
+ # View generated source for a mana-compiled method
51
+ def source(method_name, owner: nil)
52
+ Compiler.source(method_name, owner: owner)
53
+ end
54
+
55
+ # Cache directory for compiled methods
56
+ def cache_dir=(dir)
57
+ Compiler.cache_dir = dir
35
58
  end
36
59
  end
37
60
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mana
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl
@@ -37,11 +37,11 @@ files:
37
37
  - LICENSE
38
38
  - README.md
39
39
  - lib/mana.rb
40
+ - lib/mana/compiler.rb
40
41
  - lib/mana/config.rb
41
- - lib/mana/effects.rb
42
+ - lib/mana/effect_registry.rb
42
43
  - lib/mana/engine.rb
43
- - lib/mana/llm/anthropic.rb
44
- - lib/mana/llm/base.rb
44
+ - lib/mana/introspect.rb
45
45
  - lib/mana/mixin.rb
46
46
  - lib/mana/string_ext.rb
47
47
  - lib/mana/version.rb
@@ -67,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  requirements: []
70
- rubygems_version: 3.4.20
70
+ rubygems_version: 3.5.22
71
71
  signing_key:
72
72
  specification_version: 4
73
73
  summary: Embed LLM as native Ruby — write natural language, it just runs
data/lib/mana/effects.rb DELETED
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mana
4
- module Effects
5
- ReadVar = Struct.new(:name)
6
- WriteVar = Struct.new(:name, :value)
7
- ReadAttr = Struct.new(:obj_name, :attr)
8
- WriteAttr = Struct.new(:obj_name, :attr, :value)
9
- CallFunc = Struct.new(:name, :args)
10
- Done = Struct.new(:result)
11
- end
12
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
-
7
- module Mana
8
- module LLM
9
- class Anthropic < Base
10
- API_URL = "https://api.anthropic.com/v1/messages"
11
- API_VERSION = "2023-06-01"
12
-
13
- def initialize(config = Mana.config)
14
- super(config)
15
- @api_key = config.api_key
16
- @model = config.model
17
- end
18
-
19
- def chat(system:, messages:, tools:)
20
- raise Mana::Error, "Anthropic API key not set" unless @api_key
21
-
22
- body = {
23
- model: @model,
24
- max_tokens: 4096,
25
- temperature: @config.temperature,
26
- system: system,
27
- messages: messages,
28
- tools: tools
29
- }
30
-
31
- response = post(body)
32
-
33
- unless response.is_a?(Net::HTTPSuccess)
34
- parsed = JSON.parse(response.body) rescue nil
35
- error_msg = parsed&.dig("error", "message") || response.body
36
- raise Mana::Error, "Anthropic API error (#{response.code}): #{error_msg}"
37
- end
38
-
39
- parsed = JSON.parse(response.body)
40
- parsed["content"].map { |block| symbolize_keys(block) }
41
- end
42
-
43
- private
44
-
45
- def post(body)
46
- uri = URI(API_URL)
47
- http = Net::HTTP.new(uri.host, uri.port)
48
- http.use_ssl = true
49
- http.open_timeout = 30
50
- http.read_timeout = 120
51
- http.write_timeout = 30
52
-
53
- request = Net::HTTP::Post.new(uri)
54
- request["x-api-key"] = @api_key
55
- request["anthropic-version"] = API_VERSION
56
- request["content-type"] = "application/json"
57
- request.body = JSON.generate(body)
58
-
59
- http.request(request)
60
- end
61
-
62
- def symbolize_keys(hash)
63
- hash.each_with_object({}) do |(k, v), acc|
64
- acc[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v
65
- end
66
- end
67
- end
68
- end
69
- end
data/lib/mana/llm/base.rb DELETED
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mana
4
- module LLM
5
- # Base interface for LLM clients.
6
- # Subclass and implement #chat to add new providers.
7
- class Base
8
- def initialize(config)
9
- @config = config
10
- end
11
-
12
- # Send a chat request with tools.
13
- # Returns an array of content blocks (tool_use / text).
14
- def chat(system:, messages:, tools:)
15
- raise NotImplementedError, "#{self.class}#chat not implemented"
16
- end
17
- end
18
- end
19
- end