ruby-mana 0.1.0 → 0.3.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 +4 -4
- data/README.md +119 -5
- data/lib/mana/compiler.rb +158 -0
- data/lib/mana/config.rb +12 -1
- data/lib/mana/context_window.rb +28 -0
- data/lib/mana/effect_registry.rb +155 -0
- data/lib/mana/engine.rb +116 -6
- data/lib/mana/introspect.rb +117 -0
- data/lib/mana/memory.rb +236 -0
- data/lib/mana/memory_store.rb +69 -0
- data/lib/mana/mixin.rb +23 -4
- data/lib/mana/namespace.rb +39 -0
- data/lib/mana/string_ext.rb +8 -1
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +39 -1
- metadata +10 -6
- data/lib/mana/effects.rb +0 -12
- data/lib/mana/llm/anthropic.rb +0 -69
- data/lib/mana/llm/base.rb +0 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ddacfb41b0e2ca07887ffd1c683e211f90529901a4a401393c1c1da91f5da46c
|
|
4
|
+
data.tar.gz: 33db80124c57c45499b0a8ae392463782d6e3c6f221853e9d1353ac7f64838af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c604e080b7a6d4cf04d54d4782d7c36d9789d76f7e485728c1eb1869757ae320d688a8f222c00d9591116e300fec2f7272d77f04b1d9c402edef714ea628b24
|
|
7
|
+
data.tar.gz: 2ac2008b69a995bf5d1c4a098d25e3cb89b02b6a83e48c15014a7ba1268efecb3f84ca756a4efd3d53a200b6e160d0c79c38f6571eb581895dac6a9764e343e7
|
data/README.md
CHANGED
|
@@ -135,19 +135,133 @@ Mana.configure do |c|
|
|
|
135
135
|
c.temperature = 0
|
|
136
136
|
c.api_key = ENV["ANTHROPIC_API_KEY"]
|
|
137
137
|
c.max_iterations = 50
|
|
138
|
+
|
|
139
|
+
# Memory settings
|
|
140
|
+
c.namespace = "my-project" # nil = auto-detect from git/pwd
|
|
141
|
+
c.context_window = 200_000 # nil = auto-detect from model
|
|
142
|
+
c.memory_pressure = 0.7 # compact when tokens exceed 70% of context window
|
|
143
|
+
c.memory_keep_recent = 4 # keep last 4 rounds during compaction
|
|
144
|
+
c.compact_model = nil # nil = use main model for compaction
|
|
145
|
+
c.memory_store = Mana::FileStore.new # default file-based persistence
|
|
138
146
|
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Custom effect handlers
|
|
150
|
+
|
|
151
|
+
Define your own tools that the LLM can call. Each effect becomes an LLM tool automatically — the block's keyword parameters define the tool's input schema.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
# No params
|
|
155
|
+
Mana.define_effect :get_time do
|
|
156
|
+
Time.now.to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# With params — keyword args become tool parameters
|
|
160
|
+
Mana.define_effect :query_db do |sql:|
|
|
161
|
+
ActiveRecord::Base.connection.execute(sql).to_a
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# With description (optional, recommended)
|
|
165
|
+
Mana.define_effect :search_web,
|
|
166
|
+
description: "Search the web for information" do |query:, max_results: 5|
|
|
167
|
+
WebSearch.search(query, limit: max_results)
|
|
168
|
+
end
|
|
139
169
|
|
|
140
|
-
#
|
|
141
|
-
|
|
170
|
+
# Use in prompts
|
|
171
|
+
~"get the current time and store in <now>"
|
|
172
|
+
~"find recent orders using query_db, store in <orders>"
|
|
142
173
|
```
|
|
143
174
|
|
|
175
|
+
Built-in effects (`read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `done`) are reserved and cannot be overridden.
|
|
176
|
+
|
|
177
|
+
### Memory — automatic context sharing
|
|
178
|
+
|
|
179
|
+
Consecutive `~"..."` calls automatically share context. No wrapper block needed:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
~"remember: always translate to Japanese, casual tone"
|
|
183
|
+
~"translate <text1>, store in <result1>" # uses the preference
|
|
184
|
+
~"translate <text2>, store in <result2>" # still remembers
|
|
185
|
+
~"which translation was harder? store in <analysis>" # can reference both
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Memory is per-thread and auto-created on the first `~"..."` call.
|
|
189
|
+
|
|
190
|
+
#### Long-term memory
|
|
191
|
+
|
|
192
|
+
The LLM has a `remember` tool that persists facts across script executions:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
~"remember that the user prefers concise output"
|
|
196
|
+
# ... later, in a different script execution ...
|
|
197
|
+
~"translate <text>" # LLM sees the preference in its long-term memory
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Manage long-term memory via Ruby:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
Mana.memory.long_term # view all memories
|
|
204
|
+
Mana.memory.forget(id: 2) # remove a specific memory
|
|
205
|
+
Mana.memory.clear_long_term! # clear all long-term memories
|
|
206
|
+
Mana.memory.clear_short_term! # clear conversation history
|
|
207
|
+
Mana.memory.clear! # clear everything
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### Incognito mode
|
|
211
|
+
|
|
212
|
+
Run without any memory — nothing is loaded or saved:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
Mana.incognito do
|
|
216
|
+
~"translate <text>" # no memory, no persistence
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### LLM-compiled methods
|
|
221
|
+
|
|
222
|
+
`mana def` lets LLM generate a method implementation on first call. The generated code is cached as a real `.rb` file — subsequent calls are pure Ruby with zero API overhead.
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
mana def fizzbuzz(n)
|
|
226
|
+
~"return an array of FizzBuzz results from 1 to n"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
fizzbuzz(15) # first call → LLM generates code → cached → executed
|
|
230
|
+
fizzbuzz(20) # pure Ruby from .mana_cache/fizzbuzz.rb
|
|
231
|
+
|
|
232
|
+
# View the generated source
|
|
233
|
+
puts Mana.source(:fizzbuzz)
|
|
234
|
+
# def fizzbuzz(n)
|
|
235
|
+
# (1..n).map do |i|
|
|
236
|
+
# if i % 15 == 0 then "FizzBuzz"
|
|
237
|
+
# elsif i % 3 == 0 then "Fizz"
|
|
238
|
+
# elsif i % 5 == 0 then "Buzz"
|
|
239
|
+
# else i.to_s
|
|
240
|
+
# end
|
|
241
|
+
# end
|
|
242
|
+
# end
|
|
243
|
+
|
|
244
|
+
# Works in classes too
|
|
245
|
+
class Converter
|
|
246
|
+
include Mana::Mixin
|
|
247
|
+
|
|
248
|
+
mana def celsius_to_fahrenheit(c)
|
|
249
|
+
~"convert Celsius to Fahrenheit"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Generated files live in `.mana_cache/` (add to `.gitignore`, or commit them to skip LLM on CI).
|
|
255
|
+
|
|
144
256
|
## How it works
|
|
145
257
|
|
|
146
258
|
1. `~"..."` calls `String#~@`, which captures the caller's `Binding`
|
|
147
259
|
2. Mana parses `<var>` references and reads existing variables as context
|
|
148
|
-
3.
|
|
149
|
-
4.
|
|
150
|
-
5.
|
|
260
|
+
3. Memory loads long-term facts and prior conversation into the system prompt
|
|
261
|
+
4. The prompt + context is sent to the LLM with tools: `read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `remember`, `done`
|
|
262
|
+
5. LLM responds with tool calls → Mana executes them against the live Ruby binding → sends results back
|
|
263
|
+
6. Loop until LLM calls `done` or returns without tool calls
|
|
264
|
+
7. After completion, memory compaction runs in background if context is getting large
|
|
151
265
|
|
|
152
266
|
## Safety
|
|
153
267
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Mana
|
|
6
|
+
# Compiler for `mana def` — LLM generates method implementations on first call,
|
|
7
|
+
# caches them as real .rb files, and replaces the method with native Ruby.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# mana def fizzbuzz(n)
|
|
11
|
+
# ~"return an array of FizzBuzz results from 1 to n"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# fizzbuzz(15) # first call → LLM generates code → cached → executed
|
|
15
|
+
# fizzbuzz(20) # subsequent calls → pure Ruby, zero API overhead
|
|
16
|
+
#
|
|
17
|
+
# Mana.source(:fizzbuzz) # view generated source
|
|
18
|
+
module Compiler
|
|
19
|
+
class << self
|
|
20
|
+
# Registry of compiled method sources: { "ClassName#method" => source_code }
|
|
21
|
+
def registry
|
|
22
|
+
@registry ||= {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Cache directory for generated .rb files
|
|
26
|
+
def cache_dir
|
|
27
|
+
@cache_dir || ".mana_cache"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_writer :cache_dir
|
|
31
|
+
|
|
32
|
+
# Get the generated source for a compiled method
|
|
33
|
+
def source(method_name, owner: nil)
|
|
34
|
+
key = registry_key(method_name, owner)
|
|
35
|
+
registry[key]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Compile a method: wrap it so first invocation triggers LLM code generation
|
|
39
|
+
def compile(owner, method_name)
|
|
40
|
+
original = owner.instance_method(method_name)
|
|
41
|
+
compiler = self
|
|
42
|
+
key = registry_key(method_name, owner)
|
|
43
|
+
|
|
44
|
+
# Read the prompt from the original method body
|
|
45
|
+
prompt = extract_prompt(original)
|
|
46
|
+
|
|
47
|
+
# Build parameter signature for the generated method
|
|
48
|
+
params_desc = describe_params(original)
|
|
49
|
+
|
|
50
|
+
owner.define_method(method_name) do |*args, **kwargs, &blk|
|
|
51
|
+
# Generate implementation via LLM
|
|
52
|
+
generated = compiler.generate(method_name, params_desc, prompt)
|
|
53
|
+
|
|
54
|
+
# Write to cache file
|
|
55
|
+
cache_path = compiler.write_cache(method_name, generated, owner)
|
|
56
|
+
|
|
57
|
+
# Store in registry
|
|
58
|
+
compiler.registry[key] = generated
|
|
59
|
+
|
|
60
|
+
# Define the method on the correct owner (not Object) via class_eval
|
|
61
|
+
target_owner = owner
|
|
62
|
+
target_owner.class_eval(generated, cache_path, 1)
|
|
63
|
+
|
|
64
|
+
# Call the now-native method
|
|
65
|
+
send(method_name, *args, **kwargs, &blk)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Generate Ruby method source via LLM
|
|
70
|
+
def generate(method_name, params_desc, prompt)
|
|
71
|
+
code = nil # pre-declare so binding captures it
|
|
72
|
+
b = binding
|
|
73
|
+
engine_prompt = "Write a Ruby method definition `def #{method_name}(#{params_desc})` that: #{prompt}. " \
|
|
74
|
+
"Return ONLY the complete method definition (def...end), no explanation. " \
|
|
75
|
+
"Store the code as a string in <code>"
|
|
76
|
+
|
|
77
|
+
Mana::Engine.run(engine_prompt, b)
|
|
78
|
+
code
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Write generated code to a cache file, return the path
|
|
82
|
+
def write_cache(method_name, source, owner = nil)
|
|
83
|
+
FileUtils.mkdir_p(cache_dir)
|
|
84
|
+
prefix = owner && owner != Object ? "#{underscore(owner.name)}_" : ""
|
|
85
|
+
path = File.join(cache_dir, "#{prefix}#{method_name}.rb")
|
|
86
|
+
File.write(path, "# Auto-generated by ruby-mana\n# frozen_string_literal: true\n\n#{source}\n")
|
|
87
|
+
path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Clear all cached files and registry
|
|
91
|
+
def clear!
|
|
92
|
+
FileUtils.rm_rf(cache_dir) if Dir.exist?(cache_dir)
|
|
93
|
+
@registry = {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def registry_key(method_name, owner = nil)
|
|
99
|
+
if owner && owner != Object
|
|
100
|
+
"#{owner}##{method_name}"
|
|
101
|
+
else
|
|
102
|
+
method_name.to_s
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Extract the prompt string from the original method.
|
|
107
|
+
# The method body should be a single ~"..." expression.
|
|
108
|
+
def extract_prompt(unbound_method)
|
|
109
|
+
source_loc = unbound_method.source_location
|
|
110
|
+
return nil unless source_loc
|
|
111
|
+
|
|
112
|
+
file, line = source_loc
|
|
113
|
+
return nil unless file && File.exist?(file)
|
|
114
|
+
|
|
115
|
+
lines = File.readlines(file)
|
|
116
|
+
# Scan from the def line to find the prompt string
|
|
117
|
+
body_lines = []
|
|
118
|
+
depth = 0
|
|
119
|
+
(line - 1...lines.length).each do |i|
|
|
120
|
+
l = lines[i]
|
|
121
|
+
depth += l.scan(/\bdef\b|\bdo\b|\bclass\b|\bmodule\b|\bif\b|\bunless\b|\bcase\b|\bwhile\b|\buntil\b|\bbegin\b/).length
|
|
122
|
+
depth -= l.scan(/\bend\b/).length
|
|
123
|
+
body_lines << l
|
|
124
|
+
break if depth <= 0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Extract string content from ~"..." pattern
|
|
128
|
+
body = body_lines.join
|
|
129
|
+
match = body.match(/~"([^"]*)"/) || body.match(/~'([^']*)'/)
|
|
130
|
+
match ? match[1] : body_lines[1...-1].join.strip
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def describe_params(unbound_method)
|
|
134
|
+
unbound_method.parameters.map do |(type, name)|
|
|
135
|
+
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
|
+
else name.to_s
|
|
144
|
+
end
|
|
145
|
+
end.join(", ")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def underscore(str)
|
|
149
|
+
return "anonymous" if str.nil? || str.empty?
|
|
150
|
+
|
|
151
|
+
str.gsub("::", "_")
|
|
152
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
153
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
154
|
+
.downcase
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/mana/config.rb
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Mana
|
|
4
4
|
class Config
|
|
5
|
-
attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url
|
|
5
|
+
attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
|
|
6
|
+
:namespace, :memory_store, :memory_path,
|
|
7
|
+
:context_window, :memory_pressure, :memory_keep_recent,
|
|
8
|
+
:compact_model, :on_compact
|
|
6
9
|
|
|
7
10
|
def initialize
|
|
8
11
|
@model = "claude-sonnet-4-20250514"
|
|
@@ -10,6 +13,14 @@ module Mana
|
|
|
10
13
|
@api_key = ENV["ANTHROPIC_API_KEY"]
|
|
11
14
|
@max_iterations = 50
|
|
12
15
|
@base_url = "https://api.anthropic.com"
|
|
16
|
+
@namespace = nil
|
|
17
|
+
@memory_store = nil
|
|
18
|
+
@memory_path = nil
|
|
19
|
+
@context_window = nil
|
|
20
|
+
@memory_pressure = 0.7
|
|
21
|
+
@memory_keep_recent = 4
|
|
22
|
+
@compact_model = nil
|
|
23
|
+
@on_compact = nil
|
|
13
24
|
end
|
|
14
25
|
end
|
|
15
26
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
module ContextWindow
|
|
5
|
+
SIZES = {
|
|
6
|
+
/claude-3-5-sonnet/ => 200_000,
|
|
7
|
+
/claude-sonnet-4/ => 200_000,
|
|
8
|
+
/claude-3-5-haiku/ => 200_000,
|
|
9
|
+
/claude-3-opus/ => 200_000,
|
|
10
|
+
/claude-opus-4/ => 200_000,
|
|
11
|
+
/gpt-4o/ => 128_000,
|
|
12
|
+
/gpt-4-turbo/ => 128_000,
|
|
13
|
+
/gpt-3\.5/ => 16_385
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
DEFAULT = 128_000
|
|
17
|
+
|
|
18
|
+
def self.detect(model_name)
|
|
19
|
+
return DEFAULT unless model_name
|
|
20
|
+
|
|
21
|
+
SIZES.each do |pattern, size|
|
|
22
|
+
return size if model_name.match?(pattern)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
DEFAULT
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
# Registry for custom effect handlers.
|
|
5
|
+
#
|
|
6
|
+
# Users define effects that become LLM tools automatically:
|
|
7
|
+
#
|
|
8
|
+
# Mana.define_effect :query_db,
|
|
9
|
+
# description: "Execute a SQL query" do |sql:|
|
|
10
|
+
# DB.execute(sql)
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The block's keyword parameters become the tool's input schema.
|
|
14
|
+
# The block's return value is serialized and sent back to the LLM.
|
|
15
|
+
module EffectRegistry
|
|
16
|
+
class EffectDefinition
|
|
17
|
+
attr_reader :name, :description, :handler, :params
|
|
18
|
+
|
|
19
|
+
def initialize(name, description: nil, &handler)
|
|
20
|
+
@name = name.to_s
|
|
21
|
+
@description = description || @name
|
|
22
|
+
@handler = handler
|
|
23
|
+
@params = extract_params(handler)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Convert to LLM tool definition
|
|
27
|
+
def to_tool
|
|
28
|
+
properties = {}
|
|
29
|
+
required = []
|
|
30
|
+
|
|
31
|
+
@params.each do |param|
|
|
32
|
+
properties[param[:name]] = {
|
|
33
|
+
type: infer_type(param[:default]),
|
|
34
|
+
description: param[:name]
|
|
35
|
+
}
|
|
36
|
+
required << param[:name] if param[:required]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
tool = {
|
|
40
|
+
name: @name,
|
|
41
|
+
description: @description,
|
|
42
|
+
input_schema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: properties
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
tool[:input_schema][:required] = required unless required.empty?
|
|
48
|
+
tool
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Call the handler with LLM-provided input
|
|
52
|
+
def call(input)
|
|
53
|
+
kwargs = {}
|
|
54
|
+
@params.each do |param|
|
|
55
|
+
key = param[:name]
|
|
56
|
+
if input.key?(key)
|
|
57
|
+
kwargs[key.to_sym] = input[key]
|
|
58
|
+
elsif param[:default] != :__mana_no_default__
|
|
59
|
+
# Use block's default
|
|
60
|
+
elsif param[:required]
|
|
61
|
+
raise Mana::Error, "missing required parameter: #{key}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if kwargs.empty? && @params.empty?
|
|
66
|
+
@handler.call
|
|
67
|
+
else
|
|
68
|
+
@handler.call(**kwargs)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def extract_params(block)
|
|
75
|
+
return [] unless block
|
|
76
|
+
|
|
77
|
+
block.parameters.map do |(type, name)|
|
|
78
|
+
case type
|
|
79
|
+
when :keyreq
|
|
80
|
+
{ name: name.to_s, required: true, default: :__mana_no_default__ }
|
|
81
|
+
when :key
|
|
82
|
+
# Try to get default value — not possible via reflection,
|
|
83
|
+
# so we mark it as optional with unknown default
|
|
84
|
+
{ name: name.to_s, required: false, default: nil }
|
|
85
|
+
when :keyrest
|
|
86
|
+
# **kwargs — skip, can't generate schema
|
|
87
|
+
nil
|
|
88
|
+
else
|
|
89
|
+
# Positional args — treat as required string params
|
|
90
|
+
{ name: name.to_s, required: true, default: :__mana_no_default__ } if name
|
|
91
|
+
end
|
|
92
|
+
end.compact
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def infer_type(default)
|
|
96
|
+
case default
|
|
97
|
+
when Integer then "integer"
|
|
98
|
+
when Float then "number"
|
|
99
|
+
when TrueClass, FalseClass then "boolean"
|
|
100
|
+
when Array then "array"
|
|
101
|
+
when Hash then "object"
|
|
102
|
+
else "string"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
RESERVED_EFFECTS = %w[read_var write_var read_attr write_attr call_func done remember].freeze
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
def registry
|
|
111
|
+
@registry ||= {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def define(name, description: nil, &handler)
|
|
115
|
+
name_s = name.to_s
|
|
116
|
+
if RESERVED_EFFECTS.include?(name_s)
|
|
117
|
+
raise Mana::Error, "cannot override built-in effect: #{name_s}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
registry[name_s] = EffectDefinition.new(name, description: description, &handler)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def undefine(name)
|
|
124
|
+
registry.delete(name.to_s)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def defined?(name)
|
|
128
|
+
registry.key?(name.to_s)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get(name)
|
|
132
|
+
registry[name.to_s]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Generate tool definitions for all registered effects
|
|
136
|
+
def tool_definitions
|
|
137
|
+
registry.values.map(&:to_tool)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def clear!
|
|
141
|
+
@registry = {}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Handle a tool call if it matches a registered effect
|
|
145
|
+
# Returns [handled, result] — handled is true if we processed it
|
|
146
|
+
def handle(name, input)
|
|
147
|
+
effect = get(name)
|
|
148
|
+
return [false, nil] unless effect
|
|
149
|
+
|
|
150
|
+
result = effect.call(input)
|
|
151
|
+
[true, result]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|