ruby-mana 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +158 -0
- data/lib/mana/config.rb +15 -0
- data/lib/mana/effects.rb +12 -0
- data/lib/mana/engine.rb +310 -0
- data/lib/mana/llm/anthropic.rb +69 -0
- data/lib/mana/llm/base.rb +19 -0
- data/lib/mana/mixin.rb +12 -0
- data/lib/mana/string_ext.rb +10 -0
- data/lib/mana/version.rb +5 -0
- data/lib/mana.rb +37 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d264315170eee3756db9dc603810cb524ce0a933f15b8897d36e7306c20d25b1
|
|
4
|
+
data.tar.gz: fc2f36ef0eceec87a7ff9911384db6ed9c6f004eee8c279fc379d3d2c36756fb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: be603cd3ddfcc6e457b656df9caff5ba8a9de4252dbb74416879daead34c64d7b4c50ad6ea422eaa21b76b92acc780e93b54425250e369e3f14223c0c82fee03
|
|
7
|
+
data.tar.gz: ae6ea7cb12475dded7d5f6cf1c1f73acfb2bc16b592741cd935b9406d118516e1530651095310bc791d436f0039991936e8074da053f629240f0ec5b2c396143
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 carlnoah6
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# ruby-mana 🔮
|
|
2
|
+
|
|
3
|
+
Embed LLM as native Ruby. Write natural language, it just runs.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "mana"
|
|
7
|
+
|
|
8
|
+
numbers = [1, "2", "three", "cuatro", "五"]
|
|
9
|
+
~"compute the semantic average of <numbers> and store in <result>"
|
|
10
|
+
puts result # => 3.0
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What is this?
|
|
14
|
+
|
|
15
|
+
Mana turns LLM into a Ruby co-processor. Your natural language strings can read and write Ruby variables, call Ruby functions, manipulate objects, and control program flow — all from a single `~"..."`.
|
|
16
|
+
|
|
17
|
+
Not an API wrapper. Not prompt formatting. Mana weaves LLM into your Ruby code as a first-class construct.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gem install ruby-mana
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or in your Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "ruby-mana"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Ruby 3.3+ and an Anthropic API key:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export ANTHROPIC_API_KEY=your_key_here
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
Prefix any string with `~` to make it an LLM prompt:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require "mana"
|
|
43
|
+
|
|
44
|
+
numbers = [1, 2, 3, 4, 5]
|
|
45
|
+
~"compute the average of <numbers> and store in <result>"
|
|
46
|
+
puts result
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Variables
|
|
50
|
+
|
|
51
|
+
Use `<var>` to reference variables. Mana figures out read vs write:
|
|
52
|
+
|
|
53
|
+
- Variable exists in scope → Mana reads it and passes to LLM
|
|
54
|
+
- Variable doesn't exist → LLM creates it via `write_var`
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
name = "Alice"
|
|
58
|
+
scores = [85, 92, 78, 95, 88]
|
|
59
|
+
|
|
60
|
+
~"analyze <scores> for <name>, store the mean in <average>, the highest in <best>, and a short comment in <comment>"
|
|
61
|
+
|
|
62
|
+
puts average # => 87.6
|
|
63
|
+
puts best # => 95
|
|
64
|
+
puts comment # => "Excellent and consistent performance"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Object manipulation
|
|
68
|
+
|
|
69
|
+
LLM can read and write object attributes:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class Email
|
|
73
|
+
attr_accessor :subject, :body, :category, :priority
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
email = Email.new
|
|
77
|
+
email.subject = "URGENT: Server down"
|
|
78
|
+
email.body = "Database connection pool exhausted..."
|
|
79
|
+
|
|
80
|
+
~"read <email> subject and body, then set its category and priority"
|
|
81
|
+
|
|
82
|
+
puts email.category # => "urgent"
|
|
83
|
+
puts email.priority # => "high"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Calling Ruby functions
|
|
87
|
+
|
|
88
|
+
LLM can call functions in your scope:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
def fetch_price(symbol)
|
|
92
|
+
{ "AAPL" => 189.5, "GOOG" => 141.2, "TSLA" => 248.9 }[symbol] || 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def send_alert(msg)
|
|
96
|
+
puts "[ALERT] #{msg}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
portfolio = ["AAPL", "GOOG", "TSLA", "MSFT"]
|
|
100
|
+
|
|
101
|
+
~"iterate <portfolio>, call fetch_price for each, send_alert if price > 200, store the sum in <total>"
|
|
102
|
+
puts total # => 579.6
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Mixed control flow
|
|
106
|
+
|
|
107
|
+
Ruby handles the structure, LLM handles the decisions:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
player_hp = 100
|
|
111
|
+
enemy_hp = 80
|
|
112
|
+
inventory = ["sword", "potion", "shield"]
|
|
113
|
+
|
|
114
|
+
while player_hp > 0 && enemy_hp > 0
|
|
115
|
+
~"player HP=<player_hp>, enemy HP=<enemy_hp>, inventory=<inventory>, choose an action and store in <action>"
|
|
116
|
+
|
|
117
|
+
case action
|
|
118
|
+
when "attack" then enemy_hp -= rand(15..25)
|
|
119
|
+
when "defend" then nil
|
|
120
|
+
when "use_item"
|
|
121
|
+
~"pick a healing item from <inventory> and store its name in <item_name>"
|
|
122
|
+
inventory.delete(item_name)
|
|
123
|
+
player_hp += 25
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
player_hp -= action == "defend" ? rand(5..10) : rand(10..20)
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Configuration
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Mana.configure do |c|
|
|
134
|
+
c.model = "claude-sonnet-4-20250514"
|
|
135
|
+
c.temperature = 0
|
|
136
|
+
c.api_key = ENV["ANTHROPIC_API_KEY"]
|
|
137
|
+
c.max_iterations = 50
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Or shorthand
|
|
141
|
+
Mana.model = "claude-sonnet-4-20250514"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## How it works
|
|
145
|
+
|
|
146
|
+
1. `~"..."` calls `String#~@`, which captures the caller's `Binding`
|
|
147
|
+
2. Mana parses `<var>` references and reads existing variables as context
|
|
148
|
+
3. The prompt + context is sent to the LLM with tools: `read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `done`
|
|
149
|
+
4. LLM responds with tool calls → Mana executes them against the live Ruby binding → sends results back
|
|
150
|
+
5. Loop until LLM calls `done` or returns without tool calls
|
|
151
|
+
|
|
152
|
+
## Safety
|
|
153
|
+
|
|
154
|
+
⚠️ Mana executes LLM-generated operations against your live Ruby state. Use with the same caution as `eval`.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
data/lib/mana/config.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@model = "claude-sonnet-4-20250514"
|
|
9
|
+
@temperature = 0
|
|
10
|
+
@api_key = ENV["ANTHROPIC_API_KEY"]
|
|
11
|
+
@max_iterations = 50
|
|
12
|
+
@base_url = "https://api.anthropic.com"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/mana/effects.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
module Effects
|
|
5
|
+
ReadVar = Struct.new(:name)
|
|
6
|
+
WriteVar = Struct.new(:name, :value)
|
|
7
|
+
ReadAttr = Struct.new(:obj_name, :attr)
|
|
8
|
+
WriteAttr = Struct.new(:obj_name, :attr, :value)
|
|
9
|
+
CallFunc = Struct.new(:name, :args)
|
|
10
|
+
Done = Struct.new(:result)
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/mana/engine.rb
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mana
|
|
8
|
+
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
|
+
class << self
|
|
81
|
+
def run(prompt, caller_binding)
|
|
82
|
+
new(prompt, caller_binding).execute
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handler_stack
|
|
86
|
+
Thread.current[:mana_handlers] ||= []
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def with_handler(handler = nil, **opts, &block)
|
|
90
|
+
handler_stack.push(handler)
|
|
91
|
+
block.call
|
|
92
|
+
ensure
|
|
93
|
+
handler_stack.pop
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def initialize(prompt, caller_binding)
|
|
98
|
+
@prompt = prompt
|
|
99
|
+
@binding = caller_binding
|
|
100
|
+
@config = Mana.config
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def execute
|
|
104
|
+
context = build_context(@prompt)
|
|
105
|
+
system_prompt = build_system_prompt(context)
|
|
106
|
+
messages = [{ role: "user", content: @prompt }]
|
|
107
|
+
|
|
108
|
+
iterations = 0
|
|
109
|
+
done_result = nil
|
|
110
|
+
|
|
111
|
+
loop do
|
|
112
|
+
iterations += 1
|
|
113
|
+
raise MaxIterationsError, "exceeded #{@config.max_iterations} iterations" if iterations > @config.max_iterations
|
|
114
|
+
|
|
115
|
+
response = llm_call(system_prompt, messages)
|
|
116
|
+
tool_uses = extract_tool_uses(response)
|
|
117
|
+
|
|
118
|
+
break if tool_uses.empty?
|
|
119
|
+
|
|
120
|
+
# Append assistant message
|
|
121
|
+
messages << { role: "assistant", content: response }
|
|
122
|
+
|
|
123
|
+
# Process each tool use
|
|
124
|
+
tool_results = tool_uses.map do |tu|
|
|
125
|
+
result = handle_effect(tu)
|
|
126
|
+
done_result = tu[:input]["result"] if tu[:name] == "done"
|
|
127
|
+
{ type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
messages << { role: "user", content: tool_results }
|
|
131
|
+
|
|
132
|
+
break if tool_uses.any? { |t| t[:name] == "done" }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
done_result
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# --- Context Building ---
|
|
141
|
+
|
|
142
|
+
def build_context(prompt)
|
|
143
|
+
var_names = prompt.scan(/<(\w+)>/).flatten.uniq
|
|
144
|
+
ctx = {}
|
|
145
|
+
var_names.each do |name|
|
|
146
|
+
val = resolve(name)
|
|
147
|
+
ctx[name] = serialize_value(val)
|
|
148
|
+
rescue NameError
|
|
149
|
+
# Variable doesn't exist yet — will be created by LLM
|
|
150
|
+
end
|
|
151
|
+
ctx
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def build_system_prompt(context)
|
|
155
|
+
parts = [
|
|
156
|
+
"You are embedded inside a Ruby program. You interact with the program's live state using the provided tools.",
|
|
157
|
+
"",
|
|
158
|
+
"Rules:",
|
|
159
|
+
"- Use read_var / read_attr to inspect variables and objects.",
|
|
160
|
+
"- Use write_var to create or update variables in the Ruby scope.",
|
|
161
|
+
"- Use write_attr to set attributes on Ruby objects.",
|
|
162
|
+
"- Use call_func to call Ruby methods available in scope.",
|
|
163
|
+
"- Call done when the task is complete.",
|
|
164
|
+
"- When the user references <var>, that's a variable in scope.",
|
|
165
|
+
"- If a referenced variable doesn't exist yet, the user expects you to create it with write_var.",
|
|
166
|
+
"- Be precise with types: use numbers for numeric values, arrays for lists, strings for text."
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
unless context.empty?
|
|
170
|
+
parts << ""
|
|
171
|
+
parts << "Current variable values:"
|
|
172
|
+
context.each { |k, v| parts << " #{k} = #{v}" }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
parts.join("\n")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# --- Effect Handling ---
|
|
179
|
+
|
|
180
|
+
def handle_effect(tool_use)
|
|
181
|
+
name = tool_use[:name]
|
|
182
|
+
input = tool_use[:input] || {}
|
|
183
|
+
# Normalize keys to strings for consistent access
|
|
184
|
+
input = input.transform_keys(&:to_s) if input.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
# Check handler stack first
|
|
187
|
+
handler = self.class.handler_stack.last
|
|
188
|
+
return handler.call(name, input) if handler && handler.respond_to?(:call)
|
|
189
|
+
|
|
190
|
+
case name
|
|
191
|
+
when "read_var"
|
|
192
|
+
serialize_value(resolve(input["name"]))
|
|
193
|
+
|
|
194
|
+
when "write_var"
|
|
195
|
+
var_name = input["name"]
|
|
196
|
+
value = input["value"]
|
|
197
|
+
write_local(var_name, value)
|
|
198
|
+
"ok: #{var_name} = #{value.inspect}"
|
|
199
|
+
|
|
200
|
+
when "read_attr"
|
|
201
|
+
obj = resolve(input["obj"])
|
|
202
|
+
validate_name!(input["attr"])
|
|
203
|
+
serialize_value(obj.public_send(input["attr"]))
|
|
204
|
+
|
|
205
|
+
when "write_attr"
|
|
206
|
+
obj = resolve(input["obj"])
|
|
207
|
+
validate_name!(input["attr"])
|
|
208
|
+
obj.public_send("#{input['attr']}=", input["value"])
|
|
209
|
+
"ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
|
|
210
|
+
|
|
211
|
+
when "call_func"
|
|
212
|
+
func = input["name"]
|
|
213
|
+
validate_name!(func)
|
|
214
|
+
args = input["args"] || []
|
|
215
|
+
result = @binding.receiver.method(func.to_sym).call(*args)
|
|
216
|
+
serialize_value(result)
|
|
217
|
+
|
|
218
|
+
when "done"
|
|
219
|
+
input["result"].to_s
|
|
220
|
+
|
|
221
|
+
else
|
|
222
|
+
"error: unknown tool #{name}"
|
|
223
|
+
end
|
|
224
|
+
rescue => e
|
|
225
|
+
"error: #{e.class}: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# --- Binding Helpers ---
|
|
229
|
+
|
|
230
|
+
VALID_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
231
|
+
|
|
232
|
+
def validate_name!(name)
|
|
233
|
+
raise Mana::Error, "invalid identifier: #{name.inspect}" unless name.match?(VALID_IDENTIFIER)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def resolve(name)
|
|
237
|
+
validate_name!(name)
|
|
238
|
+
if @binding.local_variable_defined?(name.to_sym)
|
|
239
|
+
@binding.local_variable_get(name.to_sym)
|
|
240
|
+
elsif @binding.receiver.respond_to?(name.to_sym, true)
|
|
241
|
+
@binding.receiver.send(name.to_sym)
|
|
242
|
+
else
|
|
243
|
+
raise NameError, "undefined variable or method '#{name}'"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def write_local(name, value)
|
|
248
|
+
validate_name!(name)
|
|
249
|
+
@binding.local_variable_set(name.to_sym, value)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def serialize_value(val)
|
|
253
|
+
case val
|
|
254
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
255
|
+
val.inspect
|
|
256
|
+
when Symbol
|
|
257
|
+
val.to_s.inspect
|
|
258
|
+
when Array
|
|
259
|
+
"[#{val.map { |v| serialize_value(v) }.join(', ')}]"
|
|
260
|
+
when Hash
|
|
261
|
+
pairs = val.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }
|
|
262
|
+
"{#{pairs.join(', ')}}"
|
|
263
|
+
else
|
|
264
|
+
ivars = val.instance_variables
|
|
265
|
+
obj_repr = ivars.map do |ivar|
|
|
266
|
+
attr_name = ivar.to_s.delete_prefix("@")
|
|
267
|
+
"#{attr_name}: #{val.instance_variable_get(ivar).inspect}" rescue nil
|
|
268
|
+
end.compact.join(", ")
|
|
269
|
+
"#<#{val.class} #{obj_repr}>"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# --- LLM Client ---
|
|
274
|
+
|
|
275
|
+
def llm_call(system, messages)
|
|
276
|
+
uri = URI("#{@config.base_url}/v1/messages")
|
|
277
|
+
body = {
|
|
278
|
+
model: @config.model,
|
|
279
|
+
max_tokens: 4096,
|
|
280
|
+
system: system,
|
|
281
|
+
tools: TOOLS,
|
|
282
|
+
messages: messages
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
286
|
+
http.use_ssl = uri.scheme == "https"
|
|
287
|
+
http.read_timeout = 120
|
|
288
|
+
|
|
289
|
+
req = Net::HTTP::Post.new(uri)
|
|
290
|
+
req["Content-Type"] = "application/json"
|
|
291
|
+
req["x-api-key"] = @config.api_key
|
|
292
|
+
req["anthropic-version"] = "2023-06-01"
|
|
293
|
+
req.body = JSON.generate(body)
|
|
294
|
+
|
|
295
|
+
res = http.request(req)
|
|
296
|
+
raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
297
|
+
|
|
298
|
+
parsed = JSON.parse(res.body, symbolize_names: true)
|
|
299
|
+
parsed[:content] || []
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def extract_tool_uses(content)
|
|
303
|
+
return [] unless content.is_a?(Array)
|
|
304
|
+
|
|
305
|
+
content
|
|
306
|
+
.select { |block| block[:type] == "tool_use" }
|
|
307
|
+
.map { |block| { id: block[:id], name: block[:name], input: block[:input] || {} } }
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Mana
|
|
8
|
+
module LLM
|
|
9
|
+
class Anthropic < Base
|
|
10
|
+
API_URL = "https://api.anthropic.com/v1/messages"
|
|
11
|
+
API_VERSION = "2023-06-01"
|
|
12
|
+
|
|
13
|
+
def initialize(config = Mana.config)
|
|
14
|
+
super(config)
|
|
15
|
+
@api_key = config.api_key
|
|
16
|
+
@model = config.model
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def chat(system:, messages:, tools:)
|
|
20
|
+
raise Mana::Error, "Anthropic API key not set" unless @api_key
|
|
21
|
+
|
|
22
|
+
body = {
|
|
23
|
+
model: @model,
|
|
24
|
+
max_tokens: 4096,
|
|
25
|
+
temperature: @config.temperature,
|
|
26
|
+
system: system,
|
|
27
|
+
messages: messages,
|
|
28
|
+
tools: tools
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
response = post(body)
|
|
32
|
+
|
|
33
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
34
|
+
parsed = JSON.parse(response.body) rescue nil
|
|
35
|
+
error_msg = parsed&.dig("error", "message") || response.body
|
|
36
|
+
raise Mana::Error, "Anthropic API error (#{response.code}): #{error_msg}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
parsed = JSON.parse(response.body)
|
|
40
|
+
parsed["content"].map { |block| symbolize_keys(block) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def post(body)
|
|
46
|
+
uri = URI(API_URL)
|
|
47
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
48
|
+
http.use_ssl = true
|
|
49
|
+
http.open_timeout = 30
|
|
50
|
+
http.read_timeout = 120
|
|
51
|
+
http.write_timeout = 30
|
|
52
|
+
|
|
53
|
+
request = Net::HTTP::Post.new(uri)
|
|
54
|
+
request["x-api-key"] = @api_key
|
|
55
|
+
request["anthropic-version"] = API_VERSION
|
|
56
|
+
request["content-type"] = "application/json"
|
|
57
|
+
request.body = JSON.generate(body)
|
|
58
|
+
|
|
59
|
+
http.request(request)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def symbolize_keys(hash)
|
|
63
|
+
hash.each_with_object({}) do |(k, v), acc|
|
|
64
|
+
acc[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
module LLM
|
|
5
|
+
# Base interface for LLM clients.
|
|
6
|
+
# Subclass and implement #chat to add new providers.
|
|
7
|
+
class Base
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Send a chat request with tools.
|
|
13
|
+
# Returns an array of content blocks (tool_use / text).
|
|
14
|
+
def chat(system:, messages:, tools:)
|
|
15
|
+
raise NotImplementedError, "#{self.class}#chat not implemented"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/mana/mixin.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
# Include in classes to use ~"..." in instance methods.
|
|
5
|
+
# binding_of_caller handles scope automatically, so this
|
|
6
|
+
# is mainly a semantic marker + future extension point.
|
|
7
|
+
module Mixin
|
|
8
|
+
def self.included(base)
|
|
9
|
+
# Reserved for future: auto-expose methods, etc.
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/mana/version.rb
ADDED
data/lib/mana.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mana/version"
|
|
4
|
+
require_relative "mana/config"
|
|
5
|
+
require_relative "mana/effects"
|
|
6
|
+
require_relative "mana/engine"
|
|
7
|
+
require_relative "mana/string_ext"
|
|
8
|
+
require_relative "mana/mixin"
|
|
9
|
+
|
|
10
|
+
module Mana
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class MaxIterationsError < Error; end
|
|
13
|
+
class LLMError < Error; end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def config
|
|
17
|
+
@config ||= Config.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def configure
|
|
21
|
+
yield(config) if block_given?
|
|
22
|
+
config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def model=(model)
|
|
26
|
+
config.model = model
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def handle(handler = nil, **opts, &block)
|
|
30
|
+
Engine.with_handler(handler, **opts, &block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset!
|
|
34
|
+
@config = Config.new
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby-mana
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Carl
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-02-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: binding_of_caller
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
27
|
+
description: |
|
|
28
|
+
Mana lets you write natural language strings in Ruby that execute via LLM
|
|
29
|
+
with full access to your program's live state. Read/write variables, call
|
|
30
|
+
functions, manipulate objects — all from a simple ~"..." syntax.
|
|
31
|
+
email:
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- LICENSE
|
|
38
|
+
- README.md
|
|
39
|
+
- lib/mana.rb
|
|
40
|
+
- lib/mana/config.rb
|
|
41
|
+
- lib/mana/effects.rb
|
|
42
|
+
- lib/mana/engine.rb
|
|
43
|
+
- lib/mana/llm/anthropic.rb
|
|
44
|
+
- lib/mana/llm/base.rb
|
|
45
|
+
- lib/mana/mixin.rb
|
|
46
|
+
- lib/mana/string_ext.rb
|
|
47
|
+
- lib/mana/version.rb
|
|
48
|
+
homepage: https://github.com/carlnoah6/ruby-mana
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
homepage_uri: https://github.com/carlnoah6/ruby-mana
|
|
53
|
+
source_code_uri: https://github.com/carlnoah6/ruby-mana
|
|
54
|
+
changelog_uri: https://github.com/carlnoah6/ruby-mana/blob/main/CHANGELOG.md
|
|
55
|
+
post_install_message:
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 3.3.0
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 3.4.20
|
|
71
|
+
signing_key:
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Embed LLM as native Ruby — write natural language, it just runs
|
|
74
|
+
test_files: []
|