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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +114 -195
- data/exe/mana +12 -0
- data/lib/mana/backends/anthropic.rb +47 -0
- data/lib/mana/backends/base.rb +41 -0
- data/lib/mana/backends/openai.rb +15 -0
- data/lib/mana/binding_helpers.rb +106 -0
- data/lib/mana/chat.rb +301 -0
- data/lib/mana/config.rb +0 -19
- data/lib/mana/engine.rb +102 -359
- data/lib/mana/knowledge.rb +203 -0
- data/lib/mana/logger.rb +10 -0
- data/lib/mana/prompt_builder.rb +157 -0
- data/lib/mana/tool_handler.rb +180 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +10 -1
- metadata +38 -4
- data/lib/mana/security_policy.rb +0 -195
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Mana
|
|
6
|
+
# Runtime knowledge base — assembles information about ruby-mana from live code.
|
|
7
|
+
# No static files to maintain; the code IS the source of truth.
|
|
8
|
+
module Knowledge
|
|
9
|
+
class << self
|
|
10
|
+
# Query a topic using a cascading lookup strategy.
|
|
11
|
+
# Priority: mana docs > ruby env > ri (external docs) > runtime introspection > dump all.
|
|
12
|
+
# The fallback chain ensures we always return something useful.
|
|
13
|
+
def query(topic)
|
|
14
|
+
topic_key = topic.to_s.strip.downcase
|
|
15
|
+
sections = all_sections
|
|
16
|
+
|
|
17
|
+
# 1. Match mana's own sections (bidirectional substring match for flexibility)
|
|
18
|
+
match = sections.find { |k, _| topic_key.include?(k) || k.include?(topic_key) }
|
|
19
|
+
return "[source: mana]\n#{match.last}" if match
|
|
20
|
+
|
|
21
|
+
# 2. Ruby environment info
|
|
22
|
+
return "[source: ruby runtime]\n#{ruby_environment}" if topic_key == "ruby"
|
|
23
|
+
|
|
24
|
+
# 3. Try ri (official Ruby documentation)
|
|
25
|
+
ri_result = query_ri(topic.to_s.strip)
|
|
26
|
+
return "[source: ri (Ruby official docs)]\n#{ri_result}" if ri_result
|
|
27
|
+
|
|
28
|
+
# 4. Try runtime introspection
|
|
29
|
+
introspect_result = query_introspect(topic.to_s.strip)
|
|
30
|
+
return "[source: ruby introspection]\n#{introspect_result}" if introspect_result
|
|
31
|
+
|
|
32
|
+
# 5. Fallback: dump all mana sections so the LLM has full context
|
|
33
|
+
"[source: mana]\n#{sections.values.join("\n\n")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Query Ruby's ri documentation tool (runs outside bundler to access rdoc).
|
|
39
|
+
# Must escape bundler env because ri needs access to system-wide rdoc files
|
|
40
|
+
# that bundler's isolated gem path would hide.
|
|
41
|
+
def query_ri(topic)
|
|
42
|
+
output = if defined?(Bundler)
|
|
43
|
+
Bundler.with_unbundled_env { `ri --format=markdown #{topic.shellescape} 2>&1` }
|
|
44
|
+
else
|
|
45
|
+
`ri --format=markdown #{topic.shellescape} 2>&1`
|
|
46
|
+
end
|
|
47
|
+
return nil unless $?.success?
|
|
48
|
+
# Truncate long docs to avoid flooding the LLM's context window
|
|
49
|
+
output.length > 3000 ? "#{output[0, 3000]}\n\n... (truncated, #{output.length} chars total)" : output
|
|
50
|
+
rescue
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Runtime introspection: resolve as a Ruby constant and inspect it.
|
|
55
|
+
# This gives the LLM live information about classes/modules that may not
|
|
56
|
+
# appear in ri docs (e.g. user-defined or gem classes loaded at runtime).
|
|
57
|
+
def query_introspect(topic)
|
|
58
|
+
const = Object.const_get(topic)
|
|
59
|
+
lines = []
|
|
60
|
+
if const.is_a?(Module)
|
|
61
|
+
lines << "#{const.name} (#{const.is_a?(Class) ? "class" : "module"})"
|
|
62
|
+
# Limit ancestors/methods to avoid overwhelming the LLM context
|
|
63
|
+
lines << "Ancestors: #{const.ancestors.first(8).map(&:name).compact.join(' < ')}" if const.is_a?(Class)
|
|
64
|
+
pub = const.public_instance_methods(false).sort
|
|
65
|
+
lines << "Instance methods (#{pub.size}): #{pub.first(30).join(', ')}#{"..." if pub.size > 30}"
|
|
66
|
+
if const.is_a?(Class)
|
|
67
|
+
class_methods = (const.methods - Object.methods).sort
|
|
68
|
+
lines << "Class methods (#{class_methods.size}): #{class_methods.first(20).join(', ')}#{"..." if class_methods.size > 20}" unless class_methods.empty?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
lines.empty? ? nil : lines.join("\n")
|
|
72
|
+
rescue NameError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Ruby runtime environment info
|
|
77
|
+
def ruby_environment
|
|
78
|
+
<<~TEXT
|
|
79
|
+
Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})
|
|
80
|
+
RUBY_ENGINE: #{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}
|
|
81
|
+
Loaded gems: #{Gem.loaded_specs.keys.sort.join(", ")}
|
|
82
|
+
TEXT
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# All knowledge sections are generated dynamically from live code/config,
|
|
86
|
+
# so they're always up-to-date without maintaining separate doc files.
|
|
87
|
+
def all_sections
|
|
88
|
+
{
|
|
89
|
+
"overview" => overview,
|
|
90
|
+
"tools" => tools,
|
|
91
|
+
"memory" => memory,
|
|
92
|
+
"execution" => execution,
|
|
93
|
+
"configuration" => configuration,
|
|
94
|
+
"backends" => backends,
|
|
95
|
+
"functions" => functions
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def overview
|
|
100
|
+
<<~TEXT
|
|
101
|
+
ruby-mana v#{Mana::VERSION} is a hybrid execution engine for Ruby.
|
|
102
|
+
The LLM handles reasoning and decision-making; Ruby handles actual code execution.
|
|
103
|
+
The operator ~"..." turns a natural-language string into an LLM prompt that can
|
|
104
|
+
read/write live Ruby variables, call Ruby methods, and return values — all within
|
|
105
|
+
the caller's binding.
|
|
106
|
+
TEXT
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def tools
|
|
110
|
+
# Extract tool info directly from Engine's tool definitions
|
|
111
|
+
tool_list = Engine.all_tools.map { |t|
|
|
112
|
+
desc = t[:description]
|
|
113
|
+
props = t[:input_schema][:properties] || {}
|
|
114
|
+
params = props.map { |k, v| "#{k}: #{v[:description] || v['description'] || k}" }.join(", ")
|
|
115
|
+
"- #{t[:name]}(#{params}): #{desc}"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
"Built-in tools:\n#{tool_list.join("\n")}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def memory
|
|
122
|
+
store_class = Mana.config.memory_store&.class&.name || "Mana::FileStore (default)"
|
|
123
|
+
path = if Mana.config.memory_path
|
|
124
|
+
Mana.config.memory_path
|
|
125
|
+
else
|
|
126
|
+
"~/.mana/memory/<namespace>.json"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
<<~TEXT
|
|
130
|
+
ruby-mana has two types of memory:
|
|
131
|
+
- Short-term memory: conversation history within the current process. Each ~"..."
|
|
132
|
+
call appends to it, so consecutive calls share context. Cleared when the process exits.
|
|
133
|
+
- Long-term memory: persistent facts stored on disk as JSON files.
|
|
134
|
+
Default path: #{path}
|
|
135
|
+
Current store: #{store_class}
|
|
136
|
+
Namespace is auto-detected from the git repo name, Gemfile directory, or cwd.
|
|
137
|
+
Configurable via: Mana.configure { |c| c.memory_path = "/custom/path" }
|
|
138
|
+
Or provide a custom MemoryStore subclass for Redis, DB, etc.
|
|
139
|
+
- Background compaction: when short-term memory exceeds the token pressure threshold
|
|
140
|
+
(currently #{Mana.config.memory_pressure}), old messages are summarized in a background thread.
|
|
141
|
+
- Incognito mode: Mana.incognito { ~"..." } disables all memory.
|
|
142
|
+
The LLM can store facts via the `remember` tool. These persist across script executions.
|
|
143
|
+
TEXT
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def execution
|
|
147
|
+
<<~TEXT
|
|
148
|
+
How ~"..." works step by step:
|
|
149
|
+
1. ~"..." triggers String#~@ — captures the caller's Binding via binding_of_caller.
|
|
150
|
+
2. Build context — parses <var> references, reads their values, discovers functions via Prism AST.
|
|
151
|
+
3. Build system prompt — assembles rules, memory, variable values, and function signatures.
|
|
152
|
+
4. LLM tool-calling loop — sends prompt to LLM with built-in tools. LLM responds with
|
|
153
|
+
tool calls, Mana executes them against the live Ruby binding, sends results back.
|
|
154
|
+
Loops until done() is called or max_iterations (#{Mana.config.max_iterations}) is reached.
|
|
155
|
+
5. Return value — single write_var returns the value directly; multiple writes return a Hash.
|
|
156
|
+
TEXT
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def configuration
|
|
160
|
+
c = Mana.config
|
|
161
|
+
<<~TEXT
|
|
162
|
+
Current configuration:
|
|
163
|
+
- model: #{c.model}
|
|
164
|
+
- backend: #{c.backend || 'auto-detect'}
|
|
165
|
+
- base_url: #{c.effective_base_url}
|
|
166
|
+
- timeout: #{c.timeout}s
|
|
167
|
+
- max_iterations: #{c.max_iterations}
|
|
168
|
+
- context_window: #{c.context_window}
|
|
169
|
+
- memory_pressure: #{c.memory_pressure}
|
|
170
|
+
- memory_keep_recent: #{c.memory_keep_recent}
|
|
171
|
+
- verbose: #{c.verbose}
|
|
172
|
+
All options can be set via Mana.configure { |c| ... } or environment variables
|
|
173
|
+
(MANA_MODEL, MANA_BACKEND, MANA_TIMEOUT, MANA_VERBOSE, ANTHROPIC_API_KEY, OPENAI_API_KEY).
|
|
174
|
+
TEXT
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def backends
|
|
178
|
+
<<~TEXT
|
|
179
|
+
ruby-mana supports multiple LLM backends:
|
|
180
|
+
- Anthropic (Claude) — default, native format
|
|
181
|
+
- OpenAI (GPT) — auto-translated
|
|
182
|
+
- Any OpenAI-compatible API (Gemini, local models, etc.) via custom base_url
|
|
183
|
+
Currently using: #{Mana.config.backend || 'auto-detect'} with model #{Mana.config.model}
|
|
184
|
+
Configure via:
|
|
185
|
+
Mana.configure { |c| c.backend = :openai; c.model = "gpt-4o" }
|
|
186
|
+
Or set environment variables: MANA_BACKEND, MANA_MODEL
|
|
187
|
+
TEXT
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def functions
|
|
191
|
+
<<~TEXT
|
|
192
|
+
Function discovery in ruby-mana:
|
|
193
|
+
- Prism AST parser auto-discovers methods from the caller's source file.
|
|
194
|
+
- YARD-style comments are extracted as descriptions.
|
|
195
|
+
- Methods on the receiver (minus Ruby builtins) are also discovered.
|
|
196
|
+
- No registration or JSON schema needed — just define normal Ruby methods.
|
|
197
|
+
- LLM-compiled methods: `mana def method_name` lets the LLM generate the implementation
|
|
198
|
+
on first call, then caches it on disk (.mana_cache/).
|
|
199
|
+
TEXT
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
data/lib/mana/logger.rb
CHANGED
|
@@ -66,6 +66,16 @@ module Mana
|
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Log think tool content — full text in distinct italic cyan
|
|
70
|
+
def vlog_think(content)
|
|
71
|
+
return unless @config.verbose
|
|
72
|
+
|
|
73
|
+
$stderr.puts "\e[2m[mana]\e[0m \e[3;36m💭 Think:\e[0m"
|
|
74
|
+
content.each_line do |line|
|
|
75
|
+
$stderr.puts "\e[2m[mana]\e[0m \e[3;36m #{line.rstrip}\e[0m"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
69
79
|
# Summarize tool input for compact logging.
|
|
70
80
|
# Multi-line string values are replaced with a brief summary.
|
|
71
81
|
def summarize_input(input)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
# Assembles system prompts and extracts variable context from user prompts.
|
|
5
|
+
# Mixed into Engine as private methods.
|
|
6
|
+
module PromptBuilder
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Extract <var> references from the prompt and read their current values.
|
|
10
|
+
# Variables that don't exist yet are silently skipped (LLM will create them).
|
|
11
|
+
def build_context(prompt)
|
|
12
|
+
var_names = prompt.scan(/<(\w+)>/).flatten.uniq
|
|
13
|
+
ctx = {}
|
|
14
|
+
var_names.each do |name|
|
|
15
|
+
val = resolve(name)
|
|
16
|
+
ctx[name] = serialize_value(val)
|
|
17
|
+
rescue NameError
|
|
18
|
+
# Variable doesn't exist yet — will be created by LLM
|
|
19
|
+
end
|
|
20
|
+
ctx
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Assemble the system prompt with rules, memory, variables, available functions, and custom effects
|
|
24
|
+
def build_system_prompt(context)
|
|
25
|
+
parts = [
|
|
26
|
+
"You are Mana, an AI assistant embedded in a Ruby program. Your name is Mana — never use any other name for yourself.",
|
|
27
|
+
"ALWAYS respond in the same language as the user's message. If the user writes in Chinese, respond in Chinese. If in English, respond in English.",
|
|
28
|
+
"You interact with live Ruby state using the provided tools. When unsure about your capabilities, use the knowledge tool to check.",
|
|
29
|
+
"",
|
|
30
|
+
"Rules:",
|
|
31
|
+
"- read_var / read_attr to read, write_var / write_attr to write.",
|
|
32
|
+
"- call_func to call ANY Ruby method — including Net::HTTP, File, system libraries, gems, etc. You have Ruby's full power. Use local_variables to discover variables in scope.",
|
|
33
|
+
"- NEVER refuse a task by saying you can't do it. Always try using call_func first. If it fails, the error will tell you why.",
|
|
34
|
+
"- done(result: ...) to return a value. error(message: ...) only after you have tried and failed.",
|
|
35
|
+
"- <var> references point to variables in scope; create with write_var if missing.",
|
|
36
|
+
"- Match types precisely: numbers for numeric values, arrays for lists, strings for text.",
|
|
37
|
+
"- Current prompt overrides conversation history and memories.",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
if @incognito
|
|
41
|
+
parts << ""
|
|
42
|
+
parts << "You are in incognito mode. The remember tool is disabled. No memories will be loaded or saved."
|
|
43
|
+
else
|
|
44
|
+
memory = Memory.current
|
|
45
|
+
# Inject memory context when available
|
|
46
|
+
if memory
|
|
47
|
+
# Add compaction summaries from prior conversations
|
|
48
|
+
unless memory.summaries.empty?
|
|
49
|
+
parts << ""
|
|
50
|
+
parts << "Previous conversation summary:"
|
|
51
|
+
memory.summaries.each { |s| parts << " #{s}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Add persistent long-term facts
|
|
55
|
+
unless memory.long_term.empty?
|
|
56
|
+
parts << ""
|
|
57
|
+
parts << "Long-term memories (persistent background context):"
|
|
58
|
+
memory.long_term.each { |m| parts << "- #{m[:content]}" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
unless memory.long_term.empty?
|
|
62
|
+
parts << ""
|
|
63
|
+
parts << "You have a `remember` tool to store new facts in long-term memory when the user asks."
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Inject current variable values referenced in the prompt
|
|
69
|
+
unless context.empty?
|
|
70
|
+
parts << ""
|
|
71
|
+
parts << "Current variable values:"
|
|
72
|
+
context.each { |k, v| parts << " #{k} = #{v}" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Discover available functions from two sources:
|
|
76
|
+
# 1. AST scan of the caller's source file (gets parameter signatures)
|
|
77
|
+
# 2. Receiver's methods minus Ruby builtins (catches require'd functions)
|
|
78
|
+
file_methods = begin
|
|
79
|
+
Mana::Introspect.methods_from_file(@caller_path)
|
|
80
|
+
rescue => _e
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
file_method_names = file_methods.map { |m| m[:name] }
|
|
84
|
+
|
|
85
|
+
# Methods on the receiver not from Object/Kernel (user-defined or require'd)
|
|
86
|
+
receiver = @binding.receiver
|
|
87
|
+
receiver_methods = (receiver.methods - Object.methods - Kernel.methods - [:~@, :mana])
|
|
88
|
+
.select { |m| receiver.method(m).owner != Object && receiver.method(m).owner != Kernel }
|
|
89
|
+
.reject { |m| file_method_names.include?(m.to_s) } # avoid duplicates with AST scan
|
|
90
|
+
.map { |m|
|
|
91
|
+
meth = receiver.method(m)
|
|
92
|
+
params = meth.parameters.map { |(type, name)|
|
|
93
|
+
case type
|
|
94
|
+
when :req then name.to_s
|
|
95
|
+
when :opt then "#{name}=..."
|
|
96
|
+
when :rest then "*#{name}"
|
|
97
|
+
when :keyreq then "#{name}:"
|
|
98
|
+
when :key then "#{name}: ..."
|
|
99
|
+
when :keyrest then "**#{name}"
|
|
100
|
+
when :block then "&#{name}"
|
|
101
|
+
else name.to_s
|
|
102
|
+
end
|
|
103
|
+
}
|
|
104
|
+
{ name: m.to_s, params: params }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
all_methods = file_methods + receiver_methods
|
|
108
|
+
# Append available function signatures so the LLM knows what it can call
|
|
109
|
+
unless all_methods.empty?
|
|
110
|
+
parts << ""
|
|
111
|
+
parts << Mana::Introspect.format_for_prompt(all_methods)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Inject Ruby runtime environment snapshot
|
|
115
|
+
parts << ""
|
|
116
|
+
parts << ruby_environment
|
|
117
|
+
|
|
118
|
+
parts.join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Build a concise snapshot of the Ruby runtime environment
|
|
122
|
+
def ruby_environment
|
|
123
|
+
lines = ["Environment:"]
|
|
124
|
+
lines << " Ruby #{RUBY_VERSION} | #{RUBY_PLATFORM} | pwd: #{Dir.pwd}"
|
|
125
|
+
|
|
126
|
+
# Loaded gems (top-level, skip bundler internals)
|
|
127
|
+
specs = Gem.loaded_specs.values
|
|
128
|
+
.reject { |s| %w[bundler rubygems].include?(s.name) }
|
|
129
|
+
.sort_by(&:name)
|
|
130
|
+
if specs.any?
|
|
131
|
+
gem_list = specs.map { |s| "#{s.name} #{s.version}" }.first(20)
|
|
132
|
+
gem_list << "... (#{specs.size} total)" if specs.size > 20
|
|
133
|
+
lines << " Gems: #{gem_list.join(', ')}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# User-defined classes/modules (skip Ruby internals)
|
|
137
|
+
skip = [Object, Kernel, BasicObject, Module, Class, Mana, Mana::Engine,
|
|
138
|
+
Mana::Memory, Mana::Config, Mana::Chat]
|
|
139
|
+
user_classes = ObjectSpace.each_object(Class)
|
|
140
|
+
.reject { |c| c.name.nil? || c.name.start_with?("Mana::") || c.name.start_with?("#<") }
|
|
141
|
+
.reject { |c| skip.include?(c) }
|
|
142
|
+
.reject { |c| c.name.match?(/\A(Net|URI|IO|Gem|Bundler|RubyVM|RbConfig|Reline|JSON|YAML|Psych|Prism|Encoding|Errno|Signal|Thread|Fiber|Ractor|Process|GC|RDoc|IRB|Readline|StringIO|Monitor|PP|DidYouMean|ErrorHighlight|SyntaxSuggest|Coverage|SimpleCov|RSpec|WebMock)/) }
|
|
143
|
+
.map(&:name).sort
|
|
144
|
+
if user_classes.any?
|
|
145
|
+
class_list = user_classes.first(20)
|
|
146
|
+
class_list << "... (#{user_classes.size} total)" if user_classes.size > 20
|
|
147
|
+
lines << " Classes: #{class_list.join(', ')}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Local variables in scope
|
|
151
|
+
vars = @binding.local_variables.map(&:to_s).sort
|
|
152
|
+
lines << " Local vars: #{vars.join(', ')}" if vars.any?
|
|
153
|
+
|
|
154
|
+
lines.join("\n")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
# Dispatches LLM tool calls to their respective handlers.
|
|
5
|
+
# Mixed into Engine as a private method.
|
|
6
|
+
module ToolHandler
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Dispatch a single tool call from the LLM.
|
|
10
|
+
def handle_effect(tool_use, memory = nil)
|
|
11
|
+
name = tool_use[:name]
|
|
12
|
+
input = tool_use[:input] || {}
|
|
13
|
+
# Normalize keys to strings for consistent access
|
|
14
|
+
input = input.transform_keys(&:to_s) if input.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
case name
|
|
17
|
+
when "read_var"
|
|
18
|
+
# Read a variable from the caller's binding and return its serialized value
|
|
19
|
+
val = serialize_value(resolve(input["name"]))
|
|
20
|
+
vlog_value(" ↩ #{input['name']} =", val)
|
|
21
|
+
val
|
|
22
|
+
|
|
23
|
+
when "write_var"
|
|
24
|
+
# Write a value to the caller's binding and track it for the return value
|
|
25
|
+
var_name = input["name"]
|
|
26
|
+
value = input["value"]
|
|
27
|
+
write_local(var_name, value)
|
|
28
|
+
@written_vars[var_name] = value
|
|
29
|
+
vlog_value(" ✅ #{var_name} =", value)
|
|
30
|
+
"ok: #{var_name} = #{value.inspect}"
|
|
31
|
+
|
|
32
|
+
when "read_attr"
|
|
33
|
+
# Read an attribute (public method) from a Ruby object in scope
|
|
34
|
+
obj = resolve(input["obj"])
|
|
35
|
+
validate_name!(input["attr"])
|
|
36
|
+
serialize_value(obj.public_send(input["attr"]))
|
|
37
|
+
|
|
38
|
+
when "write_attr"
|
|
39
|
+
# Set an attribute (public setter) on a Ruby object in scope
|
|
40
|
+
obj = resolve(input["obj"])
|
|
41
|
+
validate_name!(input["attr"])
|
|
42
|
+
obj.public_send("#{input['attr']}=", input["value"])
|
|
43
|
+
"ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
|
|
44
|
+
|
|
45
|
+
when "call_func"
|
|
46
|
+
handle_call_func(input)
|
|
47
|
+
|
|
48
|
+
when "knowledge"
|
|
49
|
+
# Look up information about ruby-mana from the knowledge base
|
|
50
|
+
self.class.knowledge(input["topic"])
|
|
51
|
+
|
|
52
|
+
when "remember"
|
|
53
|
+
# Store a fact in long-term memory (persistent across executions)
|
|
54
|
+
if @incognito
|
|
55
|
+
"Memory not saved (incognito mode)"
|
|
56
|
+
elsif memory
|
|
57
|
+
entry = memory.remember(input["content"])
|
|
58
|
+
"Remembered (id=#{entry[:id]}): #{input['content']}"
|
|
59
|
+
else
|
|
60
|
+
"Memory not available"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
when "done"
|
|
64
|
+
# Signal task completion; the result becomes the return value
|
|
65
|
+
done_val = input["result"]
|
|
66
|
+
vlog_value("🏁 Done:", done_val)
|
|
67
|
+
vlog("═" * 60)
|
|
68
|
+
input["result"].to_s
|
|
69
|
+
|
|
70
|
+
when "error"
|
|
71
|
+
# LLM signals it cannot complete the task — raise as exception
|
|
72
|
+
msg = input["message"] || "LLM reported an error"
|
|
73
|
+
vlog("❌ Error: #{msg}")
|
|
74
|
+
vlog("═" * 60)
|
|
75
|
+
raise Mana::LLMError, msg
|
|
76
|
+
|
|
77
|
+
when "eval"
|
|
78
|
+
result = @binding.eval(input["code"])
|
|
79
|
+
vlog_value(" ↩ eval →", result)
|
|
80
|
+
serialize_value(result)
|
|
81
|
+
|
|
82
|
+
else
|
|
83
|
+
"error: unknown tool #{name}"
|
|
84
|
+
end
|
|
85
|
+
rescue LLMError
|
|
86
|
+
# LLMError must propagate to the caller (e.g. from the error tool)
|
|
87
|
+
raise
|
|
88
|
+
rescue SyntaxError => e
|
|
89
|
+
# Catch syntax errors from body eval so the LLM can retry with corrected code
|
|
90
|
+
"error: #{e.class}: #{e.message}"
|
|
91
|
+
rescue => e
|
|
92
|
+
# Return errors as strings so the LLM can see and react to them
|
|
93
|
+
"error: #{e.class}: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Handle call_func tool: chained calls, block bodies, simple calls
|
|
97
|
+
def handle_call_func(input)
|
|
98
|
+
func = input["name"]
|
|
99
|
+
args = input["args"] || []
|
|
100
|
+
kwargs = (input["kwargs"] || {}).transform_keys(&:to_sym)
|
|
101
|
+
body_code = input["body"]
|
|
102
|
+
block = @binding.eval("proc { #{body_code} }") if body_code
|
|
103
|
+
|
|
104
|
+
# Handle chained calls (e.g. Time.now, Array.new, File.read)
|
|
105
|
+
if func.include?(".")
|
|
106
|
+
return handle_chained_call(func, args, block)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Handle body parameter for simple (non-chained) function calls
|
|
110
|
+
if body_code
|
|
111
|
+
validate_name!(func)
|
|
112
|
+
receiver = @binding.receiver
|
|
113
|
+
result = receiver.send(func.to_sym, *args, &block)
|
|
114
|
+
vlog(" ↩ #{func}(#{args.inspect}) with body → #{result.inspect}")
|
|
115
|
+
return serialize_value(result)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Simple (non-chained) function call
|
|
119
|
+
validate_name!(func)
|
|
120
|
+
|
|
121
|
+
# Binding-sensitive method: local_variables returns scope-dependent results
|
|
122
|
+
if func == "local_variables"
|
|
123
|
+
return handle_local_variables
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Try local variable (lambdas/procs) first, then receiver methods
|
|
127
|
+
callable = if @binding.local_variables.include?(func.to_sym)
|
|
128
|
+
@binding.local_variable_get(func.to_sym)
|
|
129
|
+
elsif @binding.receiver.respond_to?(func.to_sym, true)
|
|
130
|
+
@binding.receiver.method(func.to_sym)
|
|
131
|
+
else
|
|
132
|
+
raise NameError, "undefined function '#{func}'"
|
|
133
|
+
end
|
|
134
|
+
result = kwargs.empty? ? callable.call(*args) : callable.call(*args, **kwargs)
|
|
135
|
+
call_desc = args.map(&:inspect).concat(kwargs.map { |k, v| "#{k}: #{v.inspect}" }).join(", ")
|
|
136
|
+
vlog_value(" ↩ #{func}(#{call_desc}) →", result)
|
|
137
|
+
serialize_value(result)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Handle chained method calls like Time.now, Array.new(10) { rand }
|
|
141
|
+
def handle_chained_call(func, args, block)
|
|
142
|
+
first_dot = func.index(".")
|
|
143
|
+
receiver_name = func[0...first_dot]
|
|
144
|
+
rest = func[(first_dot + 1)..]
|
|
145
|
+
methods_chain = rest.split(".")
|
|
146
|
+
first_method = methods_chain.first
|
|
147
|
+
|
|
148
|
+
# Validate receiver is a simple constant name (e.g. "Time", "File", "Math")
|
|
149
|
+
unless receiver_name.match?(/\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/)
|
|
150
|
+
raise NameError, "'#{receiver_name}' is not a valid constant name"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
begin
|
|
154
|
+
receiver = @binding.eval(receiver_name)
|
|
155
|
+
rescue => e
|
|
156
|
+
raise NameError, "cannot resolve '#{receiver_name}': #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
result = receiver.public_send(first_method.to_sym, *args, &block)
|
|
159
|
+
|
|
160
|
+
# Chain remaining methods without args (e.g. .to_s, .strftime)
|
|
161
|
+
methods_chain[1..].each do |m|
|
|
162
|
+
result = result.public_send(m.to_sym)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
vlog_value(" ↩ #{func}(#{args.map(&:inspect).join(', ')}) →", result)
|
|
166
|
+
serialize_value(result)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Handle local_variables call with Mana-created singleton method tracking
|
|
170
|
+
def handle_local_variables
|
|
171
|
+
result = @binding.local_variables.map(&:to_s)
|
|
172
|
+
receiver = @binding.receiver
|
|
173
|
+
if receiver.instance_variable_defined?(:@__mana_vars__)
|
|
174
|
+
result = (result + receiver.instance_variable_get(:@__mana_vars__).map(&:to_s)).uniq
|
|
175
|
+
end
|
|
176
|
+
vlog(" ↩ local_variables() → #{result.size} variables")
|
|
177
|
+
serialize_value(result)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
data/lib/mana/version.rb
CHANGED
data/lib/mana.rb
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "mana/version"
|
|
4
4
|
require_relative "mana/config"
|
|
5
|
-
require_relative "mana/security_policy"
|
|
6
5
|
require_relative "mana/backends/base"
|
|
7
6
|
require_relative "mana/backends/anthropic"
|
|
8
7
|
require_relative "mana/backends/openai"
|
|
9
8
|
require_relative "mana/memory_store"
|
|
10
9
|
require_relative "mana/memory"
|
|
11
10
|
require_relative "mana/logger"
|
|
11
|
+
require_relative "mana/knowledge"
|
|
12
|
+
require_relative "mana/binding_helpers"
|
|
13
|
+
require_relative "mana/prompt_builder"
|
|
14
|
+
require_relative "mana/tool_handler"
|
|
12
15
|
require_relative "mana/engine"
|
|
13
16
|
require_relative "mana/introspect"
|
|
14
17
|
require_relative "mana/compiler"
|
|
15
18
|
require_relative "mana/string_ext"
|
|
16
19
|
require_relative "mana/mixin"
|
|
20
|
+
require_relative "mana/chat"
|
|
17
21
|
|
|
18
22
|
module Mana
|
|
19
23
|
class Error < StandardError; end
|
|
@@ -65,6 +69,11 @@ module Mana
|
|
|
65
69
|
def cache_dir=(dir)
|
|
66
70
|
Compiler.cache_dir = dir
|
|
67
71
|
end
|
|
72
|
+
|
|
73
|
+
# Enter interactive chat mode. Mana will have access to the caller's binding.
|
|
74
|
+
def chat
|
|
75
|
+
Chat.start(binding.of_caller(1))
|
|
76
|
+
end
|
|
68
77
|
end
|
|
69
78
|
end
|
|
70
79
|
|
metadata
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby-mana
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carl Li
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
11
|
date: 2026-03-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
@@ -24,33 +24,67 @@ dependencies:
|
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '1.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: reline
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.5'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.5'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: dotenv
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.0'
|
|
27
55
|
description: |
|
|
28
56
|
Mana lets you write natural language strings in Ruby that execute via LLM
|
|
29
57
|
with full access to your program's live state. Read/write variables, call
|
|
30
58
|
functions, manipulate objects — all from a simple ~"..." syntax.
|
|
31
59
|
email:
|
|
32
|
-
executables:
|
|
60
|
+
executables:
|
|
61
|
+
- mana
|
|
33
62
|
extensions: []
|
|
34
63
|
extra_rdoc_files: []
|
|
35
64
|
files:
|
|
36
65
|
- CHANGELOG.md
|
|
37
66
|
- LICENSE
|
|
38
67
|
- README.md
|
|
68
|
+
- exe/mana
|
|
39
69
|
- lib/mana.rb
|
|
40
70
|
- lib/mana/backends/anthropic.rb
|
|
41
71
|
- lib/mana/backends/base.rb
|
|
42
72
|
- lib/mana/backends/openai.rb
|
|
73
|
+
- lib/mana/binding_helpers.rb
|
|
74
|
+
- lib/mana/chat.rb
|
|
43
75
|
- lib/mana/compiler.rb
|
|
44
76
|
- lib/mana/config.rb
|
|
45
77
|
- lib/mana/engine.rb
|
|
46
78
|
- lib/mana/introspect.rb
|
|
79
|
+
- lib/mana/knowledge.rb
|
|
47
80
|
- lib/mana/logger.rb
|
|
48
81
|
- lib/mana/memory.rb
|
|
49
82
|
- lib/mana/memory_store.rb
|
|
50
83
|
- lib/mana/mixin.rb
|
|
51
84
|
- lib/mana/mock.rb
|
|
52
|
-
- lib/mana/
|
|
85
|
+
- lib/mana/prompt_builder.rb
|
|
53
86
|
- lib/mana/string_ext.rb
|
|
87
|
+
- lib/mana/tool_handler.rb
|
|
54
88
|
- lib/mana/version.rb
|
|
55
89
|
homepage: https://github.com/twokidsCarl/ruby-mana
|
|
56
90
|
licenses:
|