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.
data/lib/mana/engine.rb CHANGED
@@ -77,6 +77,16 @@ module Mana
77
77
  }
78
78
  ].freeze
79
79
 
80
+ REMEMBER_TOOL = {
81
+ name: "remember",
82
+ description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
83
+ input_schema: {
84
+ type: "object",
85
+ properties: { content: { type: "string", description: "The fact to remember" } },
86
+ required: ["content"]
87
+ }
88
+ }.freeze
89
+
80
90
  class << self
81
91
  def run(prompt, caller_binding)
82
92
  new(prompt, caller_binding).execute
@@ -92,18 +102,33 @@ module Mana
92
102
  ensure
93
103
  handler_stack.pop
94
104
  end
105
+
106
+ # Built-in tools + remember + any registered custom effects
107
+ def all_tools
108
+ tools = TOOLS.dup
109
+ tools << REMEMBER_TOOL unless Memory.incognito?
110
+ tools + Mana::EffectRegistry.tool_definitions
111
+ end
95
112
  end
96
113
 
97
114
  def initialize(prompt, caller_binding)
98
115
  @prompt = prompt
99
116
  @binding = caller_binding
100
117
  @config = Mana.config
118
+ @caller_path = caller_source_path
119
+ @incognito = Memory.incognito?
101
120
  end
102
121
 
103
122
  def execute
104
123
  context = build_context(@prompt)
105
124
  system_prompt = build_system_prompt(context)
106
- messages = [{ role: "user", content: @prompt }]
125
+
126
+ # Use memory's short_term messages (auto per-thread), or fresh if incognito
127
+ memory = @incognito ? nil : Memory.current
128
+ memory&.wait_for_compaction
129
+
130
+ messages = memory ? memory.short_term : []
131
+ messages << { role: "user", content: @prompt }
107
132
 
108
133
  iterations = 0
109
134
  done_result = nil
@@ -122,8 +147,8 @@ module Mana
122
147
 
123
148
  # Process each tool use
124
149
  tool_results = tool_uses.map do |tu|
125
- result = handle_effect(tu)
126
- done_result = tu[:input]["result"] if tu[:name] == "done"
150
+ result = handle_effect(tu, memory)
151
+ done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
127
152
  { type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
128
153
  end
129
154
 
@@ -132,6 +157,14 @@ module Mana
132
157
  break if tool_uses.any? { |t| t[:name] == "done" }
133
158
  end
134
159
 
160
+ # Append a final assistant summary so LLM has full context next call
161
+ if memory && done_result
162
+ messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
163
+ end
164
+
165
+ # Schedule compaction if needed (runs in background)
166
+ memory&.schedule_compaction
167
+
135
168
  done_result
136
169
  end
137
170
 
@@ -166,27 +199,80 @@ module Mana
166
199
  "- Be precise with types: use numbers for numeric values, arrays for lists, strings for text."
167
200
  ]
168
201
 
202
+ # Inject long-term memories or incognito notice
203
+ if @incognito
204
+ parts << ""
205
+ parts << "You are in incognito mode. The remember tool is disabled. No memories will be loaded or saved."
206
+ else
207
+ memory = Memory.current
208
+ if memory
209
+ # Inject summaries from compaction
210
+ unless memory.summaries.empty?
211
+ parts << ""
212
+ parts << "Previous conversation summary:"
213
+ memory.summaries.each { |s| parts << " #{s}" }
214
+ end
215
+
216
+ unless memory.long_term.empty?
217
+ parts << ""
218
+ parts << "Long-term memories (persistent across executions):"
219
+ memory.long_term.each { |m| parts << "- #{m[:content]}" }
220
+ end
221
+
222
+ unless memory.long_term.empty?
223
+ parts << ""
224
+ parts << "You have a `remember` tool to store new facts in long-term memory when the user asks."
225
+ end
226
+ end
227
+ end
228
+
169
229
  unless context.empty?
170
230
  parts << ""
171
231
  parts << "Current variable values:"
172
232
  context.each { |k, v| parts << " #{k} = #{v}" }
173
233
  end
174
234
 
235
+ # Discover available functions from caller's source
236
+ methods = begin
237
+ Mana::Introspect.methods_from_file(@caller_path)
238
+ rescue => _e
239
+ []
240
+ end
241
+ unless methods.empty?
242
+ parts << ""
243
+ parts << Mana::Introspect.format_for_prompt(methods)
244
+ end
245
+
246
+ # List custom effects
247
+ custom_effects = Mana::EffectRegistry.tool_definitions
248
+ unless custom_effects.empty?
249
+ parts << ""
250
+ parts << "Custom tools available:"
251
+ custom_effects.each do |t|
252
+ params = (t[:input_schema][:properties] || {}).keys.join(", ")
253
+ parts << " #{t[:name]}(#{params}) — #{t[:description]}"
254
+ end
255
+ end
256
+
175
257
  parts.join("\n")
176
258
  end
177
259
 
178
260
  # --- Effect Handling ---
179
261
 
180
- def handle_effect(tool_use)
262
+ def handle_effect(tool_use, memory = nil)
181
263
  name = tool_use[:name]
182
264
  input = tool_use[:input] || {}
183
265
  # Normalize keys to strings for consistent access
184
266
  input = input.transform_keys(&:to_s) if input.is_a?(Hash)
185
267
 
186
- # Check handler stack first
268
+ # Check handler stack first (legacy)
187
269
  handler = self.class.handler_stack.last
188
270
  return handler.call(name, input) if handler && handler.respond_to?(:call)
189
271
 
272
+ # Check custom effect registry
273
+ handled, result = Mana::EffectRegistry.handle(name, input)
274
+ return serialize_value(result) if handled
275
+
190
276
  case name
191
277
  when "read_var"
192
278
  serialize_value(resolve(input["name"]))
@@ -215,6 +301,16 @@ module Mana
215
301
  result = @binding.receiver.method(func.to_sym).call(*args)
216
302
  serialize_value(result)
217
303
 
304
+ when "remember"
305
+ if @incognito
306
+ "Memory not saved (incognito mode)"
307
+ elsif memory
308
+ entry = memory.remember(input["content"])
309
+ "Remembered (id=#{entry[:id]}): #{input['content']}"
310
+ else
311
+ "Memory not available"
312
+ end
313
+
218
314
  when "done"
219
315
  input["result"].to_s
220
316
 
@@ -249,6 +345,20 @@ module Mana
249
345
  @binding.local_variable_set(name.to_sym, value)
250
346
  end
251
347
 
348
+ def caller_source_path
349
+ # Walk up the call stack to find the first non-mana source file
350
+ loc = @binding.source_location
351
+ return loc[0] if loc.is_a?(Array)
352
+
353
+ # Fallback: search caller_locations
354
+ caller_locations(4, 20)&.each do |frame|
355
+ path = frame.absolute_path || frame.path
356
+ next if path.nil? || path.include?("mana/")
357
+ return path
358
+ end
359
+ nil
360
+ end
361
+
252
362
  def serialize_value(val)
253
363
  case val
254
364
  when String, Integer, Float, TrueClass, FalseClass, NilClass
@@ -278,7 +388,7 @@ module Mana
278
388
  model: @config.model,
279
389
  max_tokens: 4096,
280
390
  system: system,
281
- tools: TOOLS,
391
+ tools: self.class.all_tools,
282
392
  messages: messages
283
393
  }
284
394
 
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mana
6
+ # Introspects the caller's source file to discover user-defined methods.
7
+ # Uses Prism AST to extract `def` nodes with their parameter signatures.
8
+ module Introspect
9
+ class << self
10
+ # Extract method definitions from a Ruby source file.
11
+ # Returns an array of { name:, params: } hashes.
12
+ #
13
+ # @param path [String] path to the Ruby source file
14
+ # @return [Array<Hash>] method definitions found
15
+ def methods_from_file(path)
16
+ return [] unless path && File.exist?(path)
17
+
18
+ source = File.read(path)
19
+ result = Prism.parse(source)
20
+ methods = []
21
+
22
+ walk(result.value) do |node|
23
+ next unless node.is_a?(Prism::DefNode)
24
+
25
+ params = extract_params(node)
26
+ methods << { name: node.name.to_s, params: params }
27
+ end
28
+
29
+ methods
30
+ end
31
+
32
+ # Format discovered methods as a string for the system prompt.
33
+ #
34
+ # @param methods [Array<Hash>] from methods_from_file
35
+ # @return [String] formatted method list
36
+ def format_for_prompt(methods)
37
+ return "" if methods.empty?
38
+
39
+ lines = methods.map do |m|
40
+ sig = m[:params].empty? ? m[:name] : "#{m[:name]}(#{m[:params].join(', ')})"
41
+ " #{sig}"
42
+ end
43
+
44
+ "Available Ruby functions:\n#{lines.join("\n")}"
45
+ end
46
+
47
+ private
48
+
49
+ def walk(node, &block)
50
+ queue = [node]
51
+ while (current = queue.shift)
52
+ next unless current.respond_to?(:compact_child_nodes)
53
+
54
+ block.call(current)
55
+ queue.concat(current.compact_child_nodes)
56
+ end
57
+ end
58
+
59
+ def extract_params(def_node)
60
+ params_node = def_node.parameters
61
+ return [] unless params_node
62
+
63
+ result = []
64
+
65
+ # Required parameters
66
+ (params_node.requireds || []).each do |p|
67
+ result << param_name(p)
68
+ end
69
+
70
+ # Optional parameters
71
+ (params_node.optionals || []).each do |p|
72
+ result << "#{param_name(p)}=..."
73
+ end
74
+
75
+ # Rest parameter
76
+ if params_node.rest && !params_node.rest.is_a?(Prism::ImplicitRestNode)
77
+ name = params_node.rest.name
78
+ result << "*#{name || ''}"
79
+ end
80
+
81
+ # Keyword parameters
82
+ (params_node.keywords || []).each do |p|
83
+ case p
84
+ when Prism::RequiredKeywordParameterNode
85
+ result << "#{p.name}:"
86
+ when Prism::OptionalKeywordParameterNode
87
+ result << "#{p.name}: ..."
88
+ end
89
+ end
90
+
91
+ # Keyword rest
92
+ if params_node.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
93
+ name = params_node.keyword_rest.name
94
+ result << "**#{name || ''}"
95
+ end
96
+
97
+ # Block parameter
98
+ if params_node.block
99
+ result << "&#{params_node.block.name || ''}"
100
+ end
101
+
102
+ result
103
+ end
104
+
105
+ def param_name(node)
106
+ case node
107
+ when Prism::RequiredParameterNode
108
+ node.name.to_s
109
+ when Prism::OptionalParameterNode
110
+ node.name.to_s
111
+ else
112
+ node.respond_to?(:name) ? node.name.to_s : "_"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ class Memory
5
+ attr_reader :short_term, :long_term, :summaries
6
+
7
+ def initialize
8
+ @short_term = []
9
+ @long_term = []
10
+ @summaries = []
11
+ @next_id = 1
12
+ @compact_mutex = Mutex.new
13
+ @compact_thread = nil
14
+ load_long_term
15
+ end
16
+
17
+ # --- Class methods ---
18
+
19
+ class << self
20
+ def current
21
+ return nil if incognito?
22
+
23
+ Thread.current[:mana_memory] ||= new
24
+ end
25
+
26
+ def incognito?
27
+ Thread.current[:mana_incognito] == true
28
+ end
29
+
30
+ def incognito(&block)
31
+ previous_memory = Thread.current[:mana_memory]
32
+ previous_incognito = Thread.current[:mana_incognito]
33
+ Thread.current[:mana_incognito] = true
34
+ Thread.current[:mana_memory] = nil
35
+ block.call
36
+ ensure
37
+ Thread.current[:mana_incognito] = previous_incognito
38
+ Thread.current[:mana_memory] = previous_memory
39
+ end
40
+ end
41
+
42
+ # --- Token estimation ---
43
+
44
+ def token_count
45
+ count = 0
46
+ @short_term.each do |msg|
47
+ content = msg[:content]
48
+ case content
49
+ when String
50
+ count += estimate_tokens(content)
51
+ when Array
52
+ content.each do |block|
53
+ count += estimate_tokens(block[:text] || block[:content] || "")
54
+ end
55
+ end
56
+ end
57
+ @long_term.each { |m| count += estimate_tokens(m[:content]) }
58
+ @summaries.each { |s| count += estimate_tokens(s) }
59
+ count
60
+ end
61
+
62
+ # --- Memory management ---
63
+
64
+ def clear!
65
+ clear_short_term!
66
+ clear_long_term!
67
+ end
68
+
69
+ def clear_short_term!
70
+ @short_term.clear
71
+ @summaries.clear
72
+ end
73
+
74
+ def clear_long_term!
75
+ @long_term.clear
76
+ store.clear(namespace)
77
+ end
78
+
79
+ def forget(id:)
80
+ @long_term.reject! { |m| m[:id] == id }
81
+ store.write(namespace, @long_term)
82
+ end
83
+
84
+ def remember(content)
85
+ entry = { id: @next_id, content: content, created_at: Time.now.iso8601 }
86
+ @next_id += 1
87
+ @long_term << entry
88
+ store.write(namespace, @long_term)
89
+ entry
90
+ end
91
+
92
+ # --- Compaction ---
93
+
94
+ def compact!
95
+ wait_for_compaction
96
+ perform_compaction
97
+ end
98
+
99
+ def needs_compaction?
100
+ cw = context_window
101
+ token_count > (cw * Mana.config.memory_pressure)
102
+ end
103
+
104
+ def schedule_compaction
105
+ return unless needs_compaction?
106
+
107
+ @compact_mutex.synchronize do
108
+ return if @compact_thread&.alive?
109
+
110
+ @compact_thread = Thread.new do
111
+ perform_compaction
112
+ rescue => e
113
+ # Silently handle compaction errors — don't crash the main thread
114
+ $stderr.puts "Mana compaction error: #{e.message}" if $DEBUG
115
+ end
116
+ end
117
+ end
118
+
119
+ def wait_for_compaction
120
+ thread = @compact_mutex.synchronize { @compact_thread }
121
+ thread&.join
122
+ end
123
+
124
+ # --- Display ---
125
+
126
+ def inspect
127
+ "#<Mana::Memory long_term=#{@long_term.size}, short_term=#{short_term_rounds} rounds, tokens=#{token_count}/#{context_window}>"
128
+ end
129
+
130
+ private
131
+
132
+ def short_term_rounds
133
+ @short_term.count { |m| m[:role] == "user" && m[:content].is_a?(String) }
134
+ end
135
+
136
+ def estimate_tokens(text)
137
+ return 0 unless text.is_a?(String)
138
+
139
+ # Rough estimate: ~4 chars per token
140
+ (text.length / 4.0).ceil
141
+ end
142
+
143
+ def context_window
144
+ Mana.config.context_window || ContextWindow.detect(Mana.config.model)
145
+ end
146
+
147
+ def store
148
+ Mana.config.memory_store || default_store
149
+ end
150
+
151
+ def default_store
152
+ @default_store ||= FileStore.new
153
+ end
154
+
155
+ def namespace
156
+ Namespace.detect
157
+ end
158
+
159
+ def load_long_term
160
+ return if self.class.incognito?
161
+
162
+ @long_term = store.read(namespace)
163
+ @next_id = (@long_term.map { |m| m[:id] }.max || 0) + 1
164
+ end
165
+
166
+ def perform_compaction
167
+ keep_recent = Mana.config.memory_keep_recent
168
+ # Count user-prompt messages (rounds)
169
+ user_indices = @short_term.each_with_index
170
+ .select { |msg, _| msg[:role] == "user" && msg[:content].is_a?(String) }
171
+ .map(&:last)
172
+
173
+ return if user_indices.size <= keep_recent
174
+
175
+ # Find the cutoff point: keep the last N rounds
176
+ cutoff_user_idx = user_indices[-(keep_recent)]
177
+ old_messages = @short_term[0...cutoff_user_idx]
178
+ return if old_messages.empty?
179
+
180
+ # Build text from old messages for summarization
181
+ text_parts = old_messages.map do |msg|
182
+ content = msg[:content]
183
+ case content
184
+ when String then "#{msg[:role]}: #{content}"
185
+ when Array
186
+ texts = content.map { |b| b[:text] || b[:content] }.compact
187
+ "#{msg[:role]}: #{texts.join(' ')}" unless texts.empty?
188
+ end
189
+ end.compact
190
+
191
+ return if text_parts.empty?
192
+
193
+ summary = summarize(text_parts.join("\n"))
194
+
195
+ # Replace old messages with summary
196
+ @short_term = @short_term[cutoff_user_idx..]
197
+ @summaries << summary
198
+
199
+ Mana.config.on_compact&.call(summary)
200
+ end
201
+
202
+ def summarize(text)
203
+ config = Mana.config
204
+ model = config.compact_model || config.model
205
+ uri = URI("#{config.base_url}/v1/messages")
206
+
207
+ body = {
208
+ model: model,
209
+ max_tokens: 1024,
210
+ system: "Summarize this conversation concisely. Preserve key facts, decisions, and context.",
211
+ messages: [{ role: "user", content: text }]
212
+ }
213
+
214
+ http = Net::HTTP.new(uri.host, uri.port)
215
+ http.use_ssl = uri.scheme == "https"
216
+ http.read_timeout = 60
217
+
218
+ req = Net::HTTP::Post.new(uri)
219
+ req["Content-Type"] = "application/json"
220
+ req["x-api-key"] = config.api_key
221
+ req["anthropic-version"] = "2023-06-01"
222
+ req.body = JSON.generate(body)
223
+
224
+ res = http.request(req)
225
+ return "Summary unavailable" unless res.is_a?(Net::HTTPSuccess)
226
+
227
+ parsed = JSON.parse(res.body, symbolize_names: true)
228
+ content = parsed[:content]
229
+ return "Summary unavailable" unless content.is_a?(Array)
230
+
231
+ content.map { |b| b[:text] }.compact.join("\n")
232
+ rescue => _e
233
+ "Summary unavailable"
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Mana
7
+ class MemoryStore
8
+ def read(namespace)
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def write(namespace, memories)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def clear(namespace)
17
+ raise NotImplementedError
18
+ end
19
+ end
20
+
21
+ class FileStore < MemoryStore
22
+ def initialize(base_path = nil)
23
+ @base_path = base_path
24
+ end
25
+
26
+ def read(namespace)
27
+ path = file_path(namespace)
28
+ return [] unless File.exist?(path)
29
+
30
+ data = JSON.parse(File.read(path), symbolize_names: true)
31
+ data.is_a?(Array) ? data : []
32
+ rescue JSON::ParserError
33
+ []
34
+ end
35
+
36
+ def write(namespace, memories)
37
+ path = file_path(namespace)
38
+ FileUtils.mkdir_p(File.dirname(path))
39
+ File.write(path, JSON.pretty_generate(memories))
40
+ end
41
+
42
+ def clear(namespace)
43
+ path = file_path(namespace)
44
+ File.delete(path) if File.exist?(path)
45
+ end
46
+
47
+ private
48
+
49
+ def file_path(namespace)
50
+ File.join(base_dir, "#{namespace}.json")
51
+ end
52
+
53
+ def base_dir
54
+ return File.join(@base_path, "memory") if @base_path
55
+
56
+ custom_path = Mana.config.memory_path
57
+ return File.join(custom_path, "memory") if custom_path
58
+
59
+ xdg = ENV["XDG_DATA_HOME"]
60
+ if xdg && !xdg.empty?
61
+ File.join(xdg, "mana", "memory")
62
+ elsif RUBY_PLATFORM.include?("darwin")
63
+ File.join(Dir.home, "Library", "Application Support", "mana", "memory")
64
+ else
65
+ File.join(Dir.home, ".local", "share", "mana", "memory")
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/mana/mixin.rb CHANGED
@@ -1,12 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Include in classes to use ~"..." in instance methods
5
+ # and `mana def` for LLM-compiled methods.
7
6
  module Mixin
8
7
  def self.included(base)
9
- # Reserved for future: auto-expose methods, etc.
8
+ base.extend(ClassMethods)
10
9
  end
10
+
11
+ module ClassMethods
12
+ # Mark a method for LLM compilation.
13
+ # Usage:
14
+ # mana def fizzbuzz(n)
15
+ # ~"return FizzBuzz array from 1 to n"
16
+ # end
17
+ def mana(method_name)
18
+ Mana::Compiler.compile(self, method_name)
19
+ method_name
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ # Make `mana def` available at the top level (main object)
26
+ class << self
27
+ def mana(method_name)
28
+ Mana::Compiler.compile(Object, method_name)
29
+ method_name
11
30
  end
12
31
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ module Namespace
5
+ class << self
6
+ def detect
7
+ configured || from_git_repo || from_gemfile_dir || from_pwd || "default"
8
+ end
9
+
10
+ def configured
11
+ ns = Mana.config.namespace
12
+ ns unless ns.nil? || ns.to_s.empty?
13
+ end
14
+
15
+ def from_git_repo
16
+ dir = `git rev-parse --show-toplevel 2>/dev/null`.strip
17
+ return nil if dir.empty?
18
+
19
+ File.basename(dir)
20
+ end
21
+
22
+ def from_gemfile_dir
23
+ dir = Dir.pwd
24
+ loop do
25
+ return File.basename(dir) if File.exist?(File.join(dir, "Gemfile"))
26
+
27
+ parent = File.dirname(dir)
28
+ return nil if parent == dir
29
+
30
+ dir = parent
31
+ end
32
+ end
33
+
34
+ def from_pwd
35
+ File.basename(Dir.pwd)
36
+ end
37
+ end
38
+ end
39
+ end