ruby-mana 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/README.md +147 -1
- data/data/lang-rules.yml +196 -0
- data/lib/mana/backends/anthropic.rb +38 -0
- data/lib/mana/backends/base.rb +18 -0
- data/lib/mana/backends/openai.rb +167 -0
- data/lib/mana/backends/registry.rb +23 -0
- data/lib/mana/compiler.rb +1 -1
- data/lib/mana/config.rb +2 -0
- data/lib/mana/engine.rb +38 -412
- data/lib/mana/engines/base.rb +51 -0
- data/lib/mana/engines/detect.rb +93 -0
- data/lib/mana/engines/javascript.rb +90 -0
- data/lib/mana/engines/llm.rb +459 -0
- data/lib/mana/engines/python.rb +230 -0
- data/lib/mana/engines/ruby_eval.rb +11 -0
- data/lib/mana/mock.rb +53 -0
- data/lib/mana/test.rb +18 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +16 -0
- metadata +29 -2
data/lib/mana/engine.rb
CHANGED
|
@@ -1,447 +1,73 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
|
-
|
|
7
3
|
module Mana
|
|
8
4
|
class Engine
|
|
9
|
-
TOOLS = [
|
|
10
|
-
{
|
|
11
|
-
name: "read_var",
|
|
12
|
-
description: "Read a variable value from the Ruby scope.",
|
|
13
|
-
input_schema: {
|
|
14
|
-
type: "object",
|
|
15
|
-
properties: { name: { type: "string", description: "Variable name" } },
|
|
16
|
-
required: ["name"]
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
name: "write_var",
|
|
21
|
-
description: "Write a value to a variable in the Ruby scope. Creates the variable if it doesn't exist.",
|
|
22
|
-
input_schema: {
|
|
23
|
-
type: "object",
|
|
24
|
-
properties: {
|
|
25
|
-
name: { type: "string", description: "Variable name" },
|
|
26
|
-
value: { description: "Value to assign (any JSON type)" }
|
|
27
|
-
},
|
|
28
|
-
required: %w[name value]
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: "read_attr",
|
|
33
|
-
description: "Read an attribute from a Ruby object.",
|
|
34
|
-
input_schema: {
|
|
35
|
-
type: "object",
|
|
36
|
-
properties: {
|
|
37
|
-
obj: { type: "string", description: "Variable name holding the object" },
|
|
38
|
-
attr: { type: "string", description: "Attribute name to read" }
|
|
39
|
-
},
|
|
40
|
-
required: %w[obj attr]
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
name: "write_attr",
|
|
45
|
-
description: "Set an attribute on a Ruby object.",
|
|
46
|
-
input_schema: {
|
|
47
|
-
type: "object",
|
|
48
|
-
properties: {
|
|
49
|
-
obj: { type: "string", description: "Variable name holding the object" },
|
|
50
|
-
attr: { type: "string", description: "Attribute name to set" },
|
|
51
|
-
value: { description: "Value to assign" }
|
|
52
|
-
},
|
|
53
|
-
required: %w[obj attr value]
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
name: "call_func",
|
|
58
|
-
description: "Call a Ruby method/function available in the current scope.",
|
|
59
|
-
input_schema: {
|
|
60
|
-
type: "object",
|
|
61
|
-
properties: {
|
|
62
|
-
name: { type: "string", description: "Function/method name" },
|
|
63
|
-
args: { type: "array", description: "Arguments to pass", items: {} }
|
|
64
|
-
},
|
|
65
|
-
required: ["name"]
|
|
66
|
-
}
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
name: "done",
|
|
70
|
-
description: "Signal that the task is complete.",
|
|
71
|
-
input_schema: {
|
|
72
|
-
type: "object",
|
|
73
|
-
properties: {
|
|
74
|
-
result: { description: "Optional return value" }
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
].freeze
|
|
79
|
-
|
|
80
|
-
REMEMBER_TOOL = {
|
|
81
|
-
name: "remember",
|
|
82
|
-
description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
|
|
83
|
-
input_schema: {
|
|
84
|
-
type: "object",
|
|
85
|
-
properties: { content: { type: "string", description: "The fact to remember" } },
|
|
86
|
-
required: ["content"]
|
|
87
|
-
}
|
|
88
|
-
}.freeze
|
|
89
|
-
|
|
90
5
|
class << self
|
|
91
6
|
def run(prompt, caller_binding)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def handler_stack
|
|
96
|
-
Thread.current[:mana_handlers] ||= []
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def with_handler(handler = nil, **opts, &block)
|
|
100
|
-
handler_stack.push(handler)
|
|
101
|
-
block.call
|
|
102
|
-
ensure
|
|
103
|
-
handler_stack.pop
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Built-in tools + remember + any registered custom effects
|
|
107
|
-
def all_tools
|
|
108
|
-
tools = TOOLS.dup
|
|
109
|
-
tools << REMEMBER_TOOL unless Memory.incognito?
|
|
110
|
-
tools + Mana::EffectRegistry.tool_definitions
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def initialize(prompt, caller_binding)
|
|
115
|
-
@prompt = prompt
|
|
116
|
-
@binding = caller_binding
|
|
117
|
-
@config = Mana.config
|
|
118
|
-
@caller_path = caller_source_path
|
|
119
|
-
@incognito = Memory.incognito?
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def execute
|
|
123
|
-
Thread.current[:mana_depth] ||= 0
|
|
124
|
-
Thread.current[:mana_depth] += 1
|
|
125
|
-
nested = Thread.current[:mana_depth] > 1
|
|
126
|
-
|
|
127
|
-
# Nested calls get fresh short-term memory but share long-term
|
|
128
|
-
if nested && !@incognito
|
|
129
|
-
outer_memory = Thread.current[:mana_memory]
|
|
130
|
-
inner_memory = Mana::Memory.new
|
|
131
|
-
long_term = outer_memory&.long_term || []
|
|
132
|
-
inner_memory.instance_variable_set(:@long_term, long_term)
|
|
133
|
-
inner_memory.instance_variable_set(:@next_id, (long_term.map { |m| m[:id] }.max || 0) + 1)
|
|
134
|
-
Thread.current[:mana_memory] = inner_memory
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
context = build_context(@prompt)
|
|
138
|
-
system_prompt = build_system_prompt(context)
|
|
139
|
-
|
|
140
|
-
# Use memory's short_term messages (auto per-thread), or fresh if incognito
|
|
141
|
-
memory = @incognito ? nil : Memory.current
|
|
142
|
-
memory&.wait_for_compaction
|
|
143
|
-
|
|
144
|
-
messages = memory ? memory.short_term : []
|
|
145
|
-
messages << { role: "user", content: @prompt }
|
|
146
|
-
|
|
147
|
-
iterations = 0
|
|
148
|
-
done_result = nil
|
|
149
|
-
|
|
150
|
-
loop do
|
|
151
|
-
iterations += 1
|
|
152
|
-
raise MaxIterationsError, "exceeded #{@config.max_iterations} iterations" if iterations > @config.max_iterations
|
|
153
|
-
|
|
154
|
-
response = llm_call(system_prompt, messages)
|
|
155
|
-
tool_uses = extract_tool_uses(response)
|
|
156
|
-
|
|
157
|
-
break if tool_uses.empty?
|
|
158
|
-
|
|
159
|
-
# Append assistant message
|
|
160
|
-
messages << { role: "assistant", content: response }
|
|
161
|
-
|
|
162
|
-
# Process each tool use
|
|
163
|
-
tool_results = tool_uses.map do |tu|
|
|
164
|
-
result = handle_effect(tu, memory)
|
|
165
|
-
done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
|
|
166
|
-
{ type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
|
|
7
|
+
# Mock mode check (before anything else)
|
|
8
|
+
if Mana.mock_active?
|
|
9
|
+
return Engines::LLM.new(caller_binding).handle_mock(prompt)
|
|
167
10
|
end
|
|
168
11
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Append a final assistant summary so LLM has full context next call
|
|
175
|
-
if memory && done_result
|
|
176
|
-
messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Schedule compaction if needed (runs in background, skip for nested)
|
|
180
|
-
memory&.schedule_compaction unless nested
|
|
12
|
+
# Detect language engine
|
|
13
|
+
engine_class = detect_engine(prompt)
|
|
14
|
+
Thread.current[:mana_last_engine] = engine_name(engine_class)
|
|
181
15
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
Thread.current[:mana_memory] = outer_memory
|
|
16
|
+
# Create engine and execute
|
|
17
|
+
engine = engine_class.new(caller_binding)
|
|
18
|
+
engine.execute(prompt)
|
|
186
19
|
end
|
|
187
|
-
Thread.current[:mana_depth] -= 1
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
private
|
|
191
20
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def build_context(prompt)
|
|
195
|
-
var_names = prompt.scan(/<(\w+)>/).flatten.uniq
|
|
196
|
-
ctx = {}
|
|
197
|
-
var_names.each do |name|
|
|
198
|
-
val = resolve(name)
|
|
199
|
-
ctx[name] = serialize_value(val)
|
|
200
|
-
rescue NameError
|
|
201
|
-
# Variable doesn't exist yet — will be created by LLM
|
|
21
|
+
def detect_engine(code)
|
|
22
|
+
Engines.detect(code, context: Thread.current[:mana_last_engine])
|
|
202
23
|
end
|
|
203
|
-
ctx
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def build_system_prompt(context)
|
|
207
|
-
parts = [
|
|
208
|
-
"You are embedded inside a Ruby program. You interact with the program's live state using the provided tools.",
|
|
209
|
-
"",
|
|
210
|
-
"Rules:",
|
|
211
|
-
"- Use read_var / read_attr to inspect variables and objects.",
|
|
212
|
-
"- Use write_var to create or update variables in the Ruby scope.",
|
|
213
|
-
"- Use write_attr to set attributes on Ruby objects.",
|
|
214
|
-
"- Use call_func to call Ruby methods available in scope.",
|
|
215
|
-
"- Call done when the task is complete.",
|
|
216
|
-
"- When the user references <var>, that's a variable in scope.",
|
|
217
|
-
"- If a referenced variable doesn't exist yet, the user expects you to create it with write_var.",
|
|
218
|
-
"- Be precise with types: use numbers for numeric values, arrays for lists, strings for text."
|
|
219
|
-
]
|
|
220
|
-
|
|
221
|
-
# Inject long-term memories or incognito notice
|
|
222
|
-
if @incognito
|
|
223
|
-
parts << ""
|
|
224
|
-
parts << "You are in incognito mode. The remember tool is disabled. No memories will be loaded or saved."
|
|
225
|
-
else
|
|
226
|
-
memory = Memory.current
|
|
227
|
-
if memory
|
|
228
|
-
# Inject summaries from compaction
|
|
229
|
-
unless memory.summaries.empty?
|
|
230
|
-
parts << ""
|
|
231
|
-
parts << "Previous conversation summary:"
|
|
232
|
-
memory.summaries.each { |s| parts << " #{s}" }
|
|
233
|
-
end
|
|
234
24
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
unless memory.long_term.empty?
|
|
242
|
-
parts << ""
|
|
243
|
-
parts << "You have a `remember` tool to store new facts in long-term memory when the user asks."
|
|
244
|
-
end
|
|
25
|
+
def engine_name(klass)
|
|
26
|
+
case klass.name
|
|
27
|
+
when /JavaScript/ then "javascript"
|
|
28
|
+
when /Python/ then "python"
|
|
29
|
+
when /Ruby/ then "ruby"
|
|
30
|
+
else "natural_language"
|
|
245
31
|
end
|
|
246
32
|
end
|
|
247
33
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
context.each { |k, v| parts << " #{k} = #{v}" }
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Discover available functions from caller's source
|
|
255
|
-
methods = begin
|
|
256
|
-
Mana::Introspect.methods_from_file(@caller_path)
|
|
257
|
-
rescue => _e
|
|
258
|
-
[]
|
|
259
|
-
end
|
|
260
|
-
unless methods.empty?
|
|
261
|
-
parts << ""
|
|
262
|
-
parts << Mana::Introspect.format_for_prompt(methods)
|
|
34
|
+
# Delegate to LLM engine for backward compatibility
|
|
35
|
+
def handler_stack
|
|
36
|
+
Engines::LLM.handler_stack
|
|
263
37
|
end
|
|
264
38
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
unless custom_effects.empty?
|
|
268
|
-
parts << ""
|
|
269
|
-
parts << "Custom tools available:"
|
|
270
|
-
custom_effects.each do |t|
|
|
271
|
-
params = (t[:input_schema][:properties] || {}).keys.join(", ")
|
|
272
|
-
parts << " #{t[:name]}(#{params}) — #{t[:description]}"
|
|
273
|
-
end
|
|
39
|
+
def with_handler(handler = nil, **opts, &block)
|
|
40
|
+
Engines::LLM.with_handler(handler, **opts, &block)
|
|
274
41
|
end
|
|
275
42
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
# --- Effect Handling ---
|
|
280
|
-
|
|
281
|
-
def handle_effect(tool_use, memory = nil)
|
|
282
|
-
name = tool_use[:name]
|
|
283
|
-
input = tool_use[:input] || {}
|
|
284
|
-
# Normalize keys to strings for consistent access
|
|
285
|
-
input = input.transform_keys(&:to_s) if input.is_a?(Hash)
|
|
286
|
-
|
|
287
|
-
# Check handler stack first (legacy)
|
|
288
|
-
handler = self.class.handler_stack.last
|
|
289
|
-
return handler.call(name, input) if handler && handler.respond_to?(:call)
|
|
290
|
-
|
|
291
|
-
# Check custom effect registry
|
|
292
|
-
handled, result = Mana::EffectRegistry.handle(name, input)
|
|
293
|
-
return serialize_value(result) if handled
|
|
294
|
-
|
|
295
|
-
case name
|
|
296
|
-
when "read_var"
|
|
297
|
-
serialize_value(resolve(input["name"]))
|
|
298
|
-
|
|
299
|
-
when "write_var"
|
|
300
|
-
var_name = input["name"]
|
|
301
|
-
value = input["value"]
|
|
302
|
-
write_local(var_name, value)
|
|
303
|
-
"ok: #{var_name} = #{value.inspect}"
|
|
304
|
-
|
|
305
|
-
when "read_attr"
|
|
306
|
-
obj = resolve(input["obj"])
|
|
307
|
-
validate_name!(input["attr"])
|
|
308
|
-
serialize_value(obj.public_send(input["attr"]))
|
|
309
|
-
|
|
310
|
-
when "write_attr"
|
|
311
|
-
obj = resolve(input["obj"])
|
|
312
|
-
validate_name!(input["attr"])
|
|
313
|
-
obj.public_send("#{input['attr']}=", input["value"])
|
|
314
|
-
"ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
|
|
315
|
-
|
|
316
|
-
when "call_func"
|
|
317
|
-
func = input["name"]
|
|
318
|
-
validate_name!(func)
|
|
319
|
-
args = input["args"] || []
|
|
320
|
-
# Try method first, then local variable (supports lambdas/procs)
|
|
321
|
-
callable = if @binding.receiver.respond_to?(func.to_sym, true)
|
|
322
|
-
@binding.receiver.method(func.to_sym)
|
|
323
|
-
elsif @binding.local_variables.include?(func.to_sym)
|
|
324
|
-
@binding.local_variable_get(func.to_sym)
|
|
325
|
-
else
|
|
326
|
-
@binding.receiver.method(func.to_sym) # raise NameError
|
|
327
|
-
end
|
|
328
|
-
result = callable.call(*args)
|
|
329
|
-
serialize_value(result)
|
|
330
|
-
|
|
331
|
-
when "remember"
|
|
332
|
-
if @incognito
|
|
333
|
-
"Memory not saved (incognito mode)"
|
|
334
|
-
elsif memory
|
|
335
|
-
entry = memory.remember(input["content"])
|
|
336
|
-
"Remembered (id=#{entry[:id]}): #{input['content']}"
|
|
337
|
-
else
|
|
338
|
-
"Memory not available"
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
when "done"
|
|
342
|
-
input["result"].to_s
|
|
343
|
-
|
|
344
|
-
else
|
|
345
|
-
"error: unknown tool #{name}"
|
|
43
|
+
def all_tools
|
|
44
|
+
Engines::LLM.all_tools
|
|
346
45
|
end
|
|
347
|
-
rescue => e
|
|
348
|
-
"error: #{e.class}: #{e.message}"
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
# --- Binding Helpers ---
|
|
352
|
-
|
|
353
|
-
VALID_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
354
|
-
|
|
355
|
-
def validate_name!(name)
|
|
356
|
-
raise Mana::Error, "invalid identifier: #{name.inspect}" unless name.match?(VALID_IDENTIFIER)
|
|
357
46
|
end
|
|
358
47
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
@binding.receiver.send(name.to_sym)
|
|
365
|
-
else
|
|
366
|
-
raise NameError, "undefined variable or method '#{name}'"
|
|
367
|
-
end
|
|
48
|
+
# Backward compatibility: Engine.new(prompt, binding) delegates to Engines::LLM
|
|
49
|
+
# Some tests instantiate Engine directly and call private methods
|
|
50
|
+
def initialize(prompt, caller_binding)
|
|
51
|
+
@delegate = Engines::LLM.new(caller_binding)
|
|
52
|
+
@prompt = prompt
|
|
368
53
|
end
|
|
369
54
|
|
|
370
|
-
def
|
|
371
|
-
|
|
372
|
-
@binding.local_variable_set(name.to_sym, value)
|
|
55
|
+
def execute
|
|
56
|
+
@delegate.execute(@prompt)
|
|
373
57
|
end
|
|
374
58
|
|
|
375
|
-
|
|
376
|
-
# Walk up the call stack to find the first non-mana source file
|
|
377
|
-
loc = @binding.source_location
|
|
378
|
-
return loc[0] if loc.is_a?(Array)
|
|
379
|
-
|
|
380
|
-
# Fallback: search caller_locations
|
|
381
|
-
caller_locations(4, 20)&.each do |frame|
|
|
382
|
-
path = frame.absolute_path || frame.path
|
|
383
|
-
next if path.nil? || path.include?("mana/")
|
|
384
|
-
return path
|
|
385
|
-
end
|
|
386
|
-
nil
|
|
387
|
-
end
|
|
59
|
+
private
|
|
388
60
|
|
|
389
|
-
def
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
val.inspect
|
|
393
|
-
when Symbol
|
|
394
|
-
val.to_s.inspect
|
|
395
|
-
when Array
|
|
396
|
-
"[#{val.map { |v| serialize_value(v) }.join(', ')}]"
|
|
397
|
-
when Hash
|
|
398
|
-
pairs = val.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }
|
|
399
|
-
"{#{pairs.join(', ')}}"
|
|
61
|
+
def method_missing(method, *args, **kwargs, &block)
|
|
62
|
+
if @delegate.respond_to?(method, true)
|
|
63
|
+
@delegate.send(method, *args, **kwargs, &block)
|
|
400
64
|
else
|
|
401
|
-
|
|
402
|
-
obj_repr = ivars.map do |ivar|
|
|
403
|
-
attr_name = ivar.to_s.delete_prefix("@")
|
|
404
|
-
"#{attr_name}: #{val.instance_variable_get(ivar).inspect}" rescue nil
|
|
405
|
-
end.compact.join(", ")
|
|
406
|
-
"#<#{val.class} #{obj_repr}>"
|
|
65
|
+
super
|
|
407
66
|
end
|
|
408
67
|
end
|
|
409
68
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def llm_call(system, messages)
|
|
413
|
-
uri = URI("#{@config.base_url}/v1/messages")
|
|
414
|
-
body = {
|
|
415
|
-
model: @config.model,
|
|
416
|
-
max_tokens: 4096,
|
|
417
|
-
system: system,
|
|
418
|
-
tools: self.class.all_tools,
|
|
419
|
-
messages: messages
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
423
|
-
http.use_ssl = uri.scheme == "https"
|
|
424
|
-
http.read_timeout = 120
|
|
425
|
-
|
|
426
|
-
req = Net::HTTP::Post.new(uri)
|
|
427
|
-
req["Content-Type"] = "application/json"
|
|
428
|
-
req["x-api-key"] = @config.api_key
|
|
429
|
-
req["anthropic-version"] = "2023-06-01"
|
|
430
|
-
req.body = JSON.generate(body)
|
|
431
|
-
|
|
432
|
-
res = http.request(req)
|
|
433
|
-
raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
434
|
-
|
|
435
|
-
parsed = JSON.parse(res.body, symbolize_names: true)
|
|
436
|
-
parsed[:content] || []
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
def extract_tool_uses(content)
|
|
440
|
-
return [] unless content.is_a?(Array)
|
|
441
|
-
|
|
442
|
-
content
|
|
443
|
-
.select { |block| block[:type] == "tool_use" }
|
|
444
|
-
.map { |block| { id: block[:id], name: block[:name], input: block[:input] || {} } }
|
|
69
|
+
def respond_to_missing?(method, include_private = false)
|
|
70
|
+
@delegate.respond_to?(method, include_private) || super
|
|
445
71
|
end
|
|
446
72
|
end
|
|
447
73
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
module Engines
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :config, :binding
|
|
7
|
+
|
|
8
|
+
def initialize(caller_binding, config = Mana.config)
|
|
9
|
+
@binding = caller_binding
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Execute code/prompt in this engine, return the result
|
|
14
|
+
# Subclasses must implement this
|
|
15
|
+
def execute(code)
|
|
16
|
+
raise NotImplementedError, "#{self.class}#execute not implemented"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Read a variable from the Ruby binding
|
|
20
|
+
def read_var(name)
|
|
21
|
+
if @binding.local_variables.include?(name.to_sym)
|
|
22
|
+
@binding.local_variable_get(name.to_sym)
|
|
23
|
+
elsif @binding.receiver.respond_to?(name.to_sym, true)
|
|
24
|
+
@binding.receiver.send(name.to_sym)
|
|
25
|
+
else
|
|
26
|
+
raise NameError, "undefined variable: #{name}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Write a variable to the Ruby binding
|
|
31
|
+
def write_var(name, value)
|
|
32
|
+
@binding.local_variable_set(name.to_sym, value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Serialize a Ruby value for cross-language transfer
|
|
36
|
+
# Simple types: copy. Complex objects: will be remote refs (future)
|
|
37
|
+
def serialize(value)
|
|
38
|
+
case value
|
|
39
|
+
when Numeric, String, Symbol, TrueClass, FalseClass, NilClass
|
|
40
|
+
value
|
|
41
|
+
when Array
|
|
42
|
+
value.map { |v| serialize(v) }
|
|
43
|
+
when Hash
|
|
44
|
+
value.transform_values { |v| serialize(v) }
|
|
45
|
+
else
|
|
46
|
+
value.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Mana
|
|
6
|
+
module Engines
|
|
7
|
+
RULES_PATH = File.join(__dir__, "..", "..", "..", "data", "lang-rules.yml")
|
|
8
|
+
|
|
9
|
+
class Detector
|
|
10
|
+
attr_reader :rules
|
|
11
|
+
|
|
12
|
+
def initialize(rules_path = RULES_PATH)
|
|
13
|
+
@rules = YAML.safe_load(File.read(rules_path))["languages"]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Detect language, return engine class
|
|
17
|
+
# context: previous detection result (for context inference)
|
|
18
|
+
def detect(code, context: nil)
|
|
19
|
+
scores = {}
|
|
20
|
+
@rules.each do |lang, rule_set|
|
|
21
|
+
scores[lang] = score(code, rule_set)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Context inference: boost previous language slightly
|
|
25
|
+
# Only boost if there's already some evidence (score > 0)
|
|
26
|
+
if context && scores[context] && scores[context] > 0
|
|
27
|
+
scores[context] += 2
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
best = scores.max_by { |_, v| v }
|
|
31
|
+
|
|
32
|
+
# If best score is very low, default to natural_language (LLM)
|
|
33
|
+
if best[1] <= 0
|
|
34
|
+
return engine_for("natural_language")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
engine_for(best[0])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def score(code, rule_set)
|
|
43
|
+
s = 0
|
|
44
|
+
# Strong signals: +3 each
|
|
45
|
+
(rule_set["strong"] || []).each { |token| s += 3 if code.include?(token) }
|
|
46
|
+
# Weak signals: +1 each
|
|
47
|
+
(rule_set["weak"] || []).each { |token| s += 1 if code.include?(token) }
|
|
48
|
+
# Anti signals: -5 each (strong negative)
|
|
49
|
+
(rule_set["anti"] || []).each { |token| s -= 5 if code.include?(token) }
|
|
50
|
+
# Pattern signals: +4 each
|
|
51
|
+
(rule_set["patterns"] || []).each do |pattern|
|
|
52
|
+
s += 4 if code.match?(Regexp.new(pattern))
|
|
53
|
+
end
|
|
54
|
+
s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def engine_for(lang)
|
|
58
|
+
case lang
|
|
59
|
+
when "javascript" then load_js_engine
|
|
60
|
+
when "python" then load_py_engine
|
|
61
|
+
when "ruby" then Engines::Ruby
|
|
62
|
+
else Engines::LLM
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def load_js_engine
|
|
67
|
+
require_relative "javascript"
|
|
68
|
+
Engines::JavaScript
|
|
69
|
+
rescue LoadError => e
|
|
70
|
+
warn "Mana: JavaScript engine unavailable (#{e.message}), falling back to LLM"
|
|
71
|
+
Engines::LLM
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def load_py_engine
|
|
75
|
+
require_relative "python"
|
|
76
|
+
Engines::Python
|
|
77
|
+
rescue LoadError => e
|
|
78
|
+
warn "Mana: Python engine unavailable (#{e.message}), falling back to LLM"
|
|
79
|
+
Engines::LLM
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Module-level convenience method
|
|
84
|
+
def self.detect(code, context: nil)
|
|
85
|
+
@detector ||= Detector.new
|
|
86
|
+
@detector.detect(code, context: context)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.reset_detector!
|
|
90
|
+
@detector = nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|