ruby-mana 0.5.1 → 0.5.7
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 +70 -17
- data/LICENSE +1 -1
- data/README.md +189 -166
- data/lib/mana/backends/anthropic.rb +9 -27
- data/lib/mana/backends/base.rb +51 -4
- data/lib/mana/backends/openai.rb +17 -42
- data/lib/mana/compiler.rb +162 -46
- data/lib/mana/config.rb +94 -6
- data/lib/mana/engine.rb +628 -38
- data/lib/mana/introspect.rb +58 -19
- data/lib/mana/logger.rb +99 -0
- data/lib/mana/memory.rb +132 -39
- data/lib/mana/memory_store.rb +18 -8
- data/lib/mana/mixin.rb +2 -2
- data/lib/mana/mock.rb +40 -0
- data/lib/mana/security_policy.rb +195 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +7 -30
- metadata +12 -38
- data/data/lang-rules.yml +0 -196
- data/lib/mana/backends/registry.rb +0 -23
- data/lib/mana/context_window.rb +0 -28
- data/lib/mana/effect_registry.rb +0 -155
- data/lib/mana/engines/base.rb +0 -79
- data/lib/mana/engines/detect.rb +0 -93
- data/lib/mana/engines/javascript.rb +0 -314
- data/lib/mana/engines/llm.rb +0 -467
- data/lib/mana/engines/python.rb +0 -314
- data/lib/mana/engines/ruby_eval.rb +0 -11
- data/lib/mana/namespace.rb +0 -39
- data/lib/mana/object_registry.rb +0 -89
- data/lib/mana/remote_ref.rb +0 -85
- data/lib/mana/test.rb +0 -18
data/lib/mana/backends/base.rb
CHANGED
|
@@ -1,17 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
3
7
|
module Mana
|
|
4
8
|
module Backends
|
|
9
|
+
# Base class for LLM backends. Provides shared HTTP infrastructure
|
|
10
|
+
# and the factory method for backend resolution.
|
|
5
11
|
class Base
|
|
12
|
+
# Model name pattern for auto-detecting Anthropic backend
|
|
13
|
+
ANTHROPIC_PATTERN = /^claude-/i
|
|
14
|
+
|
|
6
15
|
def initialize(config)
|
|
7
16
|
@config = config
|
|
8
17
|
end
|
|
9
18
|
|
|
10
|
-
# Send a chat request
|
|
11
|
-
# (normalized). Each backend converts its native response to this format.
|
|
12
|
-
# Returns: [{ type: "text", text: "..." }, { type: "tool_use", id: "...", name: "...", input: {...} }]
|
|
19
|
+
# Send a chat request. Subclasses must implement this.
|
|
13
20
|
def chat(system:, messages:, tools:, model:, max_tokens: 4096)
|
|
14
|
-
raise NotImplementedError
|
|
21
|
+
raise NotImplementedError, "#{self.class}#chat not implemented"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# --- Factory method ---
|
|
25
|
+
|
|
26
|
+
# Resolve a backend instance from configuration.
|
|
27
|
+
# Priority: pre-built instance > explicit name > auto-detect from model name.
|
|
28
|
+
def self.for(config)
|
|
29
|
+
return config.backend if config.backend.is_a?(Base)
|
|
30
|
+
|
|
31
|
+
config.validate!
|
|
32
|
+
|
|
33
|
+
case config.backend&.to_s
|
|
34
|
+
when "openai" then OpenAI.new(config)
|
|
35
|
+
when "anthropic" then Anthropic.new(config)
|
|
36
|
+
else
|
|
37
|
+
config.model.match?(ANTHROPIC_PATTERN) ? Anthropic.new(config) : OpenAI.new(config)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Shared HTTP POST with error handling and timeout support.
|
|
44
|
+
# Returns parsed JSON response body.
|
|
45
|
+
def http_post(uri, body, headers = {})
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = uri.scheme == "https"
|
|
48
|
+
http.open_timeout = @config.timeout
|
|
49
|
+
http.read_timeout = @config.timeout
|
|
50
|
+
|
|
51
|
+
req = Net::HTTP::Post.new(uri)
|
|
52
|
+
req["Content-Type"] = "application/json"
|
|
53
|
+
headers.each { |k, v| req[k] = v }
|
|
54
|
+
req.body = JSON.generate(body)
|
|
55
|
+
|
|
56
|
+
res = http.request(req)
|
|
57
|
+
raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
58
|
+
|
|
59
|
+
JSON.parse(res.body, symbolize_names: true)
|
|
60
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
61
|
+
raise LLMError, "Request timed out: #{e.message}"
|
|
15
62
|
end
|
|
16
63
|
end
|
|
17
64
|
end
|
data/lib/mana/backends/openai.rb
CHANGED
|
@@ -1,40 +1,30 @@
|
|
|
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
|
module Backends
|
|
5
|
+
# OpenAI-compatible backend (GPT, Groq, DeepSeek, Ollama, etc.)
|
|
6
|
+
#
|
|
7
|
+
# Translates between Mana's internal format (Anthropic-style) and OpenAI's:
|
|
8
|
+
# - system prompt: top-level `system` → system message
|
|
9
|
+
# - tool calls: `tool_use`/`tool_result` blocks → `tool_calls` + `role: "tool"`
|
|
10
|
+
# - response: `choices` → content blocks
|
|
9
11
|
class OpenAI < Base
|
|
10
12
|
def chat(system:, messages:, tools:, model:, max_tokens: 4096)
|
|
11
|
-
uri = URI("#{@config.
|
|
12
|
-
|
|
13
|
+
uri = URI("#{@config.effective_base_url}/v1/chat/completions")
|
|
14
|
+
parsed = http_post(uri, {
|
|
13
15
|
model: model,
|
|
14
16
|
max_completion_tokens: max_tokens,
|
|
15
17
|
messages: convert_messages(system, messages),
|
|
16
18
|
tools: convert_tools(tools)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
http.use_ssl = uri.scheme == "https"
|
|
21
|
-
http.read_timeout = 120
|
|
22
|
-
|
|
23
|
-
req = Net::HTTP::Post.new(uri)
|
|
24
|
-
req["Content-Type"] = "application/json"
|
|
25
|
-
req["Authorization"] = "Bearer #{@config.api_key}"
|
|
26
|
-
req.body = JSON.generate(body)
|
|
27
|
-
|
|
28
|
-
res = http.request(req)
|
|
29
|
-
raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
30
|
-
|
|
31
|
-
parsed = JSON.parse(res.body, symbolize_names: true)
|
|
19
|
+
}, {
|
|
20
|
+
"Authorization" => "Bearer #{@config.api_key}"
|
|
21
|
+
})
|
|
32
22
|
normalize_response(parsed)
|
|
33
23
|
end
|
|
34
24
|
|
|
35
25
|
private
|
|
36
26
|
|
|
37
|
-
# Convert Anthropic-style messages to OpenAI format
|
|
27
|
+
# Convert Anthropic-style messages to OpenAI format.
|
|
38
28
|
def convert_messages(system, messages)
|
|
39
29
|
result = [{ role: "system", content: system }]
|
|
40
30
|
|
|
@@ -42,11 +32,7 @@ module Mana
|
|
|
42
32
|
case msg[:role]
|
|
43
33
|
when "user"
|
|
44
34
|
converted = convert_user_message(msg)
|
|
45
|
-
|
|
46
|
-
result.concat(converted)
|
|
47
|
-
else
|
|
48
|
-
result << converted
|
|
49
|
-
end
|
|
35
|
+
converted.is_a?(Array) ? result.concat(converted) : result << converted
|
|
50
36
|
when "assistant"
|
|
51
37
|
result << convert_assistant_message(msg)
|
|
52
38
|
end
|
|
@@ -58,12 +44,9 @@ module Mana
|
|
|
58
44
|
def convert_user_message(msg)
|
|
59
45
|
content = msg[:content]
|
|
60
46
|
|
|
61
|
-
# Plain text user message
|
|
62
47
|
return { role: "user", content: content } if content.is_a?(String)
|
|
63
48
|
|
|
64
|
-
|
|
65
|
-
if content.is_a?(Array) && content.all? { |b| b[:type] == "tool_result" || b["type"] == "tool_result" }
|
|
66
|
-
# Convert each tool_result to an OpenAI tool message
|
|
49
|
+
if content.is_a?(Array) && content.all? { |b| (b[:type] || b["type"]) == "tool_result" }
|
|
67
50
|
return content.map do |block|
|
|
68
51
|
{
|
|
69
52
|
role: "tool",
|
|
@@ -73,7 +56,6 @@ module Mana
|
|
|
73
56
|
end
|
|
74
57
|
end
|
|
75
58
|
|
|
76
|
-
# Other array content (e.g. text blocks) — join as string
|
|
77
59
|
if content.is_a?(Array)
|
|
78
60
|
texts = content.map { |b| b[:text] || b["text"] }.compact
|
|
79
61
|
return { role: "user", content: texts.join("\n") }
|
|
@@ -85,12 +67,8 @@ module Mana
|
|
|
85
67
|
def convert_assistant_message(msg)
|
|
86
68
|
content = msg[:content]
|
|
87
69
|
|
|
88
|
-
|
|
89
|
-
if content.is_a?(String)
|
|
90
|
-
return { role: "assistant", content: content }
|
|
91
|
-
end
|
|
70
|
+
return { role: "assistant", content: content } if content.is_a?(String)
|
|
92
71
|
|
|
93
|
-
# Array of content blocks — may contain tool_use
|
|
94
72
|
if content.is_a?(Array)
|
|
95
73
|
text_parts = []
|
|
96
74
|
tool_calls = []
|
|
@@ -121,7 +99,6 @@ module Mana
|
|
|
121
99
|
{ role: "assistant", content: content.to_s }
|
|
122
100
|
end
|
|
123
101
|
|
|
124
|
-
# Convert Anthropic tool definitions to OpenAI function calling format
|
|
125
102
|
def convert_tools(tools)
|
|
126
103
|
tools.map do |tool|
|
|
127
104
|
{
|
|
@@ -129,25 +106,23 @@ module Mana
|
|
|
129
106
|
function: {
|
|
130
107
|
name: tool[:name],
|
|
131
108
|
description: tool[:description] || "",
|
|
132
|
-
parameters: tool[:input_schema] || {}
|
|
109
|
+
parameters: (tool[:input_schema] || {}).reject { |k, _| k.to_s == "$schema" }
|
|
133
110
|
}
|
|
134
111
|
}
|
|
135
112
|
end
|
|
136
113
|
end
|
|
137
114
|
|
|
138
|
-
# Convert OpenAI response
|
|
115
|
+
# Convert OpenAI response to Anthropic-style content blocks.
|
|
139
116
|
def normalize_response(parsed)
|
|
140
117
|
choice = parsed.dig(:choices, 0, :message)
|
|
141
118
|
return [] unless choice
|
|
142
119
|
|
|
143
120
|
blocks = []
|
|
144
121
|
|
|
145
|
-
# Text content
|
|
146
122
|
if choice[:content] && !choice[:content].empty?
|
|
147
123
|
blocks << { type: "text", text: choice[:content] }
|
|
148
124
|
end
|
|
149
125
|
|
|
150
|
-
# Tool calls
|
|
151
126
|
if choice[:tool_calls]
|
|
152
127
|
choice[:tool_calls].each do |tc|
|
|
153
128
|
func = tc[:function]
|
data/lib/mana/compiler.rb
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "digest"
|
|
4
5
|
|
|
5
6
|
module Mana
|
|
6
7
|
# Compiler for `mana def` — LLM generates method implementations on first call,
|
|
7
8
|
# caches them as real .rb files, and replaces the method with native Ruby.
|
|
8
9
|
#
|
|
9
10
|
# Usage:
|
|
10
|
-
# mana def
|
|
11
|
-
# ~"return an array of
|
|
11
|
+
# mana def fibonacci(n)
|
|
12
|
+
# ~"return an array of the first n Fibonacci numbers"
|
|
12
13
|
# end
|
|
13
14
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
15
|
+
# fibonacci(10) # first call → LLM generates code → cached → executed
|
|
16
|
+
# fibonacci(20) # subsequent calls → pure Ruby, zero API overhead
|
|
16
17
|
#
|
|
17
|
-
# Mana.source(:
|
|
18
|
+
# Mana.source(:fibonacci) # view generated source
|
|
18
19
|
module Compiler
|
|
19
20
|
class << self
|
|
20
21
|
# Registry of compiled method sources: { "ClassName#method" => source_code }
|
|
@@ -35,55 +36,135 @@ module Mana
|
|
|
35
36
|
registry[key]
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
# Compile a method: wrap it so first invocation triggers LLM code generation
|
|
39
|
+
# Compile a method: wrap it so first invocation triggers LLM code generation.
|
|
40
|
+
# On subsequent calls, the generated Ruby code is loaded from cache (zero API cost).
|
|
39
41
|
def compile(owner, method_name)
|
|
40
42
|
original = owner.instance_method(method_name)
|
|
41
43
|
compiler = self
|
|
42
44
|
key = registry_key(method_name, owner)
|
|
43
45
|
|
|
44
|
-
#
|
|
46
|
+
# Detect method visibility before we replace it
|
|
47
|
+
visibility = if owner.private_method_defined?(method_name)
|
|
48
|
+
:private
|
|
49
|
+
elsif owner.protected_method_defined?(method_name)
|
|
50
|
+
:protected
|
|
51
|
+
else
|
|
52
|
+
:public
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read the prompt from the original method body (the ~"..." string)
|
|
45
56
|
prompt = extract_prompt(original)
|
|
46
57
|
|
|
47
58
|
# Build parameter signature for the generated method
|
|
48
59
|
params_desc = describe_params(original)
|
|
49
60
|
|
|
61
|
+
# Cache filename based on source file + method name
|
|
62
|
+
source_file = original.source_location&.first
|
|
63
|
+
# Include gem version, Ruby version, and sibling function signatures so cache
|
|
64
|
+
# auto-invalidates when the gem upgrades, Ruby upgrades, or dependency functions change.
|
|
65
|
+
sibling_methods = begin
|
|
66
|
+
Mana::Introspect.methods_from_file(source_file)
|
|
67
|
+
.reject { |m| m[:name] == method_name.to_s }
|
|
68
|
+
.map { |m| "#{m[:name]}(#{m[:params].join(',')})" }
|
|
69
|
+
.sort.join(";")
|
|
70
|
+
rescue
|
|
71
|
+
""
|
|
72
|
+
end
|
|
73
|
+
prompt_hash = Digest::SHA256.hexdigest("#{Mana::VERSION}:#{RUBY_VERSION}:#{method_name}:#{params_desc}:#{prompt}:#{sibling_methods}")[0, 16]
|
|
74
|
+
cache_path = cache_file_path(method_name, owner, source_file: source_file)
|
|
75
|
+
|
|
76
|
+
# Load from cache if file exists and prompt hash matches
|
|
77
|
+
if File.exist?(cache_path)
|
|
78
|
+
first_line = File.open(cache_path, &:readline) rescue ""
|
|
79
|
+
if first_line.include?(prompt_hash)
|
|
80
|
+
cached = File.read(cache_path)
|
|
81
|
+
generated = cached.lines.reject { |l| l.start_with?("#") }.join.strip
|
|
82
|
+
compiler.registry[key] = generated
|
|
83
|
+
v, $VERBOSE = $VERBOSE, nil
|
|
84
|
+
owner.class_eval(generated, cache_path, 1)
|
|
85
|
+
owner.send(visibility, method_name) unless visibility == :public
|
|
86
|
+
$VERBOSE = v
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
# Prompt changed — cache is stale, will regenerate on first call
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Replace the method with a lazy wrapper that generates code on first call
|
|
93
|
+
old_verbose, $VERBOSE = $VERBOSE, nil
|
|
94
|
+
p_hash = prompt_hash # capture for closure
|
|
95
|
+
p_text = prompt # capture for closure
|
|
96
|
+
src_file = source_file # capture for closure
|
|
50
97
|
owner.define_method(method_name) do |*args, **kwargs, &blk|
|
|
51
98
|
# Generate implementation via LLM
|
|
52
99
|
generated = compiler.generate(method_name, params_desc, prompt)
|
|
53
100
|
|
|
54
|
-
# Write to cache file
|
|
55
|
-
cache_path = compiler.write_cache(method_name, generated, owner)
|
|
101
|
+
# Write to cache file for future runs
|
|
102
|
+
cache_path = compiler.write_cache(method_name, generated, owner, prompt_hash: p_hash, prompt: p_text, source_file: src_file)
|
|
56
103
|
|
|
57
|
-
# Store in registry
|
|
104
|
+
# Store in registry so Mana.source() can retrieve it
|
|
58
105
|
compiler.registry[key] = generated
|
|
59
106
|
|
|
60
107
|
# Define the method on the correct owner (not Object) via class_eval
|
|
61
108
|
target_owner = owner
|
|
109
|
+
v, $VERBOSE = $VERBOSE, nil
|
|
62
110
|
target_owner.class_eval(generated, cache_path, 1)
|
|
111
|
+
target_owner.send(visibility, method_name) unless visibility == :public
|
|
112
|
+
$VERBOSE = v
|
|
63
113
|
|
|
64
|
-
# Call the now-native method
|
|
114
|
+
# Call the now-native method (this wrapper never runs again)
|
|
65
115
|
send(method_name, *args, **kwargs, &blk)
|
|
66
116
|
end
|
|
117
|
+
# Restore original visibility on the wrapper method
|
|
118
|
+
owner.send(visibility, method_name) unless visibility == :public
|
|
119
|
+
$VERBOSE = old_verbose
|
|
67
120
|
end
|
|
68
121
|
|
|
69
|
-
# Generate Ruby method source via LLM
|
|
122
|
+
# Generate Ruby method source via LLM.
|
|
123
|
+
# Uses an isolated binding so LLM cannot see Compiler internals.
|
|
70
124
|
def generate(method_name, params_desc, prompt)
|
|
71
|
-
code = nil # pre-declare so binding captures it
|
|
72
|
-
b = binding
|
|
73
125
|
engine_prompt = "Write a Ruby method definition `def #{method_name}(#{params_desc})` that: #{prompt}. " \
|
|
74
126
|
"Return ONLY the complete method definition (def...end), no explanation. " \
|
|
75
127
|
"Store the code as a string in <code>"
|
|
76
128
|
|
|
77
|
-
|
|
129
|
+
# Create isolated binding with only `code` variable visible.
|
|
130
|
+
# Use eval to avoid "assigned but unused variable" parse-time warning.
|
|
131
|
+
isolated = Object.new.instance_eval { eval("code = nil; binding") }
|
|
132
|
+
Mana::Engine.new(isolated).execute(engine_prompt)
|
|
133
|
+
|
|
134
|
+
code = isolated.local_variable_get(:code)
|
|
135
|
+
# LLM may return literal \n instead of real newlines — unescape them
|
|
136
|
+
code = code.gsub("\\n", "\n").gsub("\\\"", "\"").gsub("\\'", "'") if code.is_a?(String)
|
|
78
137
|
code
|
|
79
138
|
end
|
|
80
139
|
|
|
140
|
+
# Path to the cache file for a method.
|
|
141
|
+
# Includes source file path for uniqueness: lib_foo_calculate.rb
|
|
142
|
+
# Build the cache file path for a method.
|
|
143
|
+
# Prefers source-file-based naming for uniqueness; falls back to owner class name.
|
|
144
|
+
def cache_file_path(method_name, owner = nil, source_file: nil)
|
|
145
|
+
parts = []
|
|
146
|
+
if source_file
|
|
147
|
+
# Convert path relative to pwd: lib/foo.rb -> lib_foo
|
|
148
|
+
rel = source_file.sub("#{Dir.pwd}/", "").sub(/\.rb$/, "")
|
|
149
|
+
parts << rel.tr("/", "_")
|
|
150
|
+
elsif owner && owner != Object
|
|
151
|
+
# Use underscored class name when source file is unavailable
|
|
152
|
+
parts << underscore(owner.name)
|
|
153
|
+
end
|
|
154
|
+
parts << method_name.to_s
|
|
155
|
+
File.join(cache_dir, "#{parts.join('_')}.rb")
|
|
156
|
+
end
|
|
157
|
+
|
|
81
158
|
# Write generated code to a cache file, return the path
|
|
82
|
-
def write_cache(method_name, source, owner = nil)
|
|
159
|
+
def write_cache(method_name, source, owner = nil, prompt_hash: nil, prompt: nil, source_file: nil)
|
|
83
160
|
FileUtils.mkdir_p(cache_dir)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
161
|
+
path = cache_file_path(method_name, owner, source_file: source_file)
|
|
162
|
+
header = "# Auto-generated by ruby-mana v#{Mana::VERSION} | ruby #{RUBY_VERSION} | prompt_hash: #{prompt_hash}\n"
|
|
163
|
+
if prompt
|
|
164
|
+
prompt.to_s.each_line { |line| header += "# prompt: #{line.rstrip}\n" }
|
|
165
|
+
end
|
|
166
|
+
header += "# frozen_string_literal: true\n\n"
|
|
167
|
+
File.write(path, "#{header}#{source}\n")
|
|
87
168
|
path
|
|
88
169
|
end
|
|
89
170
|
|
|
@@ -104,45 +185,80 @@ module Mana
|
|
|
104
185
|
end
|
|
105
186
|
|
|
106
187
|
# Extract the prompt string from the original method.
|
|
107
|
-
#
|
|
188
|
+
# Strategy 1: Parse source file with Prism AST (handles multi-line, heredoc, escapes)
|
|
189
|
+
# Strategy 2: Extract from instruction sequence (fallback for IRB/eval)
|
|
108
190
|
def extract_prompt(unbound_method)
|
|
109
191
|
source_loc = unbound_method.source_location
|
|
110
|
-
return
|
|
192
|
+
return extract_prompt_from_iseq(unbound_method) unless source_loc
|
|
111
193
|
|
|
112
194
|
file, line = source_loc
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
195
|
+
return extract_prompt_from_iseq(unbound_method) unless file && File.exist?(file)
|
|
196
|
+
|
|
197
|
+
# Parse with Prism AST — finds ~@ call on a string node
|
|
198
|
+
source = File.read(file)
|
|
199
|
+
result = Prism.parse(source)
|
|
200
|
+
|
|
201
|
+
# Walk AST to find the DefNode at the right line, then find ~@ inside it
|
|
202
|
+
prompt = nil
|
|
203
|
+
queue = [result.value]
|
|
204
|
+
while (node = queue.shift)
|
|
205
|
+
next unless node.respond_to?(:compact_child_nodes)
|
|
206
|
+
|
|
207
|
+
if node.is_a?(Prism::DefNode) && node.location.start_line == line
|
|
208
|
+
# Found our method — now find the ~"..." call inside
|
|
209
|
+
inner_queue = node.compact_child_nodes.dup
|
|
210
|
+
while (inner = inner_queue.shift)
|
|
211
|
+
next unless inner.respond_to?(:compact_child_nodes)
|
|
212
|
+
if inner.is_a?(Prism::CallNode) && inner.name == :~@ && inner.receiver.is_a?(Prism::StringNode)
|
|
213
|
+
prompt = inner.receiver.unescaped
|
|
214
|
+
break
|
|
215
|
+
end
|
|
216
|
+
inner_queue.concat(inner.compact_child_nodes)
|
|
217
|
+
end
|
|
218
|
+
break
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
queue.concat(node.compact_child_nodes)
|
|
125
222
|
end
|
|
126
223
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
224
|
+
prompt || extract_prompt_from_iseq(unbound_method)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Fallback: extract prompt from method instruction sequence (works in IRB/eval).
|
|
228
|
+
# Uses iseq.to_a to find the string literal directly — more reliable than disasm
|
|
229
|
+
# because it preserves real newlines (disasm escapes them as \\n).
|
|
230
|
+
def extract_prompt_from_iseq(unbound_method)
|
|
231
|
+
iseq = RubyVM::InstructionSequence.of(unbound_method)
|
|
232
|
+
return nil unless iseq
|
|
233
|
+
|
|
234
|
+
# Walk the flattened instruction array to find putstring/putchilledstring
|
|
235
|
+
flat = iseq.to_a.flatten
|
|
236
|
+
flat.each_with_index do |item, i|
|
|
237
|
+
if (item == :putchilledstring || item == :putstring) && flat[i + 1].is_a?(String)
|
|
238
|
+
return flat[i + 1]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
nil
|
|
242
|
+
rescue
|
|
243
|
+
nil
|
|
131
244
|
end
|
|
132
245
|
|
|
246
|
+
# Build a human-readable parameter signature string from method parameters.
|
|
247
|
+
# Maps each parameter type to its Ruby syntax representation.
|
|
133
248
|
def describe_params(unbound_method)
|
|
134
249
|
unbound_method.parameters.map do |(type, name)|
|
|
135
250
|
case type
|
|
136
|
-
when :req then name.to_s
|
|
137
|
-
when :opt then "#{name}=nil"
|
|
138
|
-
when :rest then "*#{name}"
|
|
139
|
-
when :keyreq then "#{name}:"
|
|
140
|
-
when :key then "#{name}: nil"
|
|
141
|
-
when :keyrest then "**#{name}"
|
|
142
|
-
when :block then "&#{name}"
|
|
143
|
-
|
|
251
|
+
when :req then name.to_s # required positional
|
|
252
|
+
when :opt then "#{name}=nil" # optional positional
|
|
253
|
+
when :rest then "*#{name}" # splat
|
|
254
|
+
when :keyreq then "#{name}:" # required keyword
|
|
255
|
+
when :key then "#{name}: nil" # optional keyword
|
|
256
|
+
when :keyrest then "**#{name}" # double splat
|
|
257
|
+
when :block then "&#{name}" # block parameter
|
|
258
|
+
when :nokey then nil # **nil — skip (no keywords accepted)
|
|
259
|
+
else name&.to_s
|
|
144
260
|
end
|
|
145
|
-
end.join(", ")
|
|
261
|
+
end.compact.join(", ")
|
|
146
262
|
end
|
|
147
263
|
|
|
148
264
|
def underscore(str)
|
data/lib/mana/config.rb
CHANGED
|
@@ -1,28 +1,116 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mana
|
|
4
|
+
# Central configuration for Mana. Set via Mana.configure { |c| ... }.
|
|
5
|
+
#
|
|
6
|
+
# Key options:
|
|
7
|
+
# model - LLM model name (default: claude-sonnet-4-6)
|
|
8
|
+
# api_key - API key, falls back to ANTHROPIC_API_KEY or OPENAI_API_KEY env vars
|
|
9
|
+
# base_url - Custom API endpoint, falls back to ANTHROPIC_API_URL or OPENAI_API_URL
|
|
10
|
+
# backend - :anthropic, :openai, or nil (auto-detect from model name)
|
|
11
|
+
# timeout - HTTP timeout in seconds (default: 120)
|
|
12
|
+
# memory_pressure - Token ratio (0-1) that triggers memory compaction (default: 0.7)
|
|
4
13
|
class Config
|
|
5
14
|
attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
|
|
6
|
-
:backend,
|
|
15
|
+
:backend, :verbose,
|
|
7
16
|
:namespace, :memory_store, :memory_path,
|
|
8
17
|
:context_window, :memory_pressure, :memory_keep_recent,
|
|
9
18
|
:compact_model, :on_compact
|
|
19
|
+
attr_reader :timeout
|
|
10
20
|
|
|
21
|
+
DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com"
|
|
22
|
+
DEFAULT_OPENAI_URL = "https://api.openai.com"
|
|
23
|
+
|
|
24
|
+
# All config options can be set via environment variables:
|
|
25
|
+
# MANA_MODEL, MANA_VERBOSE, MANA_TIMEOUT, MANA_BACKEND
|
|
26
|
+
# ANTHROPIC_API_KEY / OPENAI_API_KEY
|
|
27
|
+
# ANTHROPIC_API_URL / OPENAI_API_URL
|
|
11
28
|
def initialize
|
|
12
|
-
@model = "claude-sonnet-4-
|
|
29
|
+
@model = ENV["MANA_MODEL"] || "claude-sonnet-4-6"
|
|
13
30
|
@temperature = 0
|
|
14
|
-
@api_key = ENV["ANTHROPIC_API_KEY"]
|
|
31
|
+
@api_key = ENV["ANTHROPIC_API_KEY"] || ENV["OPENAI_API_KEY"]
|
|
15
32
|
@max_iterations = 50
|
|
16
|
-
@base_url = "
|
|
17
|
-
|
|
33
|
+
@base_url = ENV["ANTHROPIC_API_URL"] || ENV["OPENAI_API_URL"]
|
|
34
|
+
self.timeout = (ENV["MANA_TIMEOUT"] || 120).to_i
|
|
35
|
+
@verbose = %w[1 true yes].include?(ENV["MANA_VERBOSE"]&.downcase)
|
|
36
|
+
@backend = ENV["MANA_BACKEND"]&.to_sym
|
|
37
|
+
sec = ENV["MANA_SECURITY"]
|
|
38
|
+
@security = SecurityPolicy.new(sec ? sec.to_sym : :standard)
|
|
18
39
|
@namespace = nil
|
|
19
40
|
@memory_store = nil
|
|
20
41
|
@memory_path = nil
|
|
21
|
-
@context_window =
|
|
42
|
+
@context_window = 128_000
|
|
22
43
|
@memory_pressure = 0.7
|
|
23
44
|
@memory_keep_recent = 4
|
|
24
45
|
@compact_model = nil
|
|
25
46
|
@on_compact = nil
|
|
26
47
|
end
|
|
48
|
+
|
|
49
|
+
# Set timeout; must be a positive number
|
|
50
|
+
def timeout=(value)
|
|
51
|
+
unless value.is_a?(Numeric) && value.positive?
|
|
52
|
+
raise ArgumentError, "timeout must be a positive number, got #{value.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@timeout = value
|
|
56
|
+
end
|
|
57
|
+
|
|
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
|
+
# Resolve the effective base URL based on the configured or auto-detected backend.
|
|
76
|
+
# Falls back to the appropriate default when no explicit URL is set.
|
|
77
|
+
def effective_base_url
|
|
78
|
+
# Return user-configured URL if explicitly set
|
|
79
|
+
return @base_url if @base_url
|
|
80
|
+
|
|
81
|
+
# Otherwise pick the default URL based on backend type
|
|
82
|
+
if anthropic_backend?
|
|
83
|
+
DEFAULT_ANTHROPIC_URL
|
|
84
|
+
else
|
|
85
|
+
DEFAULT_OPENAI_URL
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Validate configuration and raise early if something is wrong.
|
|
90
|
+
# Called automatically by Mana.configure, or manually via Mana.config.validate!
|
|
91
|
+
def validate!
|
|
92
|
+
if @api_key.nil? || @api_key.to_s.strip.empty?
|
|
93
|
+
raise ConfigError,
|
|
94
|
+
"API key is not configured. Set it via environment variable or Mana.configure:\n\n" \
|
|
95
|
+
" export ANTHROPIC_API_KEY=your_key_here\n" \
|
|
96
|
+
" # or\n" \
|
|
97
|
+
" export OPENAI_API_KEY=your_key_here\n" \
|
|
98
|
+
" # or\n" \
|
|
99
|
+
" Mana.configure { |c| c.api_key = \"your_key_here\" }\n"
|
|
100
|
+
end
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Determine whether the current backend is Anthropic
|
|
107
|
+
def anthropic_backend?
|
|
108
|
+
case @backend&.to_s
|
|
109
|
+
when "anthropic" then true
|
|
110
|
+
when "openai" then false
|
|
111
|
+
# No explicit backend; auto-detect from model name pattern
|
|
112
|
+
else @model.match?(Backends::Base::ANTHROPIC_PATTERN)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
27
115
|
end
|
|
28
116
|
end
|