ruby-mana 0.4.0 → 0.5.1

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.
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "pycall"
5
+ rescue LoadError
6
+ raise LoadError, "pycall gem is required for Python support. Add `gem 'pycall'` to your Gemfile."
7
+ end
8
+
9
+ require "json"
10
+
11
+ module Mana
12
+ module Engines
13
+ class Python < Base
14
+ # Python helper code injected once per namespace.
15
+ # Sets up weak-ref tracking so when Python drops a reference to a
16
+ # complex Ruby object, the ObjectRegistry is notified.
17
+ PY_GC_HELPER = <<~PYTHON
18
+ import weakref as _mana_wr
19
+ import builtins as _mana_bi
20
+ _mana_bi._mana_weakref = _mana_wr
21
+
22
+ class __ManaRef:
23
+ """Weak-ref release notifier for Ruby objects passed to Python."""
24
+ _release_fn = None
25
+ _instances = {}
26
+
27
+ @classmethod
28
+ def set_release_fn(cls, fn):
29
+ cls._release_fn = fn
30
+
31
+ @classmethod
32
+ def track(cls, ref_id, obj):
33
+ """Track a Ruby object. When Python GC collects it, notify Ruby."""
34
+ import builtins
35
+ _wr = builtins._mana_weakref
36
+ try:
37
+ ref = _wr.ref(obj, lambda r, rid=ref_id: cls._release(rid))
38
+ cls._instances[ref_id] = ref
39
+ except TypeError:
40
+ # Some objects can't be weakly referenced; skip them
41
+ pass
42
+
43
+ @classmethod
44
+ def _release(cls, ref_id):
45
+ cls._instances.pop(ref_id, None)
46
+ if cls._release_fn:
47
+ try:
48
+ cls._release_fn(ref_id)
49
+ except Exception:
50
+ pass
51
+
52
+ @classmethod
53
+ def release_all(cls):
54
+ for ref_id in list(cls._instances.keys()):
55
+ cls._release(ref_id)
56
+ PYTHON
57
+
58
+ # Thread-local persistent Python state
59
+ # PyCall shares a single Python interpreter per process,
60
+ # but we track our own variable namespace
61
+ def self.namespace
62
+ Thread.current[:mana_py_namespace] ||= create_namespace
63
+ end
64
+
65
+ def self.create_namespace
66
+ PyCall.eval("dict()")
67
+ end
68
+
69
+ def self.reset!
70
+ ns = Thread.current[:mana_py_namespace]
71
+ if ns
72
+ begin
73
+ mana_ref = ns["__ManaRef"]
74
+ mana_ref.release_all if mana_ref
75
+ rescue => e
76
+ # ignore
77
+ end
78
+ PyCall.exec("pass") # ensure interpreter is alive
79
+ Thread.current[:mana_py_namespace] = nil
80
+ end
81
+ Thread.current[:mana_py_gc_injected] = nil
82
+ ObjectRegistry.reset!
83
+ end
84
+
85
+ def execute(code)
86
+ ns = self.class.namespace
87
+
88
+ # 0. Inject GC helper (once per namespace)
89
+ inject_gc_helper(ns)
90
+
91
+ # 1. Inject Ruby variables into Python namespace
92
+ inject_ruby_vars(ns, code)
93
+
94
+ # 2. Inject the Ruby bridge for Python->Ruby callbacks
95
+ inject_ruby_bridge(ns)
96
+
97
+ # 3. Execute Python code in the namespace
98
+ PyCall.exec(code, locals: ns)
99
+
100
+ # 4. Extract declared variables back to Ruby
101
+ extract_py_vars(ns, code)
102
+
103
+ # Return the last expression value if possible
104
+ begin
105
+ ns["result"]
106
+ rescue
107
+ nil
108
+ end
109
+ rescue PyCall::PyError => e
110
+ raise Mana::Error, "Python execution error: #{e.message}"
111
+ rescue ArgumentError => e
112
+ raise Mana::Error, "Python execution error: #{e.message}"
113
+ end
114
+
115
+ private
116
+
117
+ def inject_gc_helper(ns)
118
+ return if Thread.current[:mana_py_gc_injected]
119
+
120
+ PyCall.exec(PY_GC_HELPER, locals: ns)
121
+
122
+ # Wire up the release callback: when Python GC collects a tracked object,
123
+ # __ManaRef calls this proc to release it from the Ruby ObjectRegistry.
124
+ registry = ObjectRegistry.current
125
+ release_fn = proc { |ref_id| registry.release(ref_id.to_i) }
126
+ mana_ref = ns["__ManaRef"]
127
+ mana_ref.set_release_fn(release_fn)
128
+
129
+ Thread.current[:mana_py_gc_injected] = true
130
+ end
131
+
132
+ def inject_ruby_vars(ns, code)
133
+ @binding.local_variables.each do |var_name|
134
+ value = @binding.local_variable_get(var_name)
135
+ serialized = serialize_for_py(value)
136
+ begin
137
+ ns[var_name.to_s] = serialized
138
+ rescue => e
139
+ next
140
+ end
141
+ end
142
+ end
143
+
144
+ # Inject a `ruby` bridge into the Python namespace.
145
+ #
146
+ # The bridge is a Ruby object with method_missing that proxies calls
147
+ # back to the Ruby binding. PyCall automatically wraps it so Python
148
+ # can call methods directly:
149
+ #
150
+ # ruby.method_name(arg1, arg2) -- call a Ruby method on the receiver
151
+ # ruby.read("var") -- read a Ruby local variable
152
+ # ruby.write("var", value) -- write a Ruby local variable
153
+ # ruby.call_proc("name", args) -- call a local proc/lambda by name
154
+ #
155
+ # Ruby objects (including the binding receiver) can also be injected
156
+ # directly and called from Python -- PyCall handles the wrapping.
157
+ def inject_ruby_bridge(ns)
158
+ ns["ruby"] = RubyBridge.new(@binding)
159
+ end
160
+
161
+ def extract_py_vars(ns, code)
162
+ declared_vars = extract_declared_vars(code)
163
+ declared_vars.each do |var_name|
164
+ next if var_name == "ruby" # skip the bridge object
165
+ begin
166
+ value = ns[var_name]
167
+ deserialized = deserialize_py(value)
168
+ write_var(var_name, deserialized)
169
+ rescue => e
170
+ next
171
+ end
172
+ end
173
+ end
174
+
175
+ def extract_declared_vars(code)
176
+ vars = []
177
+ # Match Python assignments: x = ..., but not x == ... or x += ...
178
+ code.scan(/^(\w+)\s*=[^=]/).each { |m| vars << m[0] }
179
+ # Also match augmented assignments: x += ..., x -= ...
180
+ code.scan(/^(\w+)\s*[+\-*\/]?=/).each { |m| vars << m[0] }
181
+ vars.uniq
182
+ end
183
+
184
+ # Serialize Ruby values for Python injection.
185
+ # Simple types are copied. Complex objects are passed directly via PyCall
186
+ # (which wraps them so Python can call their methods) AND registered in
187
+ # the ObjectRegistry for lifecycle tracking + GC notification.
188
+ def serialize_for_py(value)
189
+ case value
190
+ when Numeric, String, TrueClass, FalseClass, NilClass
191
+ value
192
+ when Symbol
193
+ value.to_s
194
+ when Array
195
+ value.map { |v| serialize_for_py(v) }
196
+ when Hash
197
+ value.each_with_object({}) do |(k, v), h|
198
+ key = k.is_a?(Symbol) ? k.to_s : k
199
+ h[key] = serialize_for_py(v)
200
+ end
201
+ when Proc, Method
202
+ ref_id = ObjectRegistry.current.register(value)
203
+ track_in_python(ref_id, value)
204
+ value
205
+ else
206
+ ref_id = ObjectRegistry.current.register(value)
207
+ track_in_python(ref_id, value)
208
+ value
209
+ end
210
+ end
211
+
212
+ # Tell the Python __ManaRef tracker to watch this object via weakref.
213
+ def track_in_python(ref_id, value)
214
+ ns = self.class.namespace
215
+ begin
216
+ mana_ref = ns["__ManaRef"]
217
+ mana_ref.track(ref_id, value) if mana_ref
218
+ rescue => e
219
+ # Non-fatal: some objects can't be weakly referenced in Python
220
+ end
221
+ end
222
+
223
+ def deserialize_py(value)
224
+ if defined?(PyCall::PyObjectWrapper) && value.is_a?(PyCall::PyObjectWrapper)
225
+ begin
226
+ value.to_a rescue value.to_s
227
+ rescue
228
+ value.to_s
229
+ end
230
+ else
231
+ value
232
+ end
233
+ end
234
+ end
235
+
236
+ # Bridge object injected as `ruby` in the Python namespace.
237
+ # Enables Python->Ruby callbacks via method_missing.
238
+ #
239
+ # Python usage:
240
+ # ruby.some_method(arg1, arg2) -- calls method on binding receiver
241
+ # ruby.read("var_name") -- reads a Ruby local variable
242
+ # ruby.write("var_name", val) -- writes a Ruby local variable
243
+ # ruby.call_proc("name", args) -- calls a local proc/lambda by name
244
+ class RubyBridge
245
+ def initialize(caller_binding)
246
+ @binding = caller_binding
247
+ @receiver = caller_binding.receiver
248
+ end
249
+
250
+ # Read a Ruby local variable
251
+ def read(name)
252
+ name = name.to_s.to_sym
253
+ if @binding.local_variables.include?(name)
254
+ @binding.local_variable_get(name)
255
+ else
256
+ raise NameError, "undefined Ruby variable '#{name}'"
257
+ end
258
+ end
259
+
260
+ # Write a Ruby local variable
261
+ def write(name, value)
262
+ @binding.local_variable_set(name.to_s.to_sym, value)
263
+ end
264
+
265
+ # Explicitly call a local proc/lambda by name
266
+ def call_proc(name, *args)
267
+ name = name.to_s.to_sym
268
+ if @binding.local_variables.include?(name)
269
+ val = @binding.local_variable_get(name)
270
+ if val.respond_to?(:call)
271
+ return val.call(*args)
272
+ end
273
+ end
274
+ raise NoMethodError, "no callable '#{name}' in Ruby scope"
275
+ end
276
+
277
+ # Proxy unknown method calls to the binding receiver.
278
+ # This lets Python do: ruby.some_method(args)
279
+ def method_missing(name, *args)
280
+ name_s = name.to_s
281
+
282
+ # First check local procs/lambdas
283
+ name_sym = name_s.to_sym
284
+ if @binding.local_variables.include?(name_sym)
285
+ val = @binding.local_variable_get(name_sym)
286
+ return val.call(*args) if val.respond_to?(:call)
287
+ end
288
+
289
+ # Then try the receiver
290
+ if @receiver.respond_to?(name_s)
291
+ return @receiver.public_send(name_s, *args)
292
+ end
293
+
294
+ super
295
+ end
296
+
297
+ def respond_to_missing?(name, include_private = false)
298
+ name_s = name.to_s
299
+ name_sym = name_s.to_sym
300
+
301
+ # Check local callables
302
+ if @binding.local_variables.include?(name_sym)
303
+ val = @binding.local_variable_get(name_sym)
304
+ return true if val.respond_to?(:call)
305
+ end
306
+
307
+ # Check receiver (public methods only)
308
+ return true if @receiver.respond_to?(name_s)
309
+
310
+ super
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ module Engines
5
+ class Ruby < Base
6
+ def execute(code)
7
+ eval(code, @binding)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ # Thread-local registry for cross-engine object references.
5
+ #
6
+ # When a complex Ruby object is passed to JS/Python, it gets registered here
7
+ # with a unique integer ID. The foreign engine holds a proxy with that ID and
8
+ # routes method calls back through the bidirectional channel.
9
+ #
10
+ # Thread-local so each engine context (which is also thread-local) has its own
11
+ # isolated set of references.
12
+ class ObjectRegistry
13
+ attr_reader :objects
14
+
15
+ def initialize
16
+ @objects = {}
17
+ @next_id = 1
18
+ @release_callbacks = []
19
+ end
20
+
21
+ # Store an object and return its reference ID.
22
+ # If the same object is already registered, return the existing ID.
23
+ def register(obj)
24
+ # Check if already registered (by object_id for identity, not equality)
25
+ @objects.each do |id, entry|
26
+ return id if entry[:object].equal?(obj)
27
+ end
28
+
29
+ id = @next_id
30
+ @next_id += 1
31
+ @objects[id] = { object: obj, type: obj.class.name }
32
+ id
33
+ end
34
+
35
+ # Retrieve an object by its reference ID.
36
+ def get(id)
37
+ entry = @objects[id]
38
+ entry ? entry[:object] : nil
39
+ end
40
+
41
+ # Register a callback invoked when any reference is released.
42
+ # The callback receives (id, entry) where entry is { object:, type: }.
43
+ def on_release(&block)
44
+ @release_callbacks << block
45
+ end
46
+
47
+ # Release a reference. Returns true if it existed.
48
+ # Fires registered on_release callbacks with the id and entry.
49
+ def release(id)
50
+ entry = @objects.delete(id)
51
+ return false unless entry
52
+
53
+ @release_callbacks.each do |cb|
54
+ cb.call(id, entry)
55
+ rescue => e
56
+ # Don't let callback errors break the release
57
+ end
58
+ true
59
+ end
60
+
61
+ # Number of live references.
62
+ def size
63
+ @objects.size
64
+ end
65
+
66
+ # Remove all references. Fires on_release for each.
67
+ def clear!
68
+ ids = @objects.keys.dup
69
+ ids.each { |id| release(id) }
70
+ @next_id = 1
71
+ end
72
+
73
+ # Check if an ID is registered.
74
+ def registered?(id)
75
+ @objects.key?(id)
76
+ end
77
+
78
+ # Thread-local singleton access
79
+ def self.current
80
+ Thread.current[:mana_object_registry] ||= new
81
+ end
82
+
83
+ def self.reset!
84
+ registry = Thread.current[:mana_object_registry]
85
+ registry&.clear!
86
+ Thread.current[:mana_object_registry] = nil
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ # A proxy object representing a remote reference to an object in another engine.
5
+ #
6
+ # When JS or Python receives a complex Ruby object, they get a RemoteRef handle.
7
+ # Method calls on the proxy are routed back to the original object via the
8
+ # ObjectRegistry and the bidirectional calling channel.
9
+ #
10
+ # On the Ruby side, RemoteRef can also wrap foreign objects (JS/Python) that
11
+ # were passed to Ruby — method calls go through the engine's eval mechanism.
12
+ #
13
+ # GC behavior:
14
+ # - When a RemoteRef is garbage collected, its finalizer releases the entry
15
+ # from the ObjectRegistry.
16
+ # - If an `on_release` callback is registered on the registry, it fires,
17
+ # allowing the source engine to be notified (e.g., to free the foreign object).
18
+ class RemoteRef
19
+ attr_reader :ref_id, :source_engine, :type_name
20
+
21
+ def initialize(ref_id, source_engine:, type_name: nil, registry: nil)
22
+ @ref_id = ref_id
23
+ @source_engine = source_engine
24
+ @type_name = type_name
25
+ @registry = registry || ObjectRegistry.current
26
+ setup_release_callback
27
+ end
28
+
29
+ # Call a method on the remote object
30
+ def method_missing(name, *args, &block)
31
+ name_s = name.to_s
32
+
33
+ # Don't proxy Ruby internal methods
34
+ return super if %w[__id__ __send__ class is_a? kind_of? instance_of? respond_to? respond_to_missing? equal? nil? frozen? inspect to_s hash].include?(name_s)
35
+
36
+ obj = @registry.get(@ref_id)
37
+ raise Mana::Error, "Remote reference #{@ref_id} has been released" unless obj
38
+
39
+ obj.public_send(name, *args, &block)
40
+ end
41
+
42
+ def respond_to_missing?(name, include_private = false)
43
+ obj = @registry.get(@ref_id)
44
+ return false unless obj
45
+ obj.respond_to?(name, false)
46
+ end
47
+
48
+ # Explicitly release this reference
49
+ def release!
50
+ @registry.release(@ref_id)
51
+ end
52
+
53
+ # Check if the referenced object is still alive
54
+ def alive?
55
+ @registry.registered?(@ref_id)
56
+ end
57
+
58
+ def inspect
59
+ "#<Mana::RemoteRef id=#{@ref_id} engine=#{@source_engine} type=#{@type_name}>"
60
+ end
61
+
62
+ def to_s
63
+ obj = @registry.get(@ref_id)
64
+ obj ? obj.to_s : inspect
65
+ end
66
+
67
+ private
68
+
69
+ # Set up a release callback via Ruby finalizer.
70
+ # When this RemoteRef is garbage collected, the registry entry is released,
71
+ # which in turn fires any on_release callbacks (notifying the source engine).
72
+ # Uses a class method to avoid capturing `self` in the closure.
73
+ def setup_release_callback
74
+ release_proc = self.class.send(:release_callback, @ref_id, @registry)
75
+ ObjectSpace.define_finalizer(self, release_proc)
76
+ end
77
+
78
+ # Build a release proc that doesn't reference the RemoteRef instance.
79
+ # This avoids the "finalizer references object to be finalized" warning.
80
+ def self.release_callback(ref_id, registry)
81
+ proc { |_| registry.release(ref_id) }
82
+ end
83
+ private_class_method :release_callback
84
+ end
85
+ 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.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/mana.rb CHANGED
@@ -11,6 +11,12 @@ require_relative "mana/namespace"
11
11
  require_relative "mana/memory_store"
12
12
  require_relative "mana/context_window"
13
13
  require_relative "mana/memory"
14
+ require_relative "mana/object_registry"
15
+ require_relative "mana/remote_ref"
16
+ require_relative "mana/engines/base"
17
+ require_relative "mana/engines/llm"
18
+ require_relative "mana/engines/ruby_eval"
19
+ require_relative "mana/engines/detect"
14
20
  require_relative "mana/engine"
15
21
  require_relative "mana/introspect"
16
22
  require_relative "mana/compiler"
@@ -44,8 +50,13 @@ module Mana
44
50
  def reset!
45
51
  @config = Config.new
46
52
  EffectRegistry.clear!
53
+ Engines.reset_detector!
54
+ ObjectRegistry.reset!
55
+ Engines::JavaScript.reset! if defined?(Engines::JavaScript)
56
+ Engines::Python.reset! if defined?(Engines::Python)
47
57
  Thread.current[:mana_memory] = nil
48
58
  Thread.current[:mana_mock] = nil
59
+ Thread.current[:mana_last_engine] = nil
49
60
  end
50
61
 
51
62
  # Define a custom effect that becomes an LLM tool
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mana
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-21 00:00:00.000000000 Z
11
+ date: 2026-02-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: binding_of_caller
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mini_racer
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.16'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.16'
27
41
  description: |
28
42
  Mana lets you write natural language strings in Ruby that execute via LLM
29
43
  with full access to your program's live state. Read/write variables, call
@@ -36,6 +50,7 @@ files:
36
50
  - CHANGELOG.md
37
51
  - LICENSE
38
52
  - README.md
53
+ - data/lang-rules.yml
39
54
  - lib/mana.rb
40
55
  - lib/mana/backends/anthropic.rb
41
56
  - lib/mana/backends/base.rb
@@ -46,12 +61,20 @@ files:
46
61
  - lib/mana/context_window.rb
47
62
  - lib/mana/effect_registry.rb
48
63
  - lib/mana/engine.rb
64
+ - lib/mana/engines/base.rb
65
+ - lib/mana/engines/detect.rb
66
+ - lib/mana/engines/javascript.rb
67
+ - lib/mana/engines/llm.rb
68
+ - lib/mana/engines/python.rb
69
+ - lib/mana/engines/ruby_eval.rb
49
70
  - lib/mana/introspect.rb
50
71
  - lib/mana/memory.rb
51
72
  - lib/mana/memory_store.rb
52
73
  - lib/mana/mixin.rb
53
74
  - lib/mana/mock.rb
54
75
  - lib/mana/namespace.rb
76
+ - lib/mana/object_registry.rb
77
+ - lib/mana/remote_ref.rb
55
78
  - lib/mana/string_ext.rb
56
79
  - lib/mana/test.rb
57
80
  - lib/mana/version.rb