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