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 +4 -4
- data/CHANGELOG.md +7 -0
- data/data/lang-rules.yml +3 -3
- data/lib/mana/engines/base.rb +28 -0
- data/lib/mana/engines/javascript.rb +239 -15
- data/lib/mana/engines/llm.rb +8 -0
- data/lib/mana/engines/python.rb +90 -6
- data/lib/mana/object_registry.rb +89 -0
- data/lib/mana/remote_ref.rb +85 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +3 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4880a32558b5c8a6d08e18d32893e3ab8ae4f9e00ba97009b7c95525998a28a2
|
|
4
|
+
data.tar.gz: 1244df8f3307dd271471f5a67d2b4b5439086ae78b63f44e1a0e1ecaecf12a40
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 "
|
data/lib/mana/engines/base.rb
CHANGED
|
@@ -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]
|
|
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.
|
|
32
|
-
|
|
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
|
-
#
|
|
86
|
+
# 3. Execute the JS code
|
|
36
87
|
result = ctx.eval(code)
|
|
37
88
|
|
|
38
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/mana/engines/llm.rb
CHANGED
|
@@ -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",
|
data/lib/mana/engines/python.rb
CHANGED
|
@@ -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
|
-
#
|
|
116
|
-
#
|
|
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
|
-
|
|
202
|
+
ref_id = ObjectRegistry.current.register(value)
|
|
203
|
+
track_in_python(ref_id, value)
|
|
132
204
|
value
|
|
133
205
|
else
|
|
134
|
-
|
|
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
|
|
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
|
|
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
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.
|
|
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-
|
|
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
|