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.
@@ -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
- methods << { name: node.name.to_s, params: params }
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
- " #{sig}"
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 : "_"
@@ -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 || ContextWindow.detect(Mana.config.model)
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
- Namespace.detect
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
- # Count user-prompt messages (rounds)
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: keep the last N rounds
176
- cutoff_user_idx = user_indices[-(keep_recent)]
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
- summary = summarize(text_parts.join("\n"))
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
- # Replace old messages with summary
196
- @short_term = @short_term[cutoff_user_idx..]
197
- @summaries << summary
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
- def summarize(text)
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
- 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
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
@@ -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
- 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
75
+ # Default fallback
76
+ File.join(Dir.home, ".mana", "memory")
67
77
  end
68
78
  end
69
79
  end