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
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
|
-
|
|
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:
|
|
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
|
data/lib/mana/memory.rb
ADDED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|