ruby-mana 0.5.1 → 0.5.7
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/CHANGELOG.md +70 -17
- data/LICENSE +1 -1
- data/README.md +189 -166
- data/lib/mana/backends/anthropic.rb +9 -27
- data/lib/mana/backends/base.rb +51 -4
- data/lib/mana/backends/openai.rb +17 -42
- data/lib/mana/compiler.rb +162 -46
- data/lib/mana/config.rb +94 -6
- data/lib/mana/engine.rb +628 -38
- data/lib/mana/introspect.rb +58 -19
- data/lib/mana/logger.rb +99 -0
- data/lib/mana/memory.rb +132 -39
- data/lib/mana/memory_store.rb +18 -8
- data/lib/mana/mixin.rb +2 -2
- data/lib/mana/mock.rb +40 -0
- data/lib/mana/security_policy.rb +195 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +7 -30
- metadata +12 -38
- data/data/lang-rules.yml +0 -196
- data/lib/mana/backends/registry.rb +0 -23
- data/lib/mana/context_window.rb +0 -28
- data/lib/mana/effect_registry.rb +0 -155
- data/lib/mana/engines/base.rb +0 -79
- data/lib/mana/engines/detect.rb +0 -93
- data/lib/mana/engines/javascript.rb +0 -314
- data/lib/mana/engines/llm.rb +0 -467
- data/lib/mana/engines/python.rb +0 -314
- data/lib/mana/engines/ruby_eval.rb +0 -11
- data/lib/mana/namespace.rb +0 -39
- data/lib/mana/object_registry.rb +0 -89
- data/lib/mana/remote_ref.rb +0 -85
- data/lib/mana/test.rb +0 -18
data/lib/mana/introspect.rb
CHANGED
|
@@ -4,18 +4,17 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
module Mana
|
|
6
6
|
# Introspects the caller's source file to discover user-defined methods.
|
|
7
|
-
# Uses Prism AST to extract `def` nodes with their parameter signatures
|
|
7
|
+
# Uses Prism AST to extract `def` nodes with their parameter signatures,
|
|
8
|
+
# descriptions (from comments above the def), and parameter types (from YARD @param tags).
|
|
8
9
|
module Introspect
|
|
9
10
|
class << self
|
|
10
11
|
# 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
|
|
12
|
+
# Returns an array of { name:, params:, description:, param_types: } hashes.
|
|
15
13
|
def methods_from_file(path)
|
|
16
14
|
return [] unless path && File.exist?(path)
|
|
17
15
|
|
|
18
16
|
source = File.read(path)
|
|
17
|
+
source_lines = source.lines
|
|
19
18
|
result = Prism.parse(source)
|
|
20
19
|
methods = []
|
|
21
20
|
|
|
@@ -23,22 +22,30 @@ module Mana
|
|
|
23
22
|
next unless node.is_a?(Prism::DefNode)
|
|
24
23
|
|
|
25
24
|
params = extract_params(node)
|
|
26
|
-
|
|
25
|
+
description, param_types = extract_comments(node, source_lines)
|
|
26
|
+
methods << {
|
|
27
|
+
name: node.name.to_s,
|
|
28
|
+
params: params,
|
|
29
|
+
description: description,
|
|
30
|
+
param_types: param_types
|
|
31
|
+
}
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
methods
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
# 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
|
|
38
|
+
# Includes descriptions when available.
|
|
36
39
|
def format_for_prompt(methods)
|
|
37
40
|
return "" if methods.empty?
|
|
38
41
|
|
|
39
42
|
lines = methods.map do |m|
|
|
40
43
|
sig = m[:params].empty? ? m[:name] : "#{m[:name]}(#{m[:params].join(', ')})"
|
|
41
|
-
|
|
44
|
+
if m[:description]
|
|
45
|
+
" #{sig} — #{m[:description]}"
|
|
46
|
+
else
|
|
47
|
+
" #{sig}"
|
|
48
|
+
end
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
"Available Ruby functions:\n#{lines.join("\n")}"
|
|
@@ -46,6 +53,7 @@ module Mana
|
|
|
46
53
|
|
|
47
54
|
private
|
|
48
55
|
|
|
56
|
+
# Breadth-first walk over the AST, yielding each node to the block
|
|
49
57
|
def walk(node, &block)
|
|
50
58
|
queue = [node]
|
|
51
59
|
while (current = queue.shift)
|
|
@@ -56,29 +64,64 @@ module Mana
|
|
|
56
64
|
end
|
|
57
65
|
end
|
|
58
66
|
|
|
67
|
+
# Extract description and @param types from comments above a def node.
|
|
68
|
+
# Supports YARD-style comments:
|
|
69
|
+
# # Description text here
|
|
70
|
+
# # @param name [Type] description
|
|
71
|
+
def extract_comments(def_node, source_lines)
|
|
72
|
+
def_line = def_node.location.start_line - 1 # 0-indexed
|
|
73
|
+
description_parts = []
|
|
74
|
+
param_types = {}
|
|
75
|
+
|
|
76
|
+
# Walk backwards from the line above def, collecting # comments
|
|
77
|
+
line_idx = def_line - 1
|
|
78
|
+
comment_lines = []
|
|
79
|
+
while line_idx >= 0
|
|
80
|
+
line = source_lines[line_idx]&.strip
|
|
81
|
+
break unless line&.start_with?("#")
|
|
82
|
+
comment_lines.unshift(line)
|
|
83
|
+
line_idx -= 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
comment_lines.each do |line|
|
|
87
|
+
text = line.sub(/^#\s?/, "")
|
|
88
|
+
if text.match?(/^@param\s/)
|
|
89
|
+
# @param name [Type] description
|
|
90
|
+
match = text.match(/^@param\s+(\w+)\s+\[(\w+)\]/)
|
|
91
|
+
if match
|
|
92
|
+
param_types[match[1]] = match[2].downcase
|
|
93
|
+
end
|
|
94
|
+
elsif text.match?(/^@return\s/)
|
|
95
|
+
# Skip @return tags
|
|
96
|
+
else
|
|
97
|
+
description_parts << text unless text.empty?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
description = description_parts.empty? ? nil : description_parts.join(" ")
|
|
102
|
+
[description, param_types]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Extract all parameter signatures from a DefNode's parameter list.
|
|
59
106
|
def extract_params(def_node)
|
|
60
107
|
params_node = def_node.parameters
|
|
61
108
|
return [] unless params_node
|
|
62
109
|
|
|
63
110
|
result = []
|
|
64
111
|
|
|
65
|
-
# Required parameters
|
|
66
112
|
(params_node.requireds || []).each do |p|
|
|
67
113
|
result << param_name(p)
|
|
68
114
|
end
|
|
69
115
|
|
|
70
|
-
# Optional parameters
|
|
71
116
|
(params_node.optionals || []).each do |p|
|
|
72
117
|
result << "#{param_name(p)}=..."
|
|
73
118
|
end
|
|
74
119
|
|
|
75
|
-
# Rest parameter
|
|
76
120
|
if params_node.rest && !params_node.rest.is_a?(Prism::ImplicitRestNode)
|
|
77
121
|
name = params_node.rest.name
|
|
78
122
|
result << "*#{name || ''}"
|
|
79
123
|
end
|
|
80
124
|
|
|
81
|
-
# Keyword parameters
|
|
82
125
|
(params_node.keywords || []).each do |p|
|
|
83
126
|
case p
|
|
84
127
|
when Prism::RequiredKeywordParameterNode
|
|
@@ -88,13 +131,11 @@ module Mana
|
|
|
88
131
|
end
|
|
89
132
|
end
|
|
90
133
|
|
|
91
|
-
# Keyword rest
|
|
92
134
|
if params_node.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
|
|
93
135
|
name = params_node.keyword_rest.name
|
|
94
136
|
result << "**#{name || ''}"
|
|
95
137
|
end
|
|
96
138
|
|
|
97
|
-
# Block parameter
|
|
98
139
|
if params_node.block
|
|
99
140
|
result << "&#{params_node.block.name || ''}"
|
|
100
141
|
end
|
|
@@ -104,9 +145,7 @@ module Mana
|
|
|
104
145
|
|
|
105
146
|
def param_name(node)
|
|
106
147
|
case node
|
|
107
|
-
when Prism::RequiredParameterNode
|
|
108
|
-
node.name.to_s
|
|
109
|
-
when Prism::OptionalParameterNode
|
|
148
|
+
when Prism::RequiredParameterNode, Prism::OptionalParameterNode
|
|
110
149
|
node.name.to_s
|
|
111
150
|
else
|
|
112
151
|
node.respond_to?(:name) ? node.name.to_s : "_"
|
data/lib/mana/logger.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
# Verbose logging utilities for tracing LLM interactions.
|
|
5
|
+
# Included by Engine — provides vlog, vlog_value, vlog_code, etc.
|
|
6
|
+
# All methods are no-ops unless @config.verbose is true.
|
|
7
|
+
module Logger
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# Log a debug message to stderr
|
|
11
|
+
def vlog(msg)
|
|
12
|
+
return unless @config.verbose
|
|
13
|
+
|
|
14
|
+
$stderr.puts "\e[2m[mana] #{msg}\e[0m"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Log a value with smart formatting:
|
|
18
|
+
# - Multi-line strings → highlighted code block
|
|
19
|
+
# - Long strings (>200 chars) → truncated with char count
|
|
20
|
+
# - Long arrays/hashes (>5 items) → truncated with item count
|
|
21
|
+
# - Everything else → inline inspect
|
|
22
|
+
def vlog_value(prefix, value)
|
|
23
|
+
return unless @config.verbose
|
|
24
|
+
|
|
25
|
+
case value
|
|
26
|
+
when String
|
|
27
|
+
if value.include?("\n")
|
|
28
|
+
vlog(prefix)
|
|
29
|
+
vlog_code(value)
|
|
30
|
+
elsif value.length > 200
|
|
31
|
+
vlog("#{prefix} #{value[0, 80].inspect}... (#{value.length} chars)")
|
|
32
|
+
else
|
|
33
|
+
vlog("#{prefix} #{value.inspect}")
|
|
34
|
+
end
|
|
35
|
+
when Array
|
|
36
|
+
if value.length > 5
|
|
37
|
+
preview = value.first(3).map(&:inspect).join(", ")
|
|
38
|
+
vlog("#{prefix} [#{preview}, ...] (#{value.length} items)")
|
|
39
|
+
else
|
|
40
|
+
vlog("#{prefix} #{value.inspect}")
|
|
41
|
+
end
|
|
42
|
+
when Hash
|
|
43
|
+
if value.length > 5
|
|
44
|
+
preview = value.first(3).map { |k, v| "#{k.inspect}=>#{v.inspect}" }.join(", ")
|
|
45
|
+
vlog("#{prefix} {#{preview}, ...} (#{value.length} keys)")
|
|
46
|
+
else
|
|
47
|
+
vlog("#{prefix} #{value.inspect}")
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
str = value.inspect
|
|
51
|
+
if str.length > 200
|
|
52
|
+
vlog("#{prefix} #{str[0, 80]}... (#{str.length} chars)")
|
|
53
|
+
else
|
|
54
|
+
vlog("#{prefix} #{str}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Log a code block with Ruby syntax highlighting to stderr
|
|
60
|
+
def vlog_code(code)
|
|
61
|
+
return unless @config.verbose
|
|
62
|
+
|
|
63
|
+
highlighted = highlight_ruby(code)
|
|
64
|
+
highlighted.each_line do |line|
|
|
65
|
+
$stderr.puts "\e[2m[mana]\e[0m #{line.rstrip}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Summarize tool input for compact logging.
|
|
70
|
+
# Multi-line string values are replaced with a brief summary.
|
|
71
|
+
def summarize_input(input)
|
|
72
|
+
return input.inspect unless input.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
summarized = input.map do |k, v|
|
|
75
|
+
if v.is_a?(String) && v.include?("\n")
|
|
76
|
+
lines = v.lines.size
|
|
77
|
+
words = v.split.size
|
|
78
|
+
first = v.lines.first&.strip&.slice(0, 30)
|
|
79
|
+
"#{k}: \"#{first}...\" (#{lines} lines, #{words} words)"
|
|
80
|
+
elsif v.is_a?(String) && v.length > 100
|
|
81
|
+
"#{k}: \"#{v[0, 50]}...\" (#{v.length} chars)"
|
|
82
|
+
else
|
|
83
|
+
"#{k}: #{v.inspect}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
"{#{summarized.join(', ')}}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Minimal Ruby syntax highlighter using ANSI escape codes
|
|
90
|
+
def highlight_ruby(code)
|
|
91
|
+
code
|
|
92
|
+
.gsub(/\b(def|end|do|if|elsif|else|unless|case|when|class|module|return|require|include|raise|begin|rescue|ensure|yield|while|until|for|break|next|nil|true|false|self)\b/) { "\e[35m#{$1}\e[0m" }
|
|
93
|
+
.gsub(/(["'])(?:(?=(\\?))\2.)*?\1/) { "\e[32m#{$&}\e[0m" }
|
|
94
|
+
.gsub(/(#[^\n]*)/) { "\e[2m#{$1}\e[0m" }
|
|
95
|
+
.gsub(/\b(\d+(?:\.\d+)?)\b/) { "\e[33m#{$1}\e[0m" }
|
|
96
|
+
.gsub(/(:[\w]+)/) { "\e[36m#{$1}\e[0m" }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/mana/memory.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Mana
|
|
|
4
4
|
class Memory
|
|
5
5
|
attr_reader :short_term, :long_term, :summaries
|
|
6
6
|
|
|
7
|
+
# Initialize with empty short-term and load persisted long-term memories from disk
|
|
7
8
|
def initialize
|
|
8
9
|
@short_term = []
|
|
9
10
|
@long_term = []
|
|
@@ -17,22 +18,27 @@ module Mana
|
|
|
17
18
|
# --- Class methods ---
|
|
18
19
|
|
|
19
20
|
class << self
|
|
21
|
+
# Return the current thread's memory instance (lazy-initialized).
|
|
22
|
+
# Returns nil in incognito mode.
|
|
20
23
|
def current
|
|
21
24
|
return nil if incognito?
|
|
22
25
|
|
|
23
26
|
Thread.current[:mana_memory] ||= new
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
# Check if the current thread is in incognito mode (no memory)
|
|
26
30
|
def incognito?
|
|
27
31
|
Thread.current[:mana_incognito] == true
|
|
28
32
|
end
|
|
29
33
|
|
|
34
|
+
# Run a block with memory disabled. Saves and restores previous state.
|
|
30
35
|
def incognito(&block)
|
|
31
36
|
previous_memory = Thread.current[:mana_memory]
|
|
32
37
|
previous_incognito = Thread.current[:mana_incognito]
|
|
33
38
|
Thread.current[:mana_incognito] = true
|
|
34
39
|
Thread.current[:mana_memory] = nil
|
|
35
40
|
block.call
|
|
41
|
+
# Always restore previous state, even if the block raises
|
|
36
42
|
ensure
|
|
37
43
|
Thread.current[:mana_incognito] = previous_incognito
|
|
38
44
|
Thread.current[:mana_memory] = previous_memory
|
|
@@ -41,14 +47,18 @@ module Mana
|
|
|
41
47
|
|
|
42
48
|
# --- Token estimation ---
|
|
43
49
|
|
|
50
|
+
# Estimate total token count across short-term messages, long-term facts, and summaries.
|
|
51
|
+
# Used to determine when memory compaction is needed.
|
|
44
52
|
def token_count
|
|
45
53
|
count = 0
|
|
46
54
|
@short_term.each do |msg|
|
|
47
55
|
content = msg[:content]
|
|
48
56
|
case content
|
|
49
57
|
when String
|
|
58
|
+
# Plain text message
|
|
50
59
|
count += estimate_tokens(content)
|
|
51
60
|
when Array
|
|
61
|
+
# Array of content blocks (tool_use, tool_result, text)
|
|
52
62
|
content.each do |block|
|
|
53
63
|
count += estimate_tokens(block[:text] || block[:content] || "")
|
|
54
64
|
end
|
|
@@ -61,27 +71,37 @@ module Mana
|
|
|
61
71
|
|
|
62
72
|
# --- Memory management ---
|
|
63
73
|
|
|
74
|
+
# Clear both short-term and long-term memory
|
|
64
75
|
def clear!
|
|
65
76
|
clear_short_term!
|
|
66
77
|
clear_long_term!
|
|
67
78
|
end
|
|
68
79
|
|
|
80
|
+
# Clear conversation history and compaction summaries
|
|
69
81
|
def clear_short_term!
|
|
70
82
|
@short_term.clear
|
|
71
83
|
@summaries.clear
|
|
72
84
|
end
|
|
73
85
|
|
|
86
|
+
# Clear persistent memories from both in-memory array and disk
|
|
74
87
|
def clear_long_term!
|
|
75
88
|
@long_term.clear
|
|
76
89
|
store.clear(namespace)
|
|
77
90
|
end
|
|
78
91
|
|
|
92
|
+
# Remove a specific long-term memory by ID and persist the change
|
|
79
93
|
def forget(id:)
|
|
80
94
|
@long_term.reject! { |m| m[:id] == id }
|
|
81
95
|
store.write(namespace, @long_term)
|
|
82
96
|
end
|
|
83
97
|
|
|
98
|
+
# Store a fact in long-term memory. Deduplicates by content.
|
|
99
|
+
# Persists to disk immediately after adding.
|
|
84
100
|
def remember(content)
|
|
101
|
+
# Deduplicate: skip if identical content already exists
|
|
102
|
+
existing = @long_term.find { |e| e[:content] == content }
|
|
103
|
+
return existing if existing
|
|
104
|
+
|
|
85
105
|
entry = { id: @next_id, content: content, created_at: Time.now.iso8601 }
|
|
86
106
|
@next_id += 1
|
|
87
107
|
@long_term << entry
|
|
@@ -91,20 +111,25 @@ module Mana
|
|
|
91
111
|
|
|
92
112
|
# --- Compaction ---
|
|
93
113
|
|
|
114
|
+
# Synchronous compaction: wait for any background run, then compact immediately
|
|
94
115
|
def compact!
|
|
95
116
|
wait_for_compaction
|
|
96
117
|
perform_compaction
|
|
97
118
|
end
|
|
98
119
|
|
|
120
|
+
# Check if token usage exceeds the configured memory pressure threshold
|
|
99
121
|
def needs_compaction?
|
|
100
122
|
cw = context_window
|
|
101
123
|
token_count > (cw * Mana.config.memory_pressure)
|
|
102
124
|
end
|
|
103
125
|
|
|
126
|
+
# Launch background compaction if token pressure exceeds the threshold.
|
|
127
|
+
# Only one compaction thread runs at a time (guarded by mutex).
|
|
104
128
|
def schedule_compaction
|
|
105
129
|
return unless needs_compaction?
|
|
106
130
|
|
|
107
131
|
@compact_mutex.synchronize do
|
|
132
|
+
# Skip if a compaction is already in progress
|
|
108
133
|
return if @compact_thread&.alive?
|
|
109
134
|
|
|
110
135
|
@compact_thread = Thread.new do
|
|
@@ -116,6 +141,7 @@ module Mana
|
|
|
116
141
|
end
|
|
117
142
|
end
|
|
118
143
|
|
|
144
|
+
# Block until the background compaction thread finishes (if running)
|
|
119
145
|
def wait_for_compaction
|
|
120
146
|
thread = @compact_mutex.synchronize { @compact_thread }
|
|
121
147
|
thread&.join
|
|
@@ -123,65 +149,94 @@ module Mana
|
|
|
123
149
|
|
|
124
150
|
# --- Display ---
|
|
125
151
|
|
|
152
|
+
# Human-readable summary: counts and token usage
|
|
126
153
|
def inspect
|
|
127
154
|
"#<Mana::Memory long_term=#{@long_term.size}, short_term=#{short_term_rounds} rounds, tokens=#{token_count}/#{context_window}>"
|
|
128
155
|
end
|
|
129
156
|
|
|
130
157
|
private
|
|
131
158
|
|
|
159
|
+
# Count conversation rounds (user-prompt messages only, not tool results)
|
|
132
160
|
def short_term_rounds
|
|
133
161
|
@short_term.count { |m| m[:role] == "user" && m[:content].is_a?(String) }
|
|
134
162
|
end
|
|
135
163
|
|
|
164
|
+
# Rough token estimate: ~4 characters per token
|
|
136
165
|
def estimate_tokens(text)
|
|
137
166
|
return 0 unless text.is_a?(String)
|
|
138
167
|
|
|
139
|
-
# Rough estimate: ~4 chars per token
|
|
140
168
|
(text.length / 4.0).ceil
|
|
141
169
|
end
|
|
142
170
|
|
|
143
171
|
def context_window
|
|
144
|
-
Mana.config.context_window
|
|
172
|
+
Mana.config.context_window
|
|
145
173
|
end
|
|
146
174
|
|
|
175
|
+
# Resolve memory store: user config > default file-based store
|
|
147
176
|
def store
|
|
148
177
|
Mana.config.memory_store || default_store
|
|
149
178
|
end
|
|
150
179
|
|
|
180
|
+
# Lazy-initialized default FileStore singleton
|
|
151
181
|
def default_store
|
|
152
182
|
@default_store ||= FileStore.new
|
|
153
183
|
end
|
|
154
184
|
|
|
155
185
|
def namespace
|
|
156
|
-
|
|
186
|
+
ns = Mana.config.namespace
|
|
187
|
+
return ns if ns && !ns.to_s.empty?
|
|
188
|
+
|
|
189
|
+
dir = `git rev-parse --show-toplevel 2>/dev/null`.strip
|
|
190
|
+
return File.basename(dir) unless dir.empty?
|
|
191
|
+
|
|
192
|
+
d = Dir.pwd
|
|
193
|
+
loop do
|
|
194
|
+
return File.basename(d) if File.exist?(File.join(d, "Gemfile"))
|
|
195
|
+
parent = File.dirname(d)
|
|
196
|
+
break if parent == d
|
|
197
|
+
d = parent
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
File.basename(Dir.pwd)
|
|
157
201
|
end
|
|
158
202
|
|
|
203
|
+
# Load long-term memories from the persistent store on initialization.
|
|
204
|
+
# Skips loading in incognito mode.
|
|
159
205
|
def load_long_term
|
|
160
206
|
return if self.class.incognito?
|
|
161
207
|
|
|
162
208
|
@long_term = store.read(namespace)
|
|
209
|
+
# Set next ID to one past the highest existing ID
|
|
163
210
|
@next_id = (@long_term.map { |m| m[:id] }.max || 0) + 1
|
|
164
211
|
end
|
|
165
212
|
|
|
213
|
+
# Compact short-term memory: summarize old messages and keep only recent rounds.
|
|
214
|
+
# Merges existing summaries + old messages into a single new summary, so
|
|
215
|
+
# summaries don't accumulate unboundedly.
|
|
166
216
|
def perform_compaction
|
|
167
217
|
keep_recent = Mana.config.memory_keep_recent
|
|
168
|
-
#
|
|
218
|
+
# Find indices of user-prompt messages (each marks a conversation round)
|
|
169
219
|
user_indices = @short_term.each_with_index
|
|
170
220
|
.select { |msg, _| msg[:role] == "user" && msg[:content].is_a?(String) }
|
|
171
221
|
.map(&:last)
|
|
172
222
|
|
|
223
|
+
# Not enough rounds to compact — nothing to do
|
|
173
224
|
return if user_indices.size <= keep_recent
|
|
174
225
|
|
|
175
|
-
# Find the cutoff point:
|
|
176
|
-
|
|
226
|
+
# Find the cutoff point: everything before the last N rounds gets summarized
|
|
227
|
+
# Clamp keep_recent to avoid negative index beyond array bounds
|
|
228
|
+
keep = [keep_recent, user_indices.size].min
|
|
229
|
+
cutoff_user_idx = user_indices[-keep]
|
|
177
230
|
old_messages = @short_term[0...cutoff_user_idx]
|
|
178
231
|
return if old_messages.empty?
|
|
179
232
|
|
|
180
233
|
# Build text from old messages for summarization
|
|
181
234
|
text_parts = old_messages.map do |msg|
|
|
182
235
|
content = msg[:content]
|
|
236
|
+
# Format each message as "role: content" for the summarizer
|
|
183
237
|
case content
|
|
184
238
|
when String then "#{msg[:role]}: #{content}"
|
|
239
|
+
# Array blocks: extract text parts and join
|
|
185
240
|
when Array
|
|
186
241
|
texts = content.map { |b| b[:text] || b[:content] }.compact
|
|
187
242
|
"#{msg[:role]}: #{texts.join(' ')}" unless texts.empty?
|
|
@@ -190,46 +245,84 @@ module Mana
|
|
|
190
245
|
|
|
191
246
|
return if text_parts.empty?
|
|
192
247
|
|
|
193
|
-
|
|
248
|
+
# Merge existing summaries into the input so we produce ONE rolling summary
|
|
249
|
+
# instead of accumulating separate summaries that never get cleaned up
|
|
250
|
+
prior_context = ""
|
|
251
|
+
unless @summaries.empty?
|
|
252
|
+
prior_context = "Previous summary:\n#{@summaries.join("\n")}\n\nNew conversation:\n"
|
|
253
|
+
end
|
|
194
254
|
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
255
|
+
# Calculate how many tokens the kept messages will use after compaction
|
|
256
|
+
kept_messages = @short_term[cutoff_user_idx..]
|
|
257
|
+
keep_tokens = kept_messages.sum do |msg|
|
|
258
|
+
content = msg[:content]
|
|
259
|
+
case content
|
|
260
|
+
when String then estimate_tokens(content)
|
|
261
|
+
when Array then content.sum { |b| estimate_tokens(b[:text] || b[:content] || "") }
|
|
262
|
+
else 0
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
@long_term.each { |m| keep_tokens += estimate_tokens(m[:content]) }
|
|
266
|
+
|
|
267
|
+
# Call the LLM to produce a single merged summary
|
|
268
|
+
summary = summarize(prior_context + text_parts.join("\n"), keep_tokens: keep_tokens)
|
|
198
269
|
|
|
270
|
+
# Replace old messages with the summary, keeping only recent rounds.
|
|
271
|
+
# Clear all previous summaries — they are now merged into the new one.
|
|
272
|
+
@short_term = kept_messages
|
|
273
|
+
@summaries = [summary]
|
|
274
|
+
|
|
275
|
+
# Notify the on_compact callback if configured
|
|
199
276
|
Mana.config.on_compact&.call(summary)
|
|
200
277
|
end
|
|
201
278
|
|
|
202
|
-
|
|
279
|
+
# Call the LLM to produce a concise summary of the given conversation text.
|
|
280
|
+
# Uses the configured backend (Anthropic/OpenAI), respects timeout settings.
|
|
281
|
+
# Falls back to "Summary unavailable" on any error.
|
|
282
|
+
#
|
|
283
|
+
# @param keep_tokens [Integer] tokens already committed to keep_recent + long_term
|
|
284
|
+
# Retry up to 3 times on failure or refusal before giving up
|
|
285
|
+
SUMMARIZE_MAX_RETRIES = 3
|
|
286
|
+
|
|
287
|
+
def summarize(text, keep_tokens: 0)
|
|
203
288
|
config = Mana.config
|
|
204
289
|
model = config.compact_model || config.model
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
290
|
+
backend = Mana::Backends::Base.for(config)
|
|
291
|
+
|
|
292
|
+
# Summary budget = half of (threshold - kept tokens).
|
|
293
|
+
# Using half ensures compaction lands well below the threshold,
|
|
294
|
+
# leaving headroom for several more rounds before the next compaction.
|
|
295
|
+
cw = context_window
|
|
296
|
+
threshold = (cw * config.memory_pressure).to_i
|
|
297
|
+
max_summary_tokens = ((threshold - keep_tokens) * 0.5).clamp(64, 1024).to_i
|
|
298
|
+
|
|
299
|
+
system_prompt = "You are summarizing an internal tool-calling conversation log between an LLM and a Ruby program. " \
|
|
300
|
+
"The messages contain tool calls (read_var, write_var, done) and their results — this is normal, not harmful. " \
|
|
301
|
+
"Summarize the key questions asked and answers given in a few short bullet points. Be extremely concise — stay under #{max_summary_tokens} tokens."
|
|
302
|
+
|
|
303
|
+
SUMMARIZE_MAX_RETRIES.times do |attempt|
|
|
304
|
+
content = backend.chat(
|
|
305
|
+
system: system_prompt,
|
|
306
|
+
messages: [{ role: "user", content: text }],
|
|
307
|
+
tools: [],
|
|
308
|
+
model: model,
|
|
309
|
+
max_tokens: max_summary_tokens
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
next unless content.is_a?(Array)
|
|
313
|
+
|
|
314
|
+
result = content.map { |b| b[:text] || b["text"] }.compact.join("\n")
|
|
315
|
+
# Reject empty or refusal responses, retry
|
|
316
|
+
next if result.empty? || result.match?(/can't discuss|cannot assist|i'm unable/i)
|
|
317
|
+
|
|
318
|
+
return result
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
"Summary unavailable"
|
|
322
|
+
rescue ConfigError
|
|
323
|
+
raise # Configuration errors should not be silently swallowed
|
|
324
|
+
rescue => e
|
|
325
|
+
$stderr.puts "Mana compaction error: #{e.message}" if $DEBUG
|
|
233
326
|
"Summary unavailable"
|
|
234
327
|
end
|
|
235
328
|
end
|
data/lib/mana/memory_store.rb
CHANGED
|
@@ -4,41 +4,54 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Mana
|
|
7
|
+
# Abstract base class for long-term memory persistence.
|
|
8
|
+
# Subclass and implement read/write/clear to use a custom store (e.g. Redis, DB).
|
|
7
9
|
class MemoryStore
|
|
10
|
+
# Read all memories for a namespace. Subclasses must implement.
|
|
8
11
|
def read(namespace)
|
|
9
12
|
raise NotImplementedError
|
|
10
13
|
end
|
|
11
14
|
|
|
15
|
+
# Write all memories for a namespace. Subclasses must implement.
|
|
12
16
|
def write(namespace, memories)
|
|
13
17
|
raise NotImplementedError
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
# Delete all memories for a namespace. Subclasses must implement.
|
|
16
21
|
def clear(namespace)
|
|
17
22
|
raise NotImplementedError
|
|
18
23
|
end
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
|
|
27
|
+
# Default file-based memory store. Persists memories as JSON files.
|
|
28
|
+
# Storage path resolution: explicit base_path > config.memory_path > XDG_DATA_HOME > OS default
|
|
21
29
|
class FileStore < MemoryStore
|
|
30
|
+
# Optional base_path overrides default storage location
|
|
22
31
|
def initialize(base_path = nil)
|
|
23
32
|
@base_path = base_path
|
|
24
33
|
end
|
|
25
34
|
|
|
35
|
+
# Read all memories for a namespace from disk. Returns [] on missing file or parse error.
|
|
26
36
|
def read(namespace)
|
|
27
37
|
path = file_path(namespace)
|
|
28
38
|
return [] unless File.exist?(path)
|
|
29
39
|
|
|
30
40
|
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
31
41
|
data.is_a?(Array) ? data : []
|
|
42
|
+
# Corrupted JSON file — return empty array rather than crashing
|
|
32
43
|
rescue JSON::ParserError
|
|
33
44
|
[]
|
|
34
45
|
end
|
|
35
46
|
|
|
47
|
+
# Write all memories for a namespace to disk (overwrites existing file)
|
|
36
48
|
def write(namespace, memories)
|
|
37
49
|
path = file_path(namespace)
|
|
38
50
|
FileUtils.mkdir_p(File.dirname(path))
|
|
39
51
|
File.write(path, JSON.pretty_generate(memories))
|
|
40
52
|
end
|
|
41
53
|
|
|
54
|
+
# Delete the memory file for a namespace
|
|
42
55
|
def clear(namespace)
|
|
43
56
|
path = file_path(namespace)
|
|
44
57
|
File.delete(path) if File.exist?(path)
|
|
@@ -46,24 +59,21 @@ module Mana
|
|
|
46
59
|
|
|
47
60
|
private
|
|
48
61
|
|
|
62
|
+
# Build the full path for a namespace's JSON file
|
|
49
63
|
def file_path(namespace)
|
|
50
64
|
File.join(base_dir, "#{namespace}.json")
|
|
51
65
|
end
|
|
52
66
|
|
|
67
|
+
# Resolve the base directory for memory storage.
|
|
68
|
+
# Priority: explicit base_path > config.memory_path > ~/.mana/memory
|
|
53
69
|
def base_dir
|
|
54
70
|
return File.join(@base_path, "memory") if @base_path
|
|
55
71
|
|
|
56
72
|
custom_path = Mana.config.memory_path
|
|
57
73
|
return File.join(custom_path, "memory") if custom_path
|
|
58
74
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
75
|
+
# Default fallback
|
|
76
|
+
File.join(Dir.home, ".mana", "memory")
|
|
67
77
|
end
|
|
68
78
|
end
|
|
69
79
|
end
|