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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d264315170eee3756db9dc603810cb524ce0a933f15b8897d36e7306c20d25b1
4
- data.tar.gz: fc2f36ef0eceec87a7ff9911384db6ed9c6f004eee8c279fc379d3d2c36756fb
3
+ metadata.gz: ddacfb41b0e2ca07887ffd1c683e211f90529901a4a401393c1c1da91f5da46c
4
+ data.tar.gz: 33db80124c57c45499b0a8ae392463782d6e3c6f221853e9d1353ac7f64838af
5
5
  SHA512:
6
- metadata.gz: be603cd3ddfcc6e457b656df9caff5ba8a9de4252dbb74416879daead34c64d7b4c50ad6ea422eaa21b76b92acc780e93b54425250e369e3f14223c0c82fee03
7
- data.tar.gz: ae6ea7cb12475dded7d5f6cf1c1f73acfb2bc16b592741cd935b9406d118516e1530651095310bc791d436f0039991936e8074da053f629240f0ec5b2c396143
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
- # Or shorthand
141
- Mana.model = "claude-sonnet-4-20250514"
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. 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
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