ruby-mana 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04d7095024d30500930422b72e86fcdb1a27b1fa9a9f62ca70c9c124c2ceab77
4
- data.tar.gz: 6e264f8292e908d45776481aa6af33bed5eb7d9113874d13e72f3a3cde513107
3
+ metadata.gz: 4880a32558b5c8a6d08e18d32893e3ab8ae4f9e00ba97009b7c95525998a28a2
4
+ data.tar.gz: 1244df8f3307dd271471f5a67d2b4b5439086ae78b63f44e1a0e1ecaecf12a40
5
5
  SHA512:
6
- metadata.gz: 470e54537747878bbe07c6825a12bf515a2909af872ee4908e0eb07a0e58b31187dacadb6b20d988782a8f9a2bfaf12c4523ae09c149dc764ad90c2b8a420871
7
- data.tar.gz: 1c322b45e74e8fa67aab109c10a5a7cac2d6546d1de61912157192e9f15330b8c2520bd5e90b4c70b942d36b7de4ecabfc165c2774be4b8489936e68e14614a0
6
+ metadata.gz: c58a40c28a7adecf324099f056a06ca65710f4ba82f9b035fed5a886be061da9cf70259b8372063cdaf60505febe8253455e874b502312811652dedd013b042c
7
+ data.tar.gz: 2c2c8887ef9da0f01ec7afc51c432f06fe9684e7b732659b3ae3994976d450fcffbaf0bcdb69344045f578126ee4127ebff891ec0b02d6a17ed9f29e3812a18c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1] - 2026-02-24
4
+
5
+ ### Changed
6
+ - **Engine capability refactor** — replaced three redundant capability flags (`supports_remote_ref?`, `supports_bidirectional?`, `supports_state?`) with a single `execution_engine?` method
7
+ - Clearer semantics: execution engines (Ruby/JS/Python) vs reasoning engines (LLM)
8
+ - Fully backward compatible — old methods still work as derived properties
9
+
3
10
  ## [0.5.0] - 2026-02-22
4
11
 
5
12
  ### Added
data/data/lang-rules.yml CHANGED
@@ -77,7 +77,7 @@ languages:
77
77
  - "if "
78
78
  - "is not"
79
79
  - "not in"
80
- - "pass"
80
+ - "pass "
81
81
  anti:
82
82
  - "import this"
83
83
  - "import that"
@@ -140,8 +140,8 @@ languages:
140
140
  - "lambda {"
141
141
  - "-> {"
142
142
  weak:
143
- - "end"
144
- - "begin"
143
+ - "end "
144
+ - "begin "
145
145
  - "class "
146
146
  - "return "
147
147
  - "if "
@@ -10,6 +10,34 @@ module Mana
10
10
  @config = config
11
11
  end
12
12
 
13
+ # --- Capability queries ---
14
+ # Subclasses override to declare what they support.
15
+
16
+ # Is this an execution engine (Ruby/JS/Python) or a reasoning engine (LLM)?
17
+ # Execution engines can execute code, hold state, and be called bidirectionally.
18
+ # Reasoning engines process natural language but cannot hold references or state.
19
+ def execution_engine?
20
+ true
21
+ end
22
+
23
+ # Can this engine hold remote references to objects in other engines?
24
+ # Derived from execution_engine? — only execution engines support remote refs.
25
+ def supports_remote_ref?
26
+ execution_engine?
27
+ end
28
+
29
+ # Can code in this engine call back into another engine (bidirectional)?
30
+ # Derived from execution_engine? — only execution engines support bidirectional calls.
31
+ def supports_bidirectional?
32
+ execution_engine?
33
+ end
34
+
35
+ # Does this engine maintain mutable state across calls?
36
+ # Derived from execution_engine? — only execution engines maintain state.
37
+ def supports_state?
38
+ execution_engine?
39
+ end
40
+
13
41
  # Execute code/prompt in this engine, return the result
14
42
  # Subclasses must implement this
15
43
  def execute(code)
@@ -7,10 +7,56 @@ rescue LoadError
7
7
  end
8
8
 
9
9
  require "json"
10
+ require "set"
10
11
 
11
12
  module Mana
12
13
  module Engines
13
14
  class JavaScript < Base
15
+ # JS helper code that creates Proxy wrappers for remote Ruby objects.
16
+ # Injected once per V8 context.
17
+ JS_PROXY_HELPER = <<~JS
18
+ (function() {
19
+ if (typeof __mana_create_proxy !== 'undefined') return;
20
+
21
+ // FinalizationRegistry for automatic GC of remote refs.
22
+ // When a JS proxy is garbage collected, notify Ruby to release the original object.
23
+ if (typeof FinalizationRegistry !== 'undefined') {
24
+ globalThis.__mana_ref_gc = new FinalizationRegistry(function(refId) {
25
+ try { ruby.__ref_release(refId); } catch(e) {}
26
+ });
27
+ }
28
+
29
+ globalThis.__mana_create_proxy = function(refId, typeName) {
30
+ var proxy = new Proxy({ __mana_ref: refId, __mana_type: typeName }, {
31
+ get: function(target, prop) {
32
+ if (prop === '__mana_ref') return target.__mana_ref;
33
+ if (prop === '__mana_type') return target.__mana_type;
34
+ if (prop === '__mana_alive') return ruby.__ref_alive(refId);
35
+ if (prop === 'release') return function() { ruby.__ref_release(refId); };
36
+ if (prop === 'toString' || prop === Symbol.toPrimitive) {
37
+ return function() { return ruby.__ref_to_s(refId); };
38
+ }
39
+ if (prop === 'inspect') {
40
+ return function() { return 'RemoteRef<' + typeName + '#' + refId + '>'; };
41
+ }
42
+ if (typeof prop === 'symbol') return undefined;
43
+ return function() {
44
+ var args = Array.prototype.slice.call(arguments);
45
+ return ruby.__ref_call(refId, prop, JSON.stringify(args));
46
+ };
47
+ }
48
+ });
49
+
50
+ // Register with FinalizationRegistry so GC triggers release
51
+ if (globalThis.__mana_ref_gc) {
52
+ globalThis.__mana_ref_gc.register(proxy, refId);
53
+ }
54
+
55
+ return proxy;
56
+ };
57
+ })();
58
+ JS
59
+
14
60
  # Thread-local persistent V8 context (lazy-loaded, long-running)
15
61
  def self.context
16
62
  Thread.current[:mana_js_context] ||= create_context
@@ -21,21 +67,26 @@ module Mana
21
67
  end
22
68
 
23
69
  def self.reset!
24
- Thread.current[:mana_js_context]&.dispose
70
+ ctx = Thread.current[:mana_js_context]
71
+ ctx&.dispose
25
72
  Thread.current[:mana_js_context] = nil
73
+ Thread.current[:mana_js_callbacks_attached] = nil
74
+ ObjectRegistry.reset!
26
75
  end
27
76
 
28
77
  def execute(code)
29
78
  ctx = self.class.context
30
79
 
31
- # 1. Scan code for Ruby variable references
32
- # Variables from Ruby binding are injected into JS context
80
+ # 1. Attach Ruby callbacks (methods + effects + ref operations)
81
+ attach_ruby_callbacks(ctx)
82
+
83
+ # 2. Inject Ruby variables into JS scope
33
84
  inject_ruby_vars(ctx, code)
34
85
 
35
- # 2. Execute the JS code
86
+ # 3. Execute the JS code
36
87
  result = ctx.eval(code)
37
88
 
38
- # 3. Extract any new/modified variables back to Ruby binding
89
+ # 4. Extract any new/modified variables back to Ruby binding
39
90
  extract_js_vars(ctx, code)
40
91
 
41
92
  result
@@ -43,20 +94,183 @@ module Mana
43
94
 
44
95
  private
45
96
 
97
+ def attach_ruby_callbacks(ctx)
98
+ attached = Thread.current[:mana_js_callbacks_attached] ||= Set.new
99
+
100
+ # Install the JS Proxy helper (once per context)
101
+ unless attached.include?("__proxy_helper")
102
+ ctx.eval(JS_PROXY_HELPER)
103
+ attached << "__proxy_helper"
104
+ end
105
+
106
+ # ruby.read / ruby.write -- variable bridge
107
+ unless attached.include?("ruby.read")
108
+ bnd = @binding
109
+ ctx.attach("ruby.read", proc { |name|
110
+ sym = name.to_sym
111
+ if bnd.local_variables.include?(sym)
112
+ val = bnd.local_variable_get(sym)
113
+ json_safe(val)
114
+ else
115
+ nil
116
+ end
117
+ })
118
+ attached << "ruby.read"
119
+ end
120
+
121
+ unless attached.include?("ruby.write")
122
+ bnd = @binding
123
+ ctx.attach("ruby.write", proc { |name, value|
124
+ bnd.local_variable_set(name.to_sym, value)
125
+ value
126
+ })
127
+ attached << "ruby.write"
128
+ end
129
+
130
+ # Remote reference operations
131
+ attach_ref_callbacks(ctx, attached)
132
+
133
+ # Attach methods from the caller's receiver
134
+ attach_receiver_methods(ctx, attached)
135
+
136
+ # Attach registered Mana effects
137
+ attach_effects(ctx, attached)
138
+ end
139
+
140
+ # Attach callbacks for operating on remote Ruby object references from JS.
141
+ def attach_ref_callbacks(ctx, attached)
142
+ registry = ObjectRegistry.current
143
+
144
+ unless attached.include?("ruby.__ref_call")
145
+ ctx.attach("ruby.__ref_call", proc { |ref_id, method_name, args_json|
146
+ obj = registry.get(ref_id)
147
+ raise "Remote reference #{ref_id} has been released" unless obj
148
+
149
+ args = args_json ? JSON.parse(args_json) : []
150
+ result = obj.public_send(method_name.to_sym, *args)
151
+ json_safe(result)
152
+ })
153
+ attached << "ruby.__ref_call"
154
+ end
155
+
156
+ unless attached.include?("ruby.__ref_release")
157
+ ctx.attach("ruby.__ref_release", proc { |ref_id|
158
+ registry.release(ref_id)
159
+ nil
160
+ })
161
+ attached << "ruby.__ref_release"
162
+ end
163
+
164
+ unless attached.include?("ruby.__ref_alive")
165
+ ctx.attach("ruby.__ref_alive", proc { |ref_id|
166
+ registry.registered?(ref_id)
167
+ })
168
+ attached << "ruby.__ref_alive"
169
+ end
170
+
171
+ unless attached.include?("ruby.__ref_to_s")
172
+ ctx.attach("ruby.__ref_to_s", proc { |ref_id|
173
+ obj = registry.get(ref_id)
174
+ obj ? obj.to_s : "released"
175
+ })
176
+ attached << "ruby.__ref_to_s"
177
+ end
178
+ end
179
+
180
+ def attach_receiver_methods(ctx, attached)
181
+ receiver = @binding.receiver
182
+ user_methods = receiver.class.instance_methods(false) -
183
+ Object.instance_methods -
184
+ [:~@]
185
+
186
+ user_methods.each do |method_name|
187
+ key = "ruby.#{method_name}"
188
+ next if attached.include?(key)
189
+
190
+ recv = receiver
191
+ ctx.attach(key, proc { |*args| json_safe(recv.public_send(method_name, *args)) })
192
+ attached << key
193
+ end
194
+ end
195
+
196
+ def attach_effects(ctx, attached)
197
+ Mana::EffectRegistry.registry.each do |name, effect|
198
+ key = "ruby.#{name}"
199
+ next if attached.include?(key)
200
+
201
+ eff = effect
202
+ ctx.attach(key, proc { |*args|
203
+ input = if args.length == 1 && args[0].is_a?(Hash)
204
+ args[0]
205
+ elsif eff.params.length == args.length
206
+ eff.params.zip(args).to_h { |p, v| [p[:name], v] }
207
+ else
208
+ {}
209
+ end
210
+ json_safe(eff.call(input))
211
+ })
212
+ attached << key
213
+ end
214
+ end
215
+
46
216
  def inject_ruby_vars(ctx, code)
47
217
  @binding.local_variables.each do |var_name|
48
- # Only inject variables actually referenced in the code (word-boundary match)
49
- pattern = /\b#{Regexp.escape(var_name.to_s)}\b/
50
- next unless code.match?(pattern)
218
+ next unless code.match?(/\b#{Regexp.escape(var_name.to_s)}\b/)
51
219
 
52
220
  value = @binding.local_variable_get(var_name)
53
- serialized = serialize(value)
54
- ctx.eval("var #{var_name} = #{JSON.generate(serialized)}")
221
+ inject_value(ctx, var_name.to_s, value)
55
222
  rescue => e
56
223
  next
57
224
  end
58
225
  end
59
226
 
227
+ # Inject a single value into the JS context.
228
+ # Simple types are serialized to JSON. Complex objects become remote ref proxies.
229
+ def inject_value(ctx, name, value)
230
+ case value
231
+ when Numeric, String, TrueClass, FalseClass, NilClass
232
+ ctx.eval("var #{name} = #{JSON.generate(value)}")
233
+ when Symbol
234
+ ctx.eval("var #{name} = #{JSON.generate(value.to_s)}")
235
+ when Array
236
+ ctx.eval("var #{name} = #{JSON.generate(serialize_array(value))}")
237
+ when Hash
238
+ ctx.eval("var #{name} = #{JSON.generate(serialize_hash(value))}")
239
+ else
240
+ # Complex object → register and create JS Proxy
241
+ ref_id = ObjectRegistry.current.register(value)
242
+ ctx.eval("var #{name} = __mana_create_proxy(#{ref_id}, #{JSON.generate(value.class.name)})")
243
+ end
244
+ end
245
+
246
+ def serialize_array(arr)
247
+ arr.map { |v| simple_value?(v) ? serialize_simple(v) : v.to_s }
248
+ end
249
+
250
+ def serialize_hash(hash)
251
+ hash.transform_keys(&:to_s).transform_values { |v|
252
+ simple_value?(v) ? serialize_simple(v) : v.to_s
253
+ }
254
+ end
255
+
256
+ def simple_value?(value)
257
+ case value
258
+ when Numeric, String, Symbol, TrueClass, FalseClass, NilClass, Array, Hash
259
+ true
260
+ else
261
+ false
262
+ end
263
+ end
264
+
265
+ def serialize_simple(value)
266
+ case value
267
+ when Symbol then value.to_s
268
+ when Array then serialize_array(value)
269
+ when Hash then serialize_hash(value)
270
+ else value
271
+ end
272
+ end
273
+
60
274
  def extract_js_vars(ctx, code)
61
275
  declared_vars = extract_declared_vars(code)
62
276
  declared_vars.each do |var_name|
@@ -72,19 +286,29 @@ module Mana
72
286
 
73
287
  def extract_declared_vars(code)
74
288
  vars = []
75
- # Match: const x = ..., let x = ..., var x = ...
76
289
  code.scan(/\b(?:const|let|var)\s+(\w+)\s*=/).each { |m| vars << m[0] }
77
- # Match: bare assignment at start of line: x = ...
78
- # But NOT: x === ..., x == ..., x => ...
79
290
  code.scan(/^(\w+)\s*=[^=>]/).each { |m| vars << m[0] }
80
291
  vars.uniq
81
292
  end
82
293
 
83
294
  def deserialize(value)
84
- # JS values come back as Ruby primitives from mini_racer
85
- # Arrays and Hashes are automatically converted
86
295
  value
87
296
  end
297
+
298
+ def json_safe(value)
299
+ case value
300
+ when Numeric, String, TrueClass, FalseClass, NilClass
301
+ value
302
+ when Symbol
303
+ value.to_s
304
+ when Array
305
+ value.map { |v| json_safe(v) }
306
+ when Hash
307
+ value.transform_keys(&:to_s).transform_values { |v| json_safe(v) }
308
+ else
309
+ value.to_s
310
+ end
311
+ end
88
312
  end
89
313
  end
90
314
  end
@@ -5,6 +5,14 @@ require "json"
5
5
  module Mana
6
6
  module Engines
7
7
  class LLM < Base
8
+ # LLM is a reasoning engine, not an execution engine.
9
+ # It understands natural language and uses tool-calling to interact with
10
+ # Ruby variables, but it cannot hold remote object references, maintain
11
+ # state, or be called back from other engines.
12
+ def execution_engine?
13
+ false
14
+ end
15
+
8
16
  TOOLS = [
9
17
  {
10
18
  name: "read_var",
@@ -11,6 +11,50 @@ require "json"
11
11
  module Mana
12
12
  module Engines
13
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
+
14
58
  # Thread-local persistent Python state
15
59
  # PyCall shares a single Python interpreter per process,
16
60
  # but we track our own variable namespace
@@ -25,14 +69,25 @@ module Mana
25
69
  def self.reset!
26
70
  ns = Thread.current[:mana_py_namespace]
27
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
28
78
  PyCall.exec("pass") # ensure interpreter is alive
29
79
  Thread.current[:mana_py_namespace] = nil
30
80
  end
81
+ Thread.current[:mana_py_gc_injected] = nil
82
+ ObjectRegistry.reset!
31
83
  end
32
84
 
33
85
  def execute(code)
34
86
  ns = self.class.namespace
35
87
 
88
+ # 0. Inject GC helper (once per namespace)
89
+ inject_gc_helper(ns)
90
+
36
91
  # 1. Inject Ruby variables into Python namespace
37
92
  inject_ruby_vars(ns, code)
38
93
 
@@ -59,6 +114,21 @@ module Mana
59
114
 
60
115
  private
61
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
+
62
132
  def inject_ruby_vars(ns, code)
63
133
  @binding.local_variables.each do |var_name|
64
134
  value = @binding.local_variable_get(var_name)
@@ -112,8 +182,9 @@ module Mana
112
182
  end
113
183
 
114
184
  # Serialize Ruby values for Python injection.
115
- # Procs/lambdas and Ruby objects are passed directly --
116
- # PyCall wraps them so Python can call their methods.
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.
117
188
  def serialize_for_py(value)
118
189
  case value
119
190
  when Numeric, String, TrueClass, FalseClass, NilClass
@@ -128,14 +199,27 @@ module Mana
128
199
  h[key] = serialize_for_py(v)
129
200
  end
130
201
  when Proc, Method
131
- # Pass callables directly -- PyCall wraps them as Python callables
202
+ ref_id = ObjectRegistry.current.register(value)
203
+ track_in_python(ref_id, value)
132
204
  value
133
205
  else
134
- # Pass Ruby objects directly -- Python can call their methods via PyCall
206
+ ref_id = ObjectRegistry.current.register(value)
207
+ track_in_python(ref_id, value)
135
208
  value
136
209
  end
137
210
  end
138
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
+
139
223
  def deserialize_py(value)
140
224
  if defined?(PyCall::PyObjectWrapper) && value.is_a?(PyCall::PyObjectWrapper)
141
225
  begin
@@ -202,7 +286,7 @@ module Mana
202
286
  return val.call(*args) if val.respond_to?(:call)
203
287
  end
204
288
 
205
- # Then try the receiver (public methods only)
289
+ # Then try the receiver
206
290
  if @receiver.respond_to?(name_s)
207
291
  return @receiver.public_send(name_s, *args)
208
292
  end
@@ -220,7 +304,7 @@ module Mana
220
304
  return true if val.respond_to?(:call)
221
305
  end
222
306
 
223
- # Check receiver (public methods only, matching method_missing)
307
+ # Check receiver (public methods only)
224
308
  return true if @receiver.respond_to?(name_s)
225
309
 
226
310
  super
@@ -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.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/mana.rb CHANGED
@@ -11,6 +11,8 @@ 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"
14
16
  require_relative "mana/engines/base"
15
17
  require_relative "mana/engines/llm"
16
18
  require_relative "mana/engines/ruby_eval"
@@ -49,6 +51,7 @@ module Mana
49
51
  @config = Config.new
50
52
  EffectRegistry.clear!
51
53
  Engines.reset_detector!
54
+ ObjectRegistry.reset!
52
55
  Engines::JavaScript.reset! if defined?(Engines::JavaScript)
53
56
  Engines::Python.reset! if defined?(Engines::Python)
54
57
  Thread.current[:mana_memory] = nil
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.5.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-22 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
@@ -73,6 +73,8 @@ files:
73
73
  - lib/mana/mixin.rb
74
74
  - lib/mana/mock.rb
75
75
  - lib/mana/namespace.rb
76
+ - lib/mana/object_registry.rb
77
+ - lib/mana/remote_ref.rb
76
78
  - lib/mana/string_ext.rb
77
79
  - lib/mana/test.rb
78
80
  - lib/mana/version.rb