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.
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
- new(prompt, caller_binding).execute
93
- end
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
- messages << { role: "user", content: tool_results }
170
-
171
- break if tool_uses.any? { |t| t[:name] == "done" }
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
- done_result
183
- ensure
184
- if nested && !@incognito
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
- # --- Context Building ---
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
- unless memory.long_term.empty?
236
- parts << ""
237
- parts << "Long-term memories (persistent across executions):"
238
- memory.long_term.each { |m| parts << "- #{m[:content]}" }
239
- end
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
- unless context.empty?
249
- parts << ""
250
- parts << "Current variable values:"
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
- # List custom effects
266
- custom_effects = Mana::EffectRegistry.tool_definitions
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
- parts.join("\n")
277
- end
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
- def resolve(name)
360
- validate_name!(name)
361
- if @binding.local_variable_defined?(name.to_sym)
362
- @binding.local_variable_get(name.to_sym)
363
- elsif @binding.receiver.respond_to?(name.to_sym, true)
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 write_local(name, value)
371
- validate_name!(name)
372
- @binding.local_variable_set(name.to_sym, value)
55
+ def execute
56
+ @delegate.execute(@prompt)
373
57
  end
374
58
 
375
- def caller_source_path
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 serialize_value(val)
390
- case val
391
- when String, Integer, Float, TrueClass, FalseClass, NilClass
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
- ivars = val.instance_variables
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
- # --- LLM Client ---
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