ruby-mana 0.4.0 → 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.
data/lib/mana/engine.rb CHANGED
@@ -1,467 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module Mana
6
4
  class Engine
7
- TOOLS = [
8
- {
9
- name: "read_var",
10
- description: "Read a variable value from the Ruby scope.",
11
- input_schema: {
12
- type: "object",
13
- properties: { name: { type: "string", description: "Variable name" } },
14
- required: ["name"]
15
- }
16
- },
17
- {
18
- name: "write_var",
19
- description: "Write a value to a variable in the Ruby scope. Creates the variable if it doesn't exist.",
20
- input_schema: {
21
- type: "object",
22
- properties: {
23
- name: { type: "string", description: "Variable name" },
24
- value: { description: "Value to assign (any JSON type)" }
25
- },
26
- required: %w[name value]
27
- }
28
- },
29
- {
30
- name: "read_attr",
31
- description: "Read an attribute from a Ruby object.",
32
- input_schema: {
33
- type: "object",
34
- properties: {
35
- obj: { type: "string", description: "Variable name holding the object" },
36
- attr: { type: "string", description: "Attribute name to read" }
37
- },
38
- required: %w[obj attr]
39
- }
40
- },
41
- {
42
- name: "write_attr",
43
- description: "Set an attribute on a Ruby object.",
44
- input_schema: {
45
- type: "object",
46
- properties: {
47
- obj: { type: "string", description: "Variable name holding the object" },
48
- attr: { type: "string", description: "Attribute name to set" },
49
- value: { description: "Value to assign" }
50
- },
51
- required: %w[obj attr value]
52
- }
53
- },
54
- {
55
- name: "call_func",
56
- description: "Call a Ruby method/function available in the current scope.",
57
- input_schema: {
58
- type: "object",
59
- properties: {
60
- name: { type: "string", description: "Function/method name" },
61
- args: { type: "array", description: "Arguments to pass", items: {} }
62
- },
63
- required: ["name"]
64
- }
65
- },
66
- {
67
- name: "done",
68
- description: "Signal that the task is complete.",
69
- input_schema: {
70
- type: "object",
71
- properties: {
72
- result: { description: "Optional return value" }
73
- }
74
- }
75
- }
76
- ].freeze
77
-
78
- REMEMBER_TOOL = {
79
- name: "remember",
80
- description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
81
- input_schema: {
82
- type: "object",
83
- properties: { content: { type: "string", description: "The fact to remember" } },
84
- required: ["content"]
85
- }
86
- }.freeze
87
-
88
5
  class << self
89
6
  def run(prompt, caller_binding)
90
- new(prompt, caller_binding).execute
91
- end
92
-
93
- def handler_stack
94
- Thread.current[:mana_handlers] ||= []
95
- end
96
-
97
- def with_handler(handler = nil, **opts, &block)
98
- handler_stack.push(handler)
99
- block.call
100
- ensure
101
- handler_stack.pop
102
- end
103
-
104
- # Built-in tools + remember + any registered custom effects
105
- def all_tools
106
- tools = TOOLS.dup
107
- tools << REMEMBER_TOOL unless Memory.incognito?
108
- tools + Mana::EffectRegistry.tool_definitions
109
- end
110
- end
111
-
112
- def initialize(prompt, caller_binding)
113
- @prompt = prompt
114
- @binding = caller_binding
115
- @config = Mana.config
116
- @caller_path = caller_source_path
117
- @incognito = Memory.incognito?
118
- end
119
-
120
- def execute
121
- # Mock mode: match prompt and return stubbed values, no API calls
122
- return handle_mock(@prompt) if Mana.mock_active?
123
-
124
- Thread.current[:mana_depth] ||= 0
125
- Thread.current[:mana_depth] += 1
126
- nested = Thread.current[:mana_depth] > 1
127
-
128
- # Nested calls get fresh short-term memory but share long-term
129
- if nested && !@incognito
130
- outer_memory = Thread.current[:mana_memory]
131
- inner_memory = Mana::Memory.new
132
- long_term = outer_memory&.long_term || []
133
- inner_memory.instance_variable_set(:@long_term, long_term)
134
- inner_memory.instance_variable_set(:@next_id, (long_term.map { |m| m[:id] }.max || 0) + 1)
135
- Thread.current[:mana_memory] = inner_memory
136
- end
137
-
138
- context = build_context(@prompt)
139
- system_prompt = build_system_prompt(context)
140
-
141
- # Use memory's short_term messages (auto per-thread), or fresh if incognito
142
- memory = @incognito ? nil : Memory.current
143
- memory&.wait_for_compaction
144
-
145
- messages = memory ? memory.short_term : []
146
- messages << { role: "user", content: @prompt }
147
-
148
- iterations = 0
149
- done_result = nil
150
-
151
- loop do
152
- iterations += 1
153
- raise MaxIterationsError, "exceeded #{@config.max_iterations} iterations" if iterations > @config.max_iterations
154
-
155
- response = llm_call(system_prompt, messages)
156
- tool_uses = extract_tool_uses(response)
157
-
158
- break if tool_uses.empty?
159
-
160
- # Append assistant message
161
- messages << { role: "assistant", content: response }
162
-
163
- # Process each tool use
164
- tool_results = tool_uses.map do |tu|
165
- result = handle_effect(tu, memory)
166
- done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
167
- { 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)
168
10
  end
169
11
 
170
- messages << { role: "user", content: tool_results }
171
-
172
- break if tool_uses.any? { |t| t[:name] == "done" }
173
- end
174
-
175
- # Append a final assistant summary so LLM has full context next call
176
- if memory && done_result
177
- messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
178
- end
179
-
180
- # Schedule compaction if needed (runs in background, skip for nested)
181
- memory&.schedule_compaction unless nested
182
-
183
- done_result
184
- ensure
185
- if nested && !@incognito
186
- Thread.current[:mana_memory] = outer_memory
187
- end
188
- Thread.current[:mana_depth] -= 1 if Thread.current[:mana_depth]
189
- end
190
-
191
- private
192
-
193
- # --- Mock Handling ---
194
-
195
- def handle_mock(prompt)
196
- mock = Mana.current_mock
197
- stub = mock.match(prompt)
198
-
199
- unless stub
200
- truncated = prompt.length > 60 ? "#{prompt[0..57]}..." : prompt
201
- raise MockError, "No mock matched: \"#{truncated}\"\n Add: mock_prompt \"#{truncated}\", _return: \"...\""
202
- end
203
-
204
- values = if stub.block
205
- stub.block.call(prompt)
206
- else
207
- stub.values.dup
208
- end
209
-
210
- return_value = values.delete(:_return)
12
+ # Detect language engine
13
+ engine_class = detect_engine(prompt)
14
+ Thread.current[:mana_last_engine] = engine_name(engine_class)
211
15
 
212
- values.each do |name, value|
213
- write_local(name.to_s, value)
16
+ # Create engine and execute
17
+ engine = engine_class.new(caller_binding)
18
+ engine.execute(prompt)
214
19
  end
215
20
 
216
- # Record in short-term memory if not incognito
217
- if !@incognito
218
- memory = Memory.current
219
- if memory
220
- memory.short_term << { role: "user", content: prompt }
221
- memory.short_term << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] }
222
- end
21
+ def detect_engine(code)
22
+ Engines.detect(code, context: Thread.current[:mana_last_engine])
223
23
  end
224
24
 
225
- return_value || values.values.first
226
- end
227
-
228
- # --- Context Building ---
229
-
230
- def build_context(prompt)
231
- var_names = prompt.scan(/<(\w+)>/).flatten.uniq
232
- ctx = {}
233
- var_names.each do |name|
234
- val = resolve(name)
235
- ctx[name] = serialize_value(val)
236
- rescue NameError
237
- # Variable doesn't exist yet — will be created by LLM
238
- end
239
- ctx
240
- end
241
-
242
- def build_system_prompt(context)
243
- parts = [
244
- "You are embedded inside a Ruby program. You interact with the program's live state using the provided tools.",
245
- "",
246
- "Rules:",
247
- "- Use read_var / read_attr to inspect variables and objects.",
248
- "- Use write_var to create or update variables in the Ruby scope.",
249
- "- Use write_attr to set attributes on Ruby objects.",
250
- "- Use call_func to call Ruby methods available in scope.",
251
- "- Call done when the task is complete.",
252
- "- When the user references <var>, that's a variable in scope.",
253
- "- If a referenced variable doesn't exist yet, the user expects you to create it with write_var.",
254
- "- Be precise with types: use numbers for numeric values, arrays for lists, strings for text."
255
- ]
256
-
257
- # Inject long-term memories or incognito notice
258
- if @incognito
259
- parts << ""
260
- parts << "You are in incognito mode. The remember tool is disabled. No memories will be loaded or saved."
261
- else
262
- memory = Memory.current
263
- if memory
264
- # Inject summaries from compaction
265
- unless memory.summaries.empty?
266
- parts << ""
267
- parts << "Previous conversation summary:"
268
- memory.summaries.each { |s| parts << " #{s}" }
269
- end
270
-
271
- unless memory.long_term.empty?
272
- parts << ""
273
- parts << "Long-term memories (persistent across executions):"
274
- memory.long_term.each { |m| parts << "- #{m[:content]}" }
275
- end
276
-
277
- unless memory.long_term.empty?
278
- parts << ""
279
- parts << "You have a `remember` tool to store new facts in long-term memory when the user asks."
280
- 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"
281
31
  end
282
32
  end
283
33
 
284
- unless context.empty?
285
- parts << ""
286
- parts << "Current variable values:"
287
- context.each { |k, v| parts << " #{k} = #{v}" }
288
- end
289
-
290
- # Discover available functions from caller's source
291
- methods = begin
292
- Mana::Introspect.methods_from_file(@caller_path)
293
- rescue => _e
294
- []
295
- end
296
- unless methods.empty?
297
- parts << ""
298
- parts << Mana::Introspect.format_for_prompt(methods)
34
+ # Delegate to LLM engine for backward compatibility
35
+ def handler_stack
36
+ Engines::LLM.handler_stack
299
37
  end
300
38
 
301
- # List custom effects
302
- custom_effects = Mana::EffectRegistry.tool_definitions
303
- unless custom_effects.empty?
304
- parts << ""
305
- parts << "Custom tools available:"
306
- custom_effects.each do |t|
307
- params = (t[:input_schema][:properties] || {}).keys.join(", ")
308
- parts << " #{t[:name]}(#{params}) — #{t[:description]}"
309
- end
39
+ def with_handler(handler = nil, **opts, &block)
40
+ Engines::LLM.with_handler(handler, **opts, &block)
310
41
  end
311
42
 
312
- parts.join("\n")
313
- end
314
-
315
- # --- Effect Handling ---
316
-
317
- def handle_effect(tool_use, memory = nil)
318
- name = tool_use[:name]
319
- input = tool_use[:input] || {}
320
- # Normalize keys to strings for consistent access
321
- input = input.transform_keys(&:to_s) if input.is_a?(Hash)
322
-
323
- # Check handler stack first (legacy)
324
- handler = self.class.handler_stack.last
325
- return handler.call(name, input) if handler && handler.respond_to?(:call)
326
-
327
- # Check custom effect registry
328
- handled, result = Mana::EffectRegistry.handle(name, input)
329
- return serialize_value(result) if handled
330
-
331
- case name
332
- when "read_var"
333
- serialize_value(resolve(input["name"]))
334
-
335
- when "write_var"
336
- var_name = input["name"]
337
- value = input["value"]
338
- write_local(var_name, value)
339
- "ok: #{var_name} = #{value.inspect}"
340
-
341
- when "read_attr"
342
- obj = resolve(input["obj"])
343
- validate_name!(input["attr"])
344
- serialize_value(obj.public_send(input["attr"]))
345
-
346
- when "write_attr"
347
- obj = resolve(input["obj"])
348
- validate_name!(input["attr"])
349
- obj.public_send("#{input['attr']}=", input["value"])
350
- "ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
351
-
352
- when "call_func"
353
- func = input["name"]
354
- validate_name!(func)
355
- args = input["args"] || []
356
- # Try method first, then local variable (supports lambdas/procs)
357
- callable = if @binding.receiver.respond_to?(func.to_sym, true)
358
- @binding.receiver.method(func.to_sym)
359
- elsif @binding.local_variables.include?(func.to_sym)
360
- @binding.local_variable_get(func.to_sym)
361
- else
362
- @binding.receiver.method(func.to_sym) # raise NameError
363
- end
364
- result = callable.call(*args)
365
- serialize_value(result)
366
-
367
- when "remember"
368
- if @incognito
369
- "Memory not saved (incognito mode)"
370
- elsif memory
371
- entry = memory.remember(input["content"])
372
- "Remembered (id=#{entry[:id]}): #{input['content']}"
373
- else
374
- "Memory not available"
375
- end
376
-
377
- when "done"
378
- input["result"].to_s
379
-
380
- else
381
- "error: unknown tool #{name}"
43
+ def all_tools
44
+ Engines::LLM.all_tools
382
45
  end
383
- rescue => e
384
- "error: #{e.class}: #{e.message}"
385
- end
386
-
387
- # --- Binding Helpers ---
388
-
389
- VALID_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
390
-
391
- def validate_name!(name)
392
- raise Mana::Error, "invalid identifier: #{name.inspect}" unless name.match?(VALID_IDENTIFIER)
393
46
  end
394
47
 
395
- def resolve(name)
396
- validate_name!(name)
397
- if @binding.local_variable_defined?(name.to_sym)
398
- @binding.local_variable_get(name.to_sym)
399
- elsif @binding.receiver.respond_to?(name.to_sym, true)
400
- @binding.receiver.send(name.to_sym)
401
- else
402
- raise NameError, "undefined variable or method '#{name}'"
403
- 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
404
53
  end
405
54
 
406
- def write_local(name, value)
407
- validate_name!(name)
408
- @binding.local_variable_set(name.to_sym, value)
55
+ def execute
56
+ @delegate.execute(@prompt)
409
57
  end
410
58
 
411
- def caller_source_path
412
- # Walk up the call stack to find the first non-mana source file
413
- loc = @binding.source_location
414
- return loc[0] if loc.is_a?(Array)
415
-
416
- # Fallback: search caller_locations
417
- caller_locations(4, 20)&.each do |frame|
418
- path = frame.absolute_path || frame.path
419
- next if path.nil? || path.include?("mana/")
420
- return path
421
- end
422
- nil
423
- end
59
+ private
424
60
 
425
- def serialize_value(val)
426
- case val
427
- when String, Integer, Float, TrueClass, FalseClass, NilClass
428
- val.inspect
429
- when Symbol
430
- val.to_s.inspect
431
- when Array
432
- "[#{val.map { |v| serialize_value(v) }.join(', ')}]"
433
- when Hash
434
- pairs = val.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }
435
- "{#{pairs.join(', ')}}"
61
+ def method_missing(method, *args, **kwargs, &block)
62
+ if @delegate.respond_to?(method, true)
63
+ @delegate.send(method, *args, **kwargs, &block)
436
64
  else
437
- ivars = val.instance_variables
438
- obj_repr = ivars.map do |ivar|
439
- attr_name = ivar.to_s.delete_prefix("@")
440
- "#{attr_name}: #{val.instance_variable_get(ivar).inspect}" rescue nil
441
- end.compact.join(", ")
442
- "#<#{val.class} #{obj_repr}>"
65
+ super
443
66
  end
444
67
  end
445
68
 
446
- # --- LLM Client ---
447
-
448
- def llm_call(system, messages)
449
- backend = Backends.for(@config)
450
- backend.chat(
451
- system: system,
452
- messages: messages,
453
- tools: self.class.all_tools,
454
- model: @config.model,
455
- max_tokens: 4096
456
- )
457
- end
458
-
459
- def extract_tool_uses(content)
460
- return [] unless content.is_a?(Array)
461
-
462
- content
463
- .select { |block| block[:type] == "tool_use" }
464
- .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
465
71
  end
466
72
  end
467
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