ruby-mana 0.5.8 → 0.5.10

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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ # Binding manipulation utilities: read/write variables, validate names, serialize values.
5
+ # Mixed into Engine as private methods.
6
+ module BindingHelpers
7
+ VALID_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
8
+
9
+ private
10
+
11
+ # Ensure a name is a valid Ruby identifier (prevents injection)
12
+ def validate_name!(name)
13
+ raise Mana::Error, "invalid identifier: #{name.inspect}" unless name.match?(VALID_IDENTIFIER)
14
+ end
15
+
16
+ # Resolve a name to a value: try local variable first, then receiver method
17
+ def resolve(name)
18
+ validate_name!(name)
19
+ if @binding.local_variable_defined?(name.to_sym)
20
+ # Found as a local variable in the caller's binding
21
+ @binding.local_variable_get(name.to_sym)
22
+ elsif @binding.receiver.respond_to?(name.to_sym)
23
+ # Found as a public method on the caller's self
24
+ @binding.receiver.public_send(name.to_sym)
25
+ else
26
+ raise NameError, "undefined variable or method '#{name}'"
27
+ end
28
+ end
29
+
30
+ # Write a value into the caller's binding, with Ruby 4.0+ singleton method fallback.
31
+ # Only defines a singleton method when the variable doesn't already exist as a local
32
+ # AND the receiver doesn't already have a real method with that name.
33
+ def write_local(name, value)
34
+ validate_name!(name)
35
+ sym = name.to_sym
36
+
37
+ # Check if the variable already exists before setting
38
+ existed = @binding.local_variable_defined?(sym)
39
+ @binding.local_variable_set(sym, value)
40
+
41
+ # Ruby 4.0+: local_variable_set can no longer create new locals visible
42
+ # in the caller's scope. Define a singleton method ONLY for new variables
43
+ # that don't conflict with existing methods on the receiver.
44
+ unless existed
45
+ receiver = @binding.eval("self")
46
+ # Don't overwrite real instance methods — only add if no method exists
47
+ unless receiver.class.method_defined?(sym) || receiver.class.private_method_defined?(sym)
48
+ old_verbose, $VERBOSE = $VERBOSE, nil
49
+ receiver.define_singleton_method(sym) { value }
50
+ $VERBOSE = old_verbose
51
+ # Track Mana-created singleton method variables so local_variables can include them
52
+ mana_vars = receiver.instance_variable_defined?(:@__mana_vars__) ? receiver.instance_variable_get(:@__mana_vars__) : Set.new
53
+ mana_vars << sym
54
+ receiver.instance_variable_set(:@__mana_vars__, mana_vars)
55
+ end
56
+ end
57
+ end
58
+
59
+ # Find the user's source file by walking up the call stack.
60
+ # Used for introspecting available methods in the caller's code.
61
+ def caller_source_path
62
+ # Try binding's source_location first (most direct)
63
+ loc = @binding.source_location
64
+ return loc[0] if loc.is_a?(Array)
65
+
66
+ # Fallback: scan caller_locations, skip frames inside the mana gem itself
67
+ caller_locations(4, 20)&.each do |frame|
68
+ path = frame.absolute_path || frame.path
69
+ next if path.nil? || path.include?("mana/")
70
+ return path
71
+ end
72
+ nil
73
+ end
74
+
75
+ # Serialize a Ruby value to a string representation the LLM can understand.
76
+ # Handles primitives, collections, and arbitrary objects (via ivar inspection).
77
+ def serialize_value(val)
78
+ case val
79
+ when Time
80
+ # Format Time as a human-readable timestamp string
81
+ val.strftime("%Y-%m-%d %H:%M:%S %z").inspect
82
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
83
+ # Primitives: use Ruby's built-in inspect
84
+ val.inspect
85
+ when Symbol
86
+ # Convert symbol to string for LLM readability
87
+ val.to_s.inspect
88
+ when Array
89
+ # Recursively serialize each element
90
+ "[#{val.map { |v| serialize_value(v) }.join(', ')}]"
91
+ when Hash
92
+ # Recursively serialize key-value pairs
93
+ pairs = val.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }
94
+ "{#{pairs.join(', ')}}"
95
+ else
96
+ # Arbitrary object: show class name and instance variables
97
+ ivars = val.instance_variables
98
+ obj_repr = ivars.map do |ivar|
99
+ attr_name = ivar.to_s.delete_prefix("@")
100
+ "#{attr_name}: #{val.instance_variable_get(ivar).inspect}" rescue nil
101
+ end.compact.join(", ")
102
+ "#<#{val.class} #{obj_repr}>"
103
+ end
104
+ end
105
+ end
106
+ end
data/lib/mana/chat.rb ADDED
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ # Interactive chat mode — enter with Mana.chat to talk to Mana in your Ruby runtime.
5
+ # Supports streaming output, colored prompts, and full access to the caller's binding.
6
+ # Auto-detects Ruby code vs natural language. Use '!' prefix to force Ruby execution.
7
+ module Chat
8
+ USER_PROMPT = "\e[36mmana>\e[0m " # cyan
9
+ MANA_PREFIX = "\e[33mmana>\e[0m " # yellow
10
+ RUBY_PREFIX = "\e[35m=>\e[0m " # magenta
11
+ THINK_COLOR = "\e[3;36m" # italic cyan
12
+ TOOL_COLOR = "\e[2;33m" # dim yellow
13
+ RESULT_COLOR = "\e[2;32m" # dim green
14
+ CODE_COLOR = "\e[36m" # cyan for code
15
+ BOLD = "\e[1m" # bold
16
+ ERROR_COLOR = "\e[31m" # red
17
+ DIM = "\e[2m" # dim
18
+ RESET = "\e[0m"
19
+
20
+ CONT_PROMPT = "\e[2m \e[0m"
21
+ EXIT_COMMANDS = /\A(exit|quit|bye|q)\z/i
22
+
23
+ # Entry point — starts the REPL loop with Reline for readline support.
24
+ # Reline is required lazily so chat mode doesn't penalize non-interactive usage.
25
+ HISTORY_FILE = File.join(Dir.home, ".mana_history")
26
+ HISTORY_MAX = 1000
27
+
28
+ def self.start(caller_binding)
29
+ require "reline"
30
+ load_history
31
+ puts "#{DIM}Mana chat · type 'exit' to quit#{RESET}"
32
+ puts
33
+
34
+ loop do
35
+ input = read_input
36
+ break if input.nil?
37
+ next if input.strip.empty?
38
+ break if input.strip.match?(EXIT_COMMANDS)
39
+
40
+ # Three-tier dispatch:
41
+ # "!" prefix — force Ruby eval (bypass ambiguity detection)
42
+ # Valid Ruby syntax — try Ruby first, fall back to LLM if NameError
43
+ # Everything else — send directly to the LLM
44
+ if input.start_with?("!")
45
+ eval_ruby(caller_binding, input[1..].strip)
46
+ elsif ruby_syntax?(input)
47
+ eval_ruby(caller_binding, input) { run_mana(caller_binding, input) }
48
+ else
49
+ run_mana(caller_binding, input)
50
+ end
51
+ puts
52
+ end
53
+
54
+ save_history
55
+ puts "#{DIM}bye!#{RESET}"
56
+ end
57
+
58
+ def self.load_history
59
+ return unless File.exist?(HISTORY_FILE)
60
+
61
+ File.readlines(HISTORY_FILE, chomp: true).last(HISTORY_MAX).each do |line|
62
+ Reline::HISTORY << line
63
+ end
64
+ rescue StandardError
65
+ # ignore corrupt history
66
+ end
67
+ private_class_method :load_history
68
+
69
+ def self.save_history
70
+ lines = Reline::HISTORY.to_a.last(HISTORY_MAX)
71
+ File.write(HISTORY_FILE, lines.join("\n") + "\n")
72
+ rescue StandardError
73
+ # ignore write failures
74
+ end
75
+ private_class_method :save_history
76
+
77
+ # Reads input with multi-line support — keeps prompting with continuation
78
+ # markers while the buffer contains incomplete Ruby (unclosed blocks, strings, etc.)
79
+ def self.read_input
80
+ # Second arg to readline: true = add to history, false = don't
81
+ buffer = Reline.readline(USER_PROMPT, true)
82
+ return nil if buffer.nil?
83
+
84
+ while incomplete_ruby?(buffer)
85
+ line = Reline.readline(CONT_PROMPT, false)
86
+ break if line.nil?
87
+ buffer += "\n" + line
88
+ end
89
+ buffer
90
+ end
91
+ private_class_method :read_input
92
+
93
+ # Heuristic: if RubyVM can compile it, treat as Ruby code.
94
+ # This lets users type `x = 1 + 2` without needing the "!" prefix.
95
+ def self.ruby_syntax?(input)
96
+ RubyVM::InstructionSequence.compile(input)
97
+ true
98
+ rescue SyntaxError
99
+ false
100
+ end
101
+ private_class_method :ruby_syntax?
102
+
103
+ # Distinguishes incomplete code (needs more input) from invalid code (syntax error).
104
+ # Only "unexpected end-of-input" and "unterminated" indicate the user is still typing;
105
+ # other SyntaxErrors mean the code is complete but malformed.
106
+ def self.incomplete_ruby?(code)
107
+ RubyVM::InstructionSequence.compile(code)
108
+ false
109
+ rescue SyntaxError => e
110
+ e.message.include?("unexpected end-of-input") ||
111
+ e.message.include?("unterminated")
112
+ end
113
+ private_class_method :incomplete_ruby?
114
+
115
+ # Eval Ruby in the caller's binding. On NameError/NoMethodError, yields to the
116
+ # fallback block (which sends to the LLM) — this is how ambiguous input like
117
+ # "sort this list" gets routed: Ruby rejects it, then the LLM handles it.
118
+ def self.eval_ruby(caller_binding, code)
119
+ result = caller_binding.eval(code)
120
+ puts "#{RUBY_PREFIX}#{result.inspect}"
121
+ rescue NameError, NoMethodError => e
122
+ block_given? ? yield : puts("#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}")
123
+ rescue => e
124
+ puts "#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}"
125
+ end
126
+ private_class_method :eval_ruby
127
+
128
+ # --- Mana LLM execution with streaming + markdown rendering ---
129
+
130
+ # Executes a prompt via the LLM engine with streaming output.
131
+ # Uses a line buffer to render complete lines with markdown formatting
132
+ # while the LLM streams tokens incrementally.
133
+ def self.run_mana(caller_binding, input)
134
+ streaming_text = false
135
+ in_code_block = false
136
+ line_buffer = +"" # mutable string — accumulates partial lines until \n
137
+ engine = Engine.new(caller_binding)
138
+
139
+ begin
140
+ result = engine.execute(input) do |type, *args|
141
+ case type
142
+ when :text
143
+ unless streaming_text
144
+ print MANA_PREFIX
145
+ streaming_text = true
146
+ end
147
+
148
+ # Buffer text and flush complete lines with markdown rendering
149
+ line_buffer << args[0].to_s
150
+ while (idx = line_buffer.index("\n"))
151
+ line = line_buffer.slice!(0, idx + 1)
152
+ in_code_block = render_line(line.chomp, in_code_block)
153
+ puts
154
+ end
155
+
156
+ when :tool_start
157
+ flush_line_buffer(line_buffer, in_code_block) if streaming_text
158
+ streaming_text = false
159
+ in_code_block = false
160
+ line_buffer.clear
161
+ name, input_data = args
162
+ detail = format_tool_call(name, input_data)
163
+ puts "#{TOOL_COLOR} ⚡ #{detail}#{RESET}"
164
+
165
+ when :tool_end
166
+ name, result_str = args
167
+ summary = truncate(result_str.to_s, 120)
168
+ puts "#{RESULT_COLOR} ↩ #{summary}#{RESET}" unless summary.start_with?("ok:")
169
+ end
170
+ end
171
+
172
+ # Flush any remaining buffered text
173
+ flush_line_buffer(line_buffer, in_code_block) if streaming_text
174
+
175
+ # Non-streaming fallback: if no text was streamed (e.g. tool-only response),
176
+ # render the final result as a single block
177
+ unless streaming_text
178
+ display = case result
179
+ when Hash then result.inspect
180
+ when nil then nil
181
+ when String then render_markdown(result)
182
+ else result.inspect
183
+ end
184
+ puts "#{MANA_PREFIX}#{display}" if display
185
+ end
186
+ rescue LLMError, MaxIterationsError => e
187
+ flush_line_buffer(line_buffer, in_code_block) if streaming_text
188
+ puts "#{ERROR_COLOR}error: #{e.message}#{RESET}"
189
+ end
190
+ end
191
+ private_class_method :run_mana
192
+
193
+ # --- Markdown → ANSI rendering ---
194
+
195
+ # Render a single line, handling code block state.
196
+ # Returns the new in_code_block state.
197
+ def self.render_line(line, in_code_block)
198
+ if line.strip.start_with?("```")
199
+ if in_code_block
200
+ # End of code block — don't print the closing ```
201
+ return false
202
+ else
203
+ # Start of code block — don't print the opening ```
204
+ return true
205
+ end
206
+ end
207
+
208
+ if in_code_block
209
+ print " #{CODE_COLOR}#{line}#{RESET}"
210
+ else
211
+ print render_markdown_inline(line)
212
+ end
213
+ in_code_block
214
+ end
215
+ private_class_method :render_line
216
+
217
+ # Flush remaining text in the line buffer
218
+ def self.flush_line_buffer(buffer, in_code_block)
219
+ return if buffer.empty?
220
+ text = buffer.dup
221
+ buffer.clear
222
+ if in_code_block
223
+ print " #{CODE_COLOR}#{text}#{RESET}"
224
+ else
225
+ print render_markdown_inline(text)
226
+ end
227
+ puts
228
+ end
229
+ private_class_method :flush_line_buffer
230
+
231
+ # Convert inline markdown to ANSI codes.
232
+ # Handles **bold**, `inline code` (with negative lookbehind to skip ```),
233
+ # and # headings. Intentionally minimal — just enough for readable terminal output.
234
+ def self.render_markdown_inline(text)
235
+ text
236
+ .gsub(/\*\*(.+?)\*\*/, "#{BOLD}\\1#{RESET}")
237
+ .gsub(/(?<!`)`([^`]+)`(?!`)/, "#{CODE_COLOR}\\1#{RESET}")
238
+ .gsub(/^\#{1,3}\s+(.+)/) { BOLD + $1 + RESET }
239
+ end
240
+ private_class_method :render_markdown_inline
241
+
242
+ # Render a complete block of markdown text (for non-streaming results)
243
+ def self.render_markdown(text)
244
+ lines = text.lines
245
+ result = +""
246
+ in_code = false
247
+ lines.each do |line|
248
+ stripped = line.strip
249
+ if stripped.start_with?("```")
250
+ in_code = !in_code
251
+ next
252
+ end
253
+ if in_code
254
+ result << " #{CODE_COLOR}#{line.rstrip}#{RESET}\n"
255
+ else
256
+ result << render_markdown_inline(line.rstrip) << "\n"
257
+ end
258
+ end
259
+ result.chomp
260
+ end
261
+ private_class_method :render_markdown
262
+
263
+ # --- Tool formatting helpers ---
264
+
265
+ def self.format_tool_call(name, input)
266
+ case name
267
+ when "call_func"
268
+ func = input[:name] || input["name"]
269
+ args = input[:args] || input["args"] || []
270
+ body = input[:body] || input["body"]
271
+ desc = func.to_s
272
+ desc += "(#{args.map(&:inspect).join(', ')})" if args.any?
273
+ desc += " { #{truncate(body, 40)} }" if body
274
+ desc
275
+ # Display read_var as just the name, write_var as an assignment
276
+ when "read_var", "write_var"
277
+ var = input[:name] || input["name"]
278
+ val = input[:value] || input["value"]
279
+ val ? "#{var} = #{truncate(val.inspect, 60)}" : var.to_s
280
+ when "read_attr", "write_attr"
281
+ obj = input[:obj] || input["obj"]
282
+ attr = input[:attr] || input["attr"]
283
+ "#{obj}.#{attr}"
284
+ when "remember"
285
+ content = input[:content] || input["content"]
286
+ "remember: #{truncate(content.to_s, 60)}"
287
+ when "knowledge"
288
+ topic = input[:topic] || input["topic"]
289
+ "knowledge(#{topic})"
290
+ else
291
+ name.to_s
292
+ end
293
+ end
294
+ private_class_method :format_tool_call
295
+
296
+ def self.truncate(str, max)
297
+ str.length > max ? "#{str[0, max]}..." : str
298
+ end
299
+ private_class_method :truncate
300
+ end
301
+ end
data/lib/mana/config.rb CHANGED
@@ -34,8 +34,6 @@ module Mana
34
34
  self.timeout = (ENV["MANA_TIMEOUT"] || 120).to_i
35
35
  @verbose = %w[1 true yes].include?(ENV["MANA_VERBOSE"]&.downcase)
36
36
  @backend = ENV["MANA_BACKEND"]&.to_sym
37
- sec = ENV["MANA_SECURITY"]
38
- @security = SecurityPolicy.new(sec ? sec.to_sym : :standard)
39
37
  @namespace = nil
40
38
  @memory_store = nil
41
39
  @memory_path = nil
@@ -55,23 +53,6 @@ module Mana
55
53
  @timeout = value
56
54
  end
57
55
 
58
- # Read the current security policy
59
- def security
60
- @security
61
- end
62
-
63
- # Accept Symbol (:strict), Integer (1), or SecurityPolicy instance
64
- def security=(value)
65
- case value
66
- when SecurityPolicy
67
- @security = value
68
- when Symbol, Integer
69
- @security = SecurityPolicy.new(value)
70
- else
71
- raise ArgumentError, "security must be a Symbol, Integer, or SecurityPolicy, got #{value.class}"
72
- end
73
- end
74
-
75
56
  # Resolve the effective base URL based on the configured or auto-detected backend.
76
57
  # Falls back to the appropriate default when no explicit URL is set.
77
58
  def effective_base_url